mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-26 14:30:40 +01:00
Compare commits
16 Commits
feat/initi
...
issue_5947
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fe033ec78 | ||
|
|
02a2800f89 | ||
|
|
02c5555a48 | ||
|
|
915aa2eb70 | ||
|
|
518caff0f2 | ||
|
|
0d3ac28286 | ||
|
|
47beef07de | ||
|
|
5f2891bd6c | ||
|
|
97f0e832ab | ||
|
|
0bdc7bf6a1 | ||
|
|
a8c04cb563 | ||
|
|
770a8f7b0a | ||
|
|
73719a3f60 | ||
|
|
724f7ce78b | ||
|
|
6f4af0a2a1 | ||
|
|
fe68b8e8b7 |
@@ -20,6 +20,16 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Extended fixtures:** For features needing complex setup (seeded data, API calls, cleanup), import from domain-specific fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the full pattern.
|
||||
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
|
||||
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
|
||||
```ts
|
||||
// Alert list tests - need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// Alert history tests - need evaluated history rows
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
|
||||
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
|
||||
|
||||
@@ -49,6 +49,7 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
|
||||
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
|
||||
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
|
||||
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
|
||||
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
|
||||
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
|
||||
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
|
||||
|
||||
@@ -7382,6 +7382,26 @@ components:
|
||||
- custom
|
||||
- text
|
||||
type: string
|
||||
QuickfiltertypesSignalFilters:
|
||||
properties:
|
||||
filters:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
type: string
|
||||
type: object
|
||||
QuickfiltertypesUpdatableQuickFilters:
|
||||
properties:
|
||||
filters:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
type: string
|
||||
type: object
|
||||
RenderErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
@@ -8010,6 +8030,7 @@ components:
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
- ai_observability
|
||||
type: string
|
||||
SavedviewtypesUpdatableSavedView:
|
||||
properties:
|
||||
@@ -18176,6 +18197,165 @@ paths:
|
||||
summary: Update my organization
|
||||
tags:
|
||||
- orgs
|
||||
/api/v2/orgs/me/filters:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the org's quick filters for every signal, each filter as
|
||||
a telemetry field key.
|
||||
operationId: ListQuickFilters
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:list
|
||||
- tokenizer:
|
||||
- quick-filter:list
|
||||
summary: List quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces the org's quick filters for the signal named in the body.
|
||||
operationId: UpdateQuickFilters
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/QuickfiltertypesUpdatableQuickFilters'
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:update
|
||||
- tokenizer:
|
||||
- quick-filter:update
|
||||
summary: Update quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
/api/v2/orgs/me/filters/{signal_name}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the org's quick filters for one signal, each filter as
|
||||
a telemetry field key.
|
||||
operationId: GetSignalQuickFilters
|
||||
parameters:
|
||||
- in: path
|
||||
name: signal_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:read
|
||||
- tokenizer:
|
||||
- quick-filter:read
|
||||
summary: Get a signal's quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
/api/v2/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -112,6 +112,41 @@ 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/`.
|
||||
|
||||
### 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>/`.
|
||||
|
||||
**Fixture scopes:**
|
||||
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
|
||||
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
|
||||
|
||||
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
|
||||
|
||||
```
|
||||
fixtures/alerts/
|
||||
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
|
||||
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
|
||||
```
|
||||
|
||||
Specs import from the fixture they need:
|
||||
|
||||
```ts
|
||||
// List tests — just need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// History tests — need history rows from ruler evaluation
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
|
||||
**When creating new fixtures:**
|
||||
|
||||
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
|
||||
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
|
||||
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
|
||||
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
|
||||
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
|
||||
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
@@ -232,11 +267,14 @@ cd tests/e2e
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single sub-area
|
||||
npx playwright test tests/alerts/history/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
npx playwright test tests/alerts/page.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
npx playwright test --project=chromium -g "AL-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
@@ -270,7 +308,14 @@ yarn test:staging
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) → `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
|
||||
|
||||
```bash
|
||||
# runs against a locally served frontend, not whatever .env.local points at
|
||||
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
|
||||
```
|
||||
|
||||
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
|
||||
|
||||
### Playwright options
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
// anything else
|
||||
console.error('any');
|
||||
console.error('ErrorResponseHandler: unclassified error');
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
|
||||
301
frontend/src/api/generated/services/quick-filter/index.ts
Normal file
301
frontend/src/api/generated/services/quick-filter/index.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
GetSignalQuickFilters200,
|
||||
GetSignalQuickFiltersPathParameters,
|
||||
ListQuickFilters200,
|
||||
QuickfiltertypesUpdatableQuickFiltersDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns the org's quick filters for every signal, each filter as a telemetry field key.
|
||||
* @summary List quick filters
|
||||
*/
|
||||
export const listQuickFilters = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListQuickFilters200>({
|
||||
url: `/api/v2/orgs/me/filters`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListQuickFiltersQueryKey = () => {
|
||||
return [`/api/v2/orgs/me/filters`] as const;
|
||||
};
|
||||
|
||||
export const getListQuickFiltersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListQuickFiltersQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listQuickFilters>>> = ({
|
||||
signal,
|
||||
}) => listQuickFilters(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListQuickFiltersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>
|
||||
>;
|
||||
export type ListQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List quick filters
|
||||
*/
|
||||
|
||||
export function useListQuickFilters<
|
||||
TData = Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListQuickFiltersQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List quick filters
|
||||
*/
|
||||
export const invalidateListQuickFilters = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListQuickFiltersQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the org's quick filters for the signal named in the body.
|
||||
* @summary Update quick filters
|
||||
*/
|
||||
export const updateQuickFilters = (
|
||||
quickfiltertypesUpdatableQuickFiltersDTO?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/orgs/me/filters`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: quickfiltertypesUpdatableQuickFiltersDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateQuickFiltersMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateQuickFilters'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return updateQuickFilters(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateQuickFiltersMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>
|
||||
>;
|
||||
export type UpdateQuickFiltersMutationBody =
|
||||
| BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>
|
||||
| undefined;
|
||||
export type UpdateQuickFiltersMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update quick filters
|
||||
*/
|
||||
export const useUpdateQuickFilters = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateQuickFiltersMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the org's quick filters for one signal, each filter as a telemetry field key.
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
export const getSignalQuickFilters = (
|
||||
{ signalName }: GetSignalQuickFiltersPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSignalQuickFilters200>({
|
||||
url: `/api/v2/orgs/me/filters/${signalName}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSignalQuickFiltersQueryKey = ({
|
||||
signalName,
|
||||
}: GetSignalQuickFiltersPathParameters) => {
|
||||
return [`/api/v2/orgs/me/filters/${signalName}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSignalQuickFiltersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ signalName }: GetSignalQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetSignalQuickFiltersQueryKey({ signalName });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>
|
||||
> = ({ signal }) => getSignalQuickFilters({ signalName }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!signalName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSignalQuickFiltersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>
|
||||
>;
|
||||
export type GetSignalQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
|
||||
export function useGetSignalQuickFilters<
|
||||
TData = Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ signalName }: GetSignalQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSignalQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSignalQuickFiltersQueryOptions(
|
||||
{ signalName },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
export const invalidateGetSignalQuickFilters = async (
|
||||
queryClient: QueryClient,
|
||||
{ signalName }: GetSignalQuickFiltersPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSignalQuickFiltersQueryKey({ signalName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
@@ -8435,6 +8435,28 @@ export enum Querybuildertypesv5QueryTypeDTO {
|
||||
clickhouse_sql = 'clickhouse_sql',
|
||||
promql = 'promql',
|
||||
}
|
||||
export interface QuickfiltertypesSignalFiltersDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface QuickfiltertypesUpdatableQuickFiltersDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal?: string;
|
||||
}
|
||||
|
||||
export interface RenderErrorResponseDTO {
|
||||
error: ErrorsJSONDTO;
|
||||
/**
|
||||
@@ -9021,6 +9043,7 @@ export enum SavedviewtypesSourceDTO {
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
ai_observability = 'ai_observability',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display?: SavedviewtypesDisplayDTO;
|
||||
@@ -11788,6 +11811,28 @@ export type GetMyOrganization200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListQuickFilters200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
data: QuickfiltertypesSignalFiltersDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetSignalQuickFiltersPathParameters = {
|
||||
signalName: string;
|
||||
};
|
||||
export type GetSignalQuickFilters200 = {
|
||||
data: QuickfiltertypesSignalFiltersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -8,12 +8,14 @@ export interface AlertBreadcrumbProps {
|
||||
items: BreadcrumbItemConfig[];
|
||||
className?: string;
|
||||
showDivider?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function AlertBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
showDivider = true,
|
||||
testId,
|
||||
}: AlertBreadcrumbProps): JSX.Element {
|
||||
const breadcrumbItems = items.map((item) => ({
|
||||
title: <BreadcrumbItem {...item} />,
|
||||
@@ -24,6 +26,7 @@ function AlertBreadcrumb({
|
||||
<Breadcrumb
|
||||
className={`${styles.breadcrumb} ${className || ''}`}
|
||||
items={breadcrumbItems}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{showDivider && <Divider className={styles.divider} />}
|
||||
</>
|
||||
|
||||
@@ -197,7 +197,7 @@ function FieldsSelector({
|
||||
() =>
|
||||
fields.map((f) => ({
|
||||
...f,
|
||||
key: buildCompositeKey(f.name, f.fieldContext),
|
||||
key: buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
})),
|
||||
[fields],
|
||||
);
|
||||
|
||||
@@ -52,13 +52,15 @@ function OtherFields({
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext, attr.fieldDataType),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType,
|
||||
}));
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => buildCompositeKey(f.name, f.fieldContext)),
|
||||
addedFields.map((f) =>
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
),
|
||||
);
|
||||
const available = suggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
|
||||
@@ -14,10 +14,10 @@ jest.mock('providers/App/App', () => ({
|
||||
useAppContext: (): { featureFlags: [] } => ({ featureFlags: [] }),
|
||||
}));
|
||||
|
||||
const field = (name: string, type = ''): IField => ({
|
||||
const field = (name: string, type = '', dataType = ''): IField => ({
|
||||
name,
|
||||
type,
|
||||
dataType: 'string',
|
||||
dataType,
|
||||
});
|
||||
|
||||
describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
@@ -136,6 +136,24 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
expect(byId.get('user_field')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('disambiguates same-name/same-context fields by dataType (3-part id)', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
fields: [
|
||||
field('http.status_code', 'attribute', 'int64'),
|
||||
field('http.status_code', 'attribute', 'string'),
|
||||
],
|
||||
fontSize: FontSize.SMALL,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'attribute:http.status_code:int64',
|
||||
'attribute:http.status_code:string',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders only the stateIndicator when fields is empty', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLogsTableColumns({
|
||||
|
||||
@@ -92,7 +92,7 @@ export function useLogsTableColumns({
|
||||
};
|
||||
|
||||
const makeUserFieldCol = (f: IField): TableColumnDef<ILog> => ({
|
||||
id: buildCompositeKey(f.name, f.type),
|
||||
id: buildCompositeKey(f.name, f.type, f.dataType),
|
||||
header: f.name,
|
||||
accessorFn: (log): unknown =>
|
||||
getLogFieldValue(log, f.name, isBodyJsonEnabled),
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
--tanstack-first-column-header-bg,
|
||||
var(--tanstack-table-header-cell-bg, var(--l2-background))
|
||||
) !important;
|
||||
padding-left: var(
|
||||
--tanstack-cell-header-padding-left-first-column,
|
||||
var(
|
||||
--tanstack-cell-header-padding-left-override,
|
||||
var(--tanstack-cell-padding-left, 0.3rem)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
.tableHeaderCell {
|
||||
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
|
||||
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
|
||||
height: 36px;
|
||||
height: var(--tanstack-table-header-height, 36px);
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -29,6 +29,7 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -40,6 +41,7 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
>
|
||||
<div className="icon">
|
||||
<DraftingCompass
|
||||
|
||||
@@ -26,7 +26,10 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -38,7 +41,10 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -50,7 +56,10 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -103,7 +112,12 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div
|
||||
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
|
||||
data-testid="stats-card"
|
||||
data-stats-title={title}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -123,7 +137,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,7 +81,11 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,11 +48,16 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card">
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="top-contributors-card__header">
|
||||
<div className="title">top contributors</div>
|
||||
{topContributorsData.length > 3 && (
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<Button
|
||||
type="text"
|
||||
className="view-all"
|
||||
onClick={toggleViewAllDrawer}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,7 +68,10 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div className="total-contribution">
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -78,7 +81,10 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,7 +31,10 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -32,8 +32,8 @@ function GraphWrapper({
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
|
||||
@@ -118,7 +118,10 @@ function TimelineTableContent(): JSX.Element {
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
@@ -128,12 +131,15 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-table">
|
||||
<div className="timeline-table" data-testid="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div className="timeline-table__filter-search">
|
||||
<div
|
||||
className="timeline-table__filter-search"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
@@ -155,6 +161,7 @@ function TimelineTableContent(): JSX.Element {
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -172,14 +179,17 @@ function TimelineTableContent(): JSX.Element {
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error">
|
||||
<div className="timeline-table__error" data-testid="timeline-error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div className="timeline-table__pagination-info">
|
||||
<div
|
||||
className="timeline-table__pagination-info"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
|
||||
@@ -21,18 +21,14 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
<AlertState state={value} showLabel testId="timeline-row-state" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'LABELS',
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
<AlertLabels labels={labels} testId="timeline-row-labels" />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -40,7 +36,10 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule__created-at">
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -53,7 +52,7 @@ export const timelineTableColumns = ({
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled>
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -65,7 +64,7 @@ export const timelineTableColumns = ({
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
|
||||
@@ -23,6 +23,7 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -33,6 +34,7 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -57,14 +59,17 @@ function TimelineFilters(): JSX.Element {
|
||||
{
|
||||
value: TimelineFilter.ALL,
|
||||
label: 'All',
|
||||
testId: 'timeline-filter-all',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.FIRED,
|
||||
label: 'Fired',
|
||||
testId: 'timeline-filter-fired',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.RESOLVED,
|
||||
label: 'Resolved',
|
||||
testId: 'timeline-filter-resolved',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
6
frontend/src/container/Controls/Controls.module.scss
Normal file
6
frontend/src/container/Controls/Controls.module.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
--button-font-size: var(--periscope-font-size-base, 13px);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { memo, useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
|
||||
import { Button, Flex, Select } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Select } from 'antd';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS, Pagination } from 'hooks/queryPagination';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { defaultSelectStyle } from './config';
|
||||
import { Container } from './styles';
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
function Controls({
|
||||
offset = 0,
|
||||
@@ -34,28 +35,24 @@ function Controls({
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className={styles.container}>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
type="link"
|
||||
variant="link"
|
||||
size="md"
|
||||
disabled={isPreviousDisabled}
|
||||
prefix={<ChevronLeft size={16} />}
|
||||
onClick={handleNavigatePrevious}
|
||||
>
|
||||
<Flex align="center" gap="4px">
|
||||
<ChevronLeft size={16} /> Previous
|
||||
</Flex>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
type="link"
|
||||
variant="link"
|
||||
size="md"
|
||||
disabled={isNextDisabled}
|
||||
suffix={<ChevronRight size={16} />}
|
||||
onClick={handleNavigateNext}
|
||||
>
|
||||
<Flex align="center" gap="4px">
|
||||
Next <ChevronRight size={16} />
|
||||
</Flex>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
{showSizeChanger && (
|
||||
@@ -74,7 +71,7 @@ function Controls({
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
@@ -34,6 +34,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
testId="send-notification-if-data-is-missing-input"
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
@@ -66,6 +67,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
testId="enforce-minimum-datapoints-input"
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,7 @@ function EvaluationWindowPopover({
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
data-testid={`${sectionId}-option-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -186,6 +186,7 @@ function Footer(): JSX.Element {
|
||||
color="primary"
|
||||
onClick={handleSaveAlert}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="save-alert-rule-button"
|
||||
>
|
||||
{isCreatingAlertRule || isUpdatingAlertRule ? (
|
||||
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
|
||||
@@ -218,6 +219,7 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleTestNotification}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="test-notification-button"
|
||||
>
|
||||
{isTestingAlertRule ? (
|
||||
<Loader data-testid="test-notification-loader-icon" size={14} />
|
||||
@@ -249,6 +251,7 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
disabled={disableButtons}
|
||||
testId="discard-alert-rule-button"
|
||||
>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
|
||||
@@ -119,6 +119,7 @@ function BasicInfo({
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
@@ -147,6 +148,7 @@ function BasicInfo({
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
@@ -161,6 +163,7 @@ function BasicInfo({
|
||||
name={['annotations', 'description']}
|
||||
>
|
||||
<TextareaMedium
|
||||
data-testid="alert-description-input"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
|
||||
@@ -105,7 +105,7 @@ function QuerySection({
|
||||
{
|
||||
label: (
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns">
|
||||
<Button className="nav-btns" data-testid="query-builder-tab">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
@@ -122,7 +122,11 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -162,7 +166,11 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -180,7 +188,11 @@ function QuerySection({
|
||||
: 'PromQL'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="promql-tab"
|
||||
>
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
|
||||
@@ -80,6 +80,7 @@ function RuleOptions({
|
||||
defaultValue={defaultCompareOp}
|
||||
value={alertDef.condition?.op}
|
||||
style={{ minWidth: '120px' }}
|
||||
data-testid="alert-threshold-op-select"
|
||||
onChange={(value: string | unknown): void => {
|
||||
const newOp = (value as string) || '';
|
||||
|
||||
@@ -116,6 +117,7 @@ function RuleOptions({
|
||||
defaultValue={defaultMatchType}
|
||||
style={{ minWidth: '130px' }}
|
||||
value={alertDef.condition?.matchType}
|
||||
data-testid="alert-threshold-match-type-select-v1"
|
||||
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
|
||||
>
|
||||
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
|
||||
@@ -177,6 +179,7 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -194,6 +197,7 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -395,6 +399,7 @@ function RuleOptions({
|
||||
value={alertDef?.condition?.target}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
data-testid="alert-threshold-target-input"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -844,8 +844,6 @@ function FormAlertRules({
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className={`form-alert-rules-container ${
|
||||
@@ -968,6 +966,7 @@ function FormAlertRules({
|
||||
!isChannelConfigurationValid ||
|
||||
queryStatus === 'error'
|
||||
}
|
||||
data-testid="alert-save-button"
|
||||
>
|
||||
{isNewRule ? t('button_createrule') : t('button_savechanges')}
|
||||
</ActionButton>
|
||||
@@ -981,6 +980,7 @@ function FormAlertRules({
|
||||
}
|
||||
type="default"
|
||||
onClick={onTestRuleHandler}
|
||||
data-testid="alert-test-button"
|
||||
>
|
||||
{' '}
|
||||
{t('button_testrule')}
|
||||
@@ -989,6 +989,7 @@ function FormAlertRules({
|
||||
disabled={loading || false}
|
||||
type="default"
|
||||
onClick={onCancelHandler}
|
||||
data-testid="alert-cancel-button"
|
||||
>
|
||||
{isNewRule && t('button_cancelchanges')}
|
||||
{ruleId && !isEmpty(ruleId) && t('button_discard')}
|
||||
@@ -998,6 +999,7 @@ function FormAlertRules({
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
testId="alert-save-confirm-dialog"
|
||||
open={isConfirmSaveOpen}
|
||||
onOpenChange={setIsConfirmSaveOpen}
|
||||
title={t('confirm_save_title')}
|
||||
|
||||
@@ -174,6 +174,7 @@ function LabelSelect({
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
|
||||
@@ -298,9 +298,9 @@ describe('useOptionsMenu', () => {
|
||||
|
||||
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
|
||||
result.current.config.addColumn?.onReorder([
|
||||
'attribute:service.name',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'attribute:service.name:string',
|
||||
'log:body:string',
|
||||
'resource:service.name:string',
|
||||
'log:timestamp',
|
||||
]);
|
||||
|
||||
@@ -331,9 +331,9 @@ describe('useOptionsMenu', () => {
|
||||
'state-indicator',
|
||||
'log:timestamp',
|
||||
'unknown.composite',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'attribute:service.name',
|
||||
'log:body:string',
|
||||
'resource:service.name:string',
|
||||
'attribute:service.name:string',
|
||||
]);
|
||||
|
||||
const reordered = mockUpdateColumns.mock.calls[0][0];
|
||||
@@ -360,7 +360,7 @@ describe('useOptionsMenu', () => {
|
||||
);
|
||||
|
||||
// Removing 'resource:service.name' should drop ONLY the resource variant.
|
||||
result.current.config.addColumn?.onRemove('resource:service.name');
|
||||
result.current.config.addColumn?.onRemove('resource:service.name:string');
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
const remaining = mockUpdateColumns.mock.calls[0][0];
|
||||
|
||||
@@ -56,7 +56,7 @@ export function dedupeColumnsByCompositeKey(
|
||||
const seen = new Set<string>();
|
||||
let hasDuplicate = false;
|
||||
const deduped = columns.filter((c) => {
|
||||
const key = buildCompositeKey(c.name, c.fieldContext);
|
||||
const key = buildCompositeKey(c.name, c.fieldContext, c.fieldDataType);
|
||||
if (seen.has(key)) {
|
||||
hasDuplicate = true;
|
||||
return false;
|
||||
|
||||
@@ -281,7 +281,8 @@ const useOptionsMenu = ({
|
||||
const handleRemoveSelectedColumn = useCallback(
|
||||
(columnKey: string) => {
|
||||
const newSelectedColumns = preferences?.columns?.filter(
|
||||
(f) => buildCompositeKey(f.name, f.fieldContext) !== columnKey,
|
||||
(f) =>
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
|
||||
);
|
||||
|
||||
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
|
||||
@@ -364,7 +365,10 @@ const useOptionsMenu = ({
|
||||
(orderedIds: string[]): void => {
|
||||
const current = preferences?.columns ?? [];
|
||||
const byCompositeKey = new Map(
|
||||
current.map((f) => [buildCompositeKey(f.name, f.fieldContext), f]),
|
||||
current.map((f) => [
|
||||
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType),
|
||||
f,
|
||||
]),
|
||||
);
|
||||
const reordered = orderedIds
|
||||
.map((id) => byCompositeKey.get(id))
|
||||
|
||||
@@ -15,8 +15,11 @@ export const getOptionsFromKeys = (
|
||||
);
|
||||
};
|
||||
|
||||
// Composite identity for a column. Disambiguates same-name fields across
|
||||
// different fieldContexts (e.g. resource:service.name vs attribute:service.name).
|
||||
// Falls back to bare name when context is missing.
|
||||
export const buildCompositeKey = (name: string, context?: string): string =>
|
||||
context ? `${context}:${name}` : name;
|
||||
export const buildCompositeKey = (
|
||||
name: string,
|
||||
context?: string,
|
||||
dataType?: string,
|
||||
): string => {
|
||||
const withContext = context ? `${context}:${name}` : name;
|
||||
return dataType ? `${withContext}:${dataType}` : withContext;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
@@ -10,3 +11,9 @@ export const defaultSelectedColumns: string[] = [
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
// Pinned timestamp column
|
||||
export const TIMESTAMP_FIELD = {
|
||||
name: 'timestamp',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import ListView from './index';
|
||||
|
||||
// globalTime starts with loading:true, which gates the list query. Force just that
|
||||
// slice's loading to false so the query fires; every other selector is untouched.
|
||||
jest.mock('react-redux', () => {
|
||||
const actual = jest.requireActual('react-redux');
|
||||
return {
|
||||
...actual,
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown => {
|
||||
const result = actual.useSelector(selector);
|
||||
if (result && typeof result === 'object' && 'loading' in result) {
|
||||
return { ...result, loading: false };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// List columns come from the options menu (server-synced preferences). Pin them
|
||||
// so the query fires and the expected columns render, independent of that API.
|
||||
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({
|
||||
options: {
|
||||
selectColumns: [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name', fieldContext: 'span' },
|
||||
{ name: 'duration_nano', fieldContext: 'span' },
|
||||
{ name: 'http_method', fieldContext: 'span' },
|
||||
{ name: 'response_status_code', fieldContext: 'span' },
|
||||
],
|
||||
},
|
||||
config: { addColumn: { onRemove: jest.fn() } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
|
||||
|
||||
const listRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
http_method: 'GET',
|
||||
response_status_code: '200',
|
||||
span_id: '772c4d29dd9076ac',
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
name: 'authenticate_check_db',
|
||||
duration_nano: 790949390,
|
||||
// empty status fields to assert the "-" cell
|
||||
http_method: '',
|
||||
response_status_code: '',
|
||||
span_id: '5704353737b6778e',
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
|
||||
const mockSuccess = (rows: unknown[] = listRows): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listResponse(rows))),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderListView = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<ListView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
|
||||
describe('Traces ListView - Data Loaded', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders backend rows in FieldCell format', async () => {
|
||||
mockSuccess();
|
||||
renderListView();
|
||||
|
||||
// plain-text columns
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
|
||||
|
||||
// duration_nano renders in milliseconds
|
||||
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
|
||||
|
||||
// http_method / response_status_code render as badges
|
||||
expect(screen.getAllByTestId('http_method')[0]).toHaveTextContent('GET');
|
||||
expect(screen.getAllByTestId('response_status_code')[0]).toHaveTextContent(
|
||||
'200',
|
||||
);
|
||||
|
||||
// empty status fields render "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -12,16 +12,18 @@ import {
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
@@ -32,20 +34,22 @@ import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { Container, tableStyles } from './styles';
|
||||
import { getListColumns, transformDataWithDate } from './utils';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
TIMESTAMP_FIELD,
|
||||
} from './configs';
|
||||
import { getTraceLink, transformSpanRows } from './utils';
|
||||
|
||||
import './ListView.styles.scss';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
@@ -93,7 +97,7 @@ function ListView({
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// TEMP — remove after traces moves to TanStack table.
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
@@ -186,60 +190,42 @@ function ListView({
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getListColumns(
|
||||
options?.selectColumns || [],
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
TIMESTAMP_FIELD,
|
||||
...(options?.selectColumns ?? []).filter(
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const transformedQueryTableData = useMemo(
|
||||
() => transformDataWithDate(queryTableData) || [],
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleDragColumn = useCallback(
|
||||
(fromIndex: number, toIndex: number): void => {
|
||||
const reordered = [...columns];
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
// `key` is the composite (fieldContext.name) — disambiguates same-name fields.
|
||||
const orderedIds = reordered
|
||||
.map((c) => String(c.key || ('dataIndex' in c && c.dataIndex) || ''))
|
||||
.filter(Boolean);
|
||||
config?.addColumn?.onReorder(orderedIds);
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(cols.map((c) => c.id));
|
||||
},
|
||||
[columns, config],
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length !== 0
|
||||
) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
|
||||
}, [isLoading, isFetching, isError, rows, panelType]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
@@ -266,33 +252,21 @@ function ListView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
|
||||
{!isError && transformedQueryTableData.length !== 0 && (
|
||||
<ResizeTable
|
||||
tableLayout="fixed"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={isFetching}
|
||||
style={tableStyles}
|
||||
dataSource={transformedQueryTableData}
|
||||
columns={columns}
|
||||
onDragColumn={handleDragColumn}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
@@ -41,12 +42,23 @@ export const transformDataWithDate = (
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: RowData): string =>
|
||||
`${ROUTES.TRACE}/${record.traceID || record.trace_id}${formUrlParams({
|
||||
spanId: record.spanID || record.span_id,
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const traceId = readId(record.traceID) || readId(record.trace_id);
|
||||
const spanId = readId(record.spanID) || readId(record.span_id);
|
||||
|
||||
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`;
|
||||
};
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
@@ -136,3 +148,21 @@ export const getListColumns = (
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
|
||||
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
|
||||
// positional ids; `timestamp` is lifted from the wrapping ListItem.
|
||||
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
|
||||
const list = data[0]?.list;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
return list.map((item) => {
|
||||
const row = item.data as Record<string, unknown>;
|
||||
return {
|
||||
...row,
|
||||
timestamp: item.timestamp,
|
||||
id: row.span_id,
|
||||
};
|
||||
}) as TracesTableRow[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import {
|
||||
DURATION_FIELD_NAMES,
|
||||
STATUS_FIELD_NAMES,
|
||||
TIMESTAMP_FIELD_NAMES,
|
||||
TRACE_ID_FIELD_NAMES,
|
||||
} from './constants';
|
||||
import { stringifyCellValue } from './utils';
|
||||
|
||||
type FieldCellProps = {
|
||||
name: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (TIMESTAMP_FIELD_NAMES.has(name)) {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
const text = String(formatted);
|
||||
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
if (value === '' || value == null) {
|
||||
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
const text = stringifyCellValue(value);
|
||||
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (STATUS_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (DURATION_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name} title={text}>
|
||||
{text}
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldCell;
|
||||
@@ -0,0 +1,26 @@
|
||||
.tableWrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tracesTable {
|
||||
--tanstack-table-row-height: 54px;
|
||||
--tanstack-table-header-height: 54px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 15px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 15px;
|
||||
--tanstack-cell-header-padding-left-override: 5px;
|
||||
|
||||
--tanstack-cell-header-padding-left-first-column: 15px;
|
||||
|
||||
--tanstack-plain-body-line-clamp: 1;
|
||||
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-bg: var(--l1-background-hover);
|
||||
--tanstack-table-row-hover-bg: var(--l1-background-hover);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
TableColumnDef,
|
||||
} from 'components/TanStackTableView/types';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { TracesTableRow } from './getFieldColumn';
|
||||
import styles from './TracesTable.module.scss';
|
||||
|
||||
export type TracesTableProps = {
|
||||
data: TracesTableRow[];
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
columnStorageKey?: string;
|
||||
respectColumnOrder?: boolean;
|
||||
panelType: PanelTypeKeys;
|
||||
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
|
||||
getRowHref: (row: TracesTableRow) => string;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | Error | null;
|
||||
isFilterApplied: boolean;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
};
|
||||
|
||||
function TracesTable({
|
||||
data,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
respectColumnOrder = false,
|
||||
panelType,
|
||||
getRowHref,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
isFilterApplied,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
cellTypographySize = 'medium',
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading && !isFetching && !isError && data.length === 0;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
history.push(getRowHref(row));
|
||||
},
|
||||
[history, getRowHref],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
|
||||
},
|
||||
[getRowHref],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
|
||||
)}
|
||||
|
||||
{!isError && data.length !== 0 && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<TanStackTable<TracesTableRow>
|
||||
data={data}
|
||||
columns={columns}
|
||||
className={styles.tracesTable}
|
||||
columnStorageKey={columnStorageKey}
|
||||
respectColumnOrder={respectColumnOrder}
|
||||
isLoading={isFetching}
|
||||
cellTypographySize={cellTypographySize}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TracesTable.defaultProps = {
|
||||
columnStorageKey: undefined,
|
||||
respectColumnOrder: false,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
};
|
||||
|
||||
export default TracesTable;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
|
||||
// camelCase and snake_case variants are listed because the API has shipped both.
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
'http.method',
|
||||
'http.request.method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
'http.status_code',
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
|
||||
import { TIMESTAMP_FIELD_NAMES } from './constants';
|
||||
import FieldCell from './FieldCell';
|
||||
|
||||
export type TracesTableRow = { id: string } & Record<string, unknown>;
|
||||
|
||||
export function getFieldColumn(
|
||||
field: TelemetryFieldKey,
|
||||
): TableColumnDef<TracesTableRow> {
|
||||
const { name, fieldContext, fieldDataType } = field;
|
||||
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
|
||||
|
||||
return {
|
||||
id: buildCompositeKey(name, fieldContext, fieldDataType),
|
||||
header: name,
|
||||
accessorFn: (row): unknown => row[name],
|
||||
enableMove: !isTimestamp,
|
||||
enableRemove: !isTimestamp,
|
||||
canBeHidden: !isTimestamp,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
|
||||
};
|
||||
}
|
||||
12
frontend/src/container/TracesExplorer/TracesTable/utils.ts
Normal file
12
frontend/src/container/TracesExplorer/TracesTable/utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function stringifyCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,50 +1,25 @@
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
import { ListItem } from 'types/api/widgets/getQuery';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
export const columns: ColumnsType<ListItem['data']> = [
|
||||
{
|
||||
title: 'Root Service Name',
|
||||
dataIndex: 'service.name',
|
||||
key: 'serviceName',
|
||||
},
|
||||
{
|
||||
title: 'Root Operation Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Root Duration (in ms)',
|
||||
dataIndex: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
render: (duration: number): JSX.Element => (
|
||||
<Typography>{getMs(String(duration))}ms</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'No of Spans',
|
||||
dataIndex: 'span_count',
|
||||
key: 'span_count',
|
||||
},
|
||||
{
|
||||
title: 'TraceID',
|
||||
dataIndex: 'trace_id',
|
||||
key: 'traceID',
|
||||
render: (traceID: string): JSX.Element => (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceID,
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name' },
|
||||
{ name: 'duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
);
|
||||
|
||||
136
frontend/src/container/TracesExplorer/TracesView/index.test.tsx
Normal file
136
frontend/src/container/TracesExplorer/TracesView/index.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import TracesView from './index';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
|
||||
|
||||
const groupedRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
span_count: 8,
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
// intentionally empty to assert the "-" cell
|
||||
name: '',
|
||||
duration_nano: 790949390,
|
||||
span_count: 3,
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groupedResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
|
||||
const mockSuccess = (rows: unknown[] = groupedRows): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(groupedResponse(rows))),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mockError = (): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error', error: 'boom' })),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderTracesView = (
|
||||
props: Record<string, unknown> = {},
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<TracesView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
|
||||
describe('TracesView (grouped root-span table)', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders backend rows in FieldCell format', async () => {
|
||||
mockSuccess();
|
||||
renderTracesView();
|
||||
|
||||
// service.name + name render as plain text
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('HTTP GET')).toBeInTheDocument();
|
||||
|
||||
// duration_nano renders in milliseconds
|
||||
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
|
||||
|
||||
// span_count renders as text
|
||||
expect(screen.getByText('8')).toBeInTheDocument();
|
||||
|
||||
// empty field renders "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// trace_id renders as a link to the trace detail
|
||||
const traceLinks = screen.getAllByTestId('trace-id');
|
||||
expect(traceLinks[0]).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('/trace/0000000000000000344ded1387b08a7e'),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state and keeps the toolbar when there are no rows', async () => {
|
||||
mockSuccess([]);
|
||||
renderTracesView();
|
||||
|
||||
// toolbar (un-gated) stays visible regardless of data
|
||||
expect(
|
||||
screen.getByText(/This tab only shows Root Spans/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the toolbar visible on API error', async () => {
|
||||
mockError();
|
||||
renderTracesView();
|
||||
|
||||
expect(
|
||||
screen.getByText(/This tab only shows Root Spans/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
@@ -12,30 +11,29 @@ import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { ActionsContainer, Container } from './styles';
|
||||
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
@@ -119,8 +117,13 @@ function TracesView({
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
const tableData = useMemo(
|
||||
() => responseData?.map((listItem) => listItem.data),
|
||||
|
||||
const rows = useMemo<TracesTableRow[]>(
|
||||
() =>
|
||||
(responseData ?? []).map((item) => {
|
||||
const row = item.data;
|
||||
return { ...row, id: row.trace_id };
|
||||
}) as TracesTableRow[],
|
||||
[responseData],
|
||||
);
|
||||
|
||||
@@ -133,71 +136,52 @@ function TracesView({
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, panelType, tableData]);
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ActionsContainer>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</ActionsContainer>
|
||||
)}
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && (tableData || []).length === 0)) && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
(tableData || []).length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
(tableData || []).length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
|
||||
)}
|
||||
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ResizeTable
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
scroll={{ x: true }}
|
||||
pagination={false}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_VIEW_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionsContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
@@ -94,6 +94,8 @@ function AlertDetails(): JSX.Element {
|
||||
>
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
data-testid="alert-details-root"
|
||||
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
|
||||
@@ -117,7 +117,11 @@ function AlertActionButtons({
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
<Switch
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
@@ -129,6 +133,7 @@ function AlertActionButtons({
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
|
||||
@@ -47,21 +47,26 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
<div className="alert-title">
|
||||
<AlertState
|
||||
state={alertRuleState ?? state ?? ''}
|
||||
testId="alert-header-state"
|
||||
/>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && <AlertSeverity severity={labels.severity} />}
|
||||
{labels?.severity && (
|
||||
<AlertSeverity severity={labels.severity} testId="alert-header-severity" />
|
||||
)}
|
||||
|
||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||
{/* <AlertStatus
|
||||
status="firing"
|
||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||
/> */}
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
<AlertLabels labels={labelsWithoutSeverity} testId="alert-header-labels" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,14 +6,16 @@ import './AlertLabels.styles.scss';
|
||||
export type AlertLabelsProps = {
|
||||
labels: Record<string, any>;
|
||||
initialCount?: number;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
function AlertLabels({
|
||||
labels,
|
||||
initialCount = 2,
|
||||
testId,
|
||||
}: AlertLabelsProps): JSX.Element {
|
||||
return (
|
||||
<div className="alert-labels">
|
||||
<div className="alert-labels" data-testid={testId}>
|
||||
<SeeMore initialCount={initialCount} moreLabel="More">
|
||||
{Object.entries(labels).map(([key, value]) => (
|
||||
<KeyValueLabel key={`label-${key}`} badgeKey={key} badgeValue={value} />
|
||||
@@ -25,6 +27,7 @@ function AlertLabels({
|
||||
|
||||
AlertLabels.defaultProps = {
|
||||
initialCount: 2,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default AlertLabels;
|
||||
|
||||
@@ -32,8 +32,10 @@ const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
|
||||
|
||||
export default function AlertSeverity({
|
||||
severity,
|
||||
testId,
|
||||
}: {
|
||||
severity: string;
|
||||
testId?: string;
|
||||
}): JSX.Element {
|
||||
const severityDetails = useMemo(() => {
|
||||
if (severityConfig[severity]) {
|
||||
@@ -52,9 +54,16 @@ export default function AlertSeverity({
|
||||
};
|
||||
}, [severity]);
|
||||
return (
|
||||
<div className={`alert-severity ${severityDetails.className}`}>
|
||||
<div
|
||||
className={`alert-severity ${severityDetails.className}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="alert-severity__icon">{severityDetails.icon}</div>
|
||||
<div className="alert-severity__text">{severityDetails.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AlertSeverity.defaultProps = {
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -8,11 +8,13 @@ import './AlertState.styles.scss';
|
||||
type AlertStateProps = {
|
||||
state: RuletypesAlertStateDTO | string;
|
||||
showLabel?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export default function AlertState({
|
||||
state,
|
||||
showLabel,
|
||||
testId,
|
||||
}: AlertStateProps): JSX.Element {
|
||||
let icon;
|
||||
let label;
|
||||
@@ -64,7 +66,7 @@ export default function AlertState({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="alert-state">
|
||||
<div className="alert-state" data-testid={testId}>
|
||||
{icon} {showLabel && <div className="alert-state__label">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -72,4 +74,5 @@ export default function AlertState({
|
||||
|
||||
AlertState.defaultProps = {
|
||||
showLabel: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
|
||||
@@ -13,6 +13,8 @@ interface Tab {
|
||||
disabled?: boolean;
|
||||
icon?: string | JSX.Element;
|
||||
isBeta?: boolean;
|
||||
/** Optional `data-testid` for the tab button. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
interface TimelineTabsProps {
|
||||
@@ -63,6 +65,7 @@ function Tabs2({
|
||||
disabled={tab.disabled}
|
||||
icon={tab.icon}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
data-testid={tab.testId}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
@@ -80,6 +81,7 @@ type provider struct {
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
quickFilterHandler quickfilter.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -118,6 +120,7 @@ func NewFactory(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
quickFilterHandler quickfilter.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -159,6 +162,7 @@ func NewFactory(
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
quickFilterHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -202,6 +206,7 @@ func newProvider(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
quickFilterHandler quickfilter.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -244,6 +249,7 @@ func newProvider(
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
quickFilterHandler: quickFilterHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -384,6 +390,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addQuickFilterRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
93
pkg/apiserver/signozapiserver/quickfilter.go
Normal file
93
pkg/apiserver/signozapiserver/quickfilter.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addQuickFilterRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/orgs/me/filters", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.GetQuickFiltersV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListQuickFilters",
|
||||
Tags: []string{"quick_filter"},
|
||||
Summary: "List quick filters",
|
||||
Description: "Returns the org's quick filters for every signal, each filter as a telemetry field key.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new([]*quickfiltertypes.SignalFilters),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceQuickFilter,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/orgs/me/filters/{signal_name}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.GetSignalFiltersV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetSignalQuickFilters",
|
||||
Tags: []string{"quick_filter"},
|
||||
Summary: "Get a signal's quick filters",
|
||||
Description: "Returns the org's quick filters for one signal, each filter as a telemetry field key.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(quickfiltertypes.SignalFilters),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceQuickFilter,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/orgs/me/filters", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.UpdateQuickFiltersV2, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateQuickFilters",
|
||||
Tags: []string{"quick_filter"},
|
||||
Summary: "Update quick filters",
|
||||
Description: "Replaces the org's quick filters for the signal named in the body.",
|
||||
Request: new(quickfiltertypes.UpdatableQuickFilters),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceQuickFilter.Scope(coretypes.VerbUpdate)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceQuickFilter,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -4,10 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -20,6 +23,89 @@ func NewHandler(module quickfilter.Module) quickfilter.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
// legacySignalFilters is the v1 API shape: filters as v3 attribute keys.
|
||||
type legacySignalFilters struct {
|
||||
Signal quickfiltertypes.Signal `json:"signal"`
|
||||
Filters []v3.AttributeKey `json:"filters"`
|
||||
}
|
||||
|
||||
// newTelemetryFieldKeysFromLegacy converts a v1 write payload with the same
|
||||
// normalizations as the storage migration: alias contexts, numerics to number.
|
||||
// The v1 shape carries no per filter signal, so meter keys get it restored.
|
||||
func newTelemetryFieldKeysFromLegacy(signal quickfiltertypes.Signal, filters []v3.AttributeKey) ([]telemetrytypes.TelemetryFieldKey, error) {
|
||||
var fieldSignal telemetrytypes.Signal
|
||||
if signal == quickfiltertypes.SignalMeter {
|
||||
fieldSignal = telemetrytypes.SignalMetrics
|
||||
}
|
||||
|
||||
fieldKeys := make([]telemetrytypes.TelemetryFieldKey, 0, len(filters))
|
||||
for _, filter := range filters {
|
||||
if err := filter.Validate(); err != nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter: %v", err)
|
||||
}
|
||||
|
||||
fieldContext, ok := telemetrytypes.FieldContextFromText(string(filter.Type))
|
||||
if !ok {
|
||||
fieldContext = telemetrytypes.FieldContextUnspecified
|
||||
}
|
||||
|
||||
var fieldDataType telemetrytypes.FieldDataType
|
||||
if err := fieldDataType.Scan(string(filter.DataType)); err != nil {
|
||||
fieldDataType = telemetrytypes.FieldDataTypeUnspecified
|
||||
}
|
||||
if fieldDataType == telemetrytypes.FieldDataTypeInt64 {
|
||||
fieldDataType = telemetrytypes.FieldDataTypeNumber
|
||||
}
|
||||
|
||||
fieldKeys = append(fieldKeys, telemetrytypes.TelemetryFieldKey{
|
||||
Name: filter.Key,
|
||||
Signal: fieldSignal,
|
||||
FieldContext: fieldContext,
|
||||
FieldDataType: fieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
return fieldKeys, nil
|
||||
}
|
||||
|
||||
// newLegacySignalFiltersFromSignalFilters renders stored telemetry field keys
|
||||
// back into the v1 shape, restoring the legacy spellings v1 clients expect.
|
||||
func newLegacySignalFiltersFromSignalFilters(signalFilters *quickfiltertypes.SignalFilters) *legacySignalFilters {
|
||||
filters := make([]v3.AttributeKey, 0, len(signalFilters.Filters))
|
||||
for _, fieldKey := range signalFilters.Filters {
|
||||
// Only tag and resource exist in the v3 enum; other contexts render as
|
||||
// unspecified so v1 clients never see spellings their queries can't use.
|
||||
var attributeType v3.AttributeKeyType
|
||||
switch fieldKey.FieldContext {
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
attributeType = v3.AttributeKeyTypeTag
|
||||
case telemetrytypes.FieldContextResource:
|
||||
attributeType = v3.AttributeKeyTypeResource
|
||||
default:
|
||||
attributeType = v3.AttributeKeyTypeUnspecified
|
||||
}
|
||||
|
||||
var dataType v3.AttributeKeyDataType
|
||||
switch fieldKey.FieldDataType {
|
||||
case telemetrytypes.FieldDataTypeNumber:
|
||||
dataType = v3.AttributeKeyDataTypeFloat64
|
||||
default:
|
||||
dataType = v3.AttributeKeyDataType(fieldKey.FieldDataType.StringValue())
|
||||
}
|
||||
|
||||
filters = append(filters, v3.AttributeKey{
|
||||
Key: fieldKey.Name,
|
||||
Type: attributeType,
|
||||
DataType: dataType,
|
||||
})
|
||||
}
|
||||
|
||||
return &legacySignalFilters{
|
||||
Signal: signalFilters.Signal,
|
||||
Filters: filters,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
@@ -33,30 +119,12 @@ func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, filters)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
legacyFilters := make([]*legacySignalFilters, 0, len(filters))
|
||||
for _, signalFilters := range filters {
|
||||
legacyFilters = append(legacyFilters, newLegacySignalFiltersFromSignalFilters(signalFilters))
|
||||
}
|
||||
|
||||
var req quickfiltertypes.UpdatableQuickFilters
|
||||
decodeErr := json.NewDecoder(r.Body).Decode(&req)
|
||||
if decodeErr != nil {
|
||||
render.Error(rw, decodeErr)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
render.Success(rw, http.StatusOK, legacyFilters)
|
||||
}
|
||||
|
||||
func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
@@ -79,5 +147,94 @@ func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, newLegacySignalFiltersFromSignalFilters(filters))
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req legacySignalFilters
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(req.Signal, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, fieldKeys)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID))
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, filters)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req quickfiltertypes.UpdatableQuickFilters
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetSignalFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
signal := mux.Vars(r)["signal_name"]
|
||||
validatedSignal, err := quickfiltertypes.NewSignal(signal)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetSignalFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, filters)
|
||||
}
|
||||
|
||||
62
pkg/modules/quickfilter/implquickfilter/handler_test.go
Normal file
62
pkg/modules/quickfilter/implquickfilter/handler_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package implquickfilter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewTelemetryFieldKeysFromLegacy(t *testing.T) {
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalTraces, []v3.AttributeKey{
|
||||
{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString},
|
||||
{Key: "http.method", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeString},
|
||||
{Key: "duration_nano", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeFloat64},
|
||||
{Key: "code_line", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeInt64},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fieldKeys, 4)
|
||||
|
||||
assert.Equal(t, telemetrytypes.TelemetryFieldKey{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString}, fieldKeys[0])
|
||||
assert.Equal(t, telemetrytypes.FieldContextAttribute, fieldKeys[1].FieldContext)
|
||||
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[2].FieldDataType)
|
||||
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[3].FieldDataType)
|
||||
|
||||
t.Run("meter writes restore the per-filter signal", func(t *testing.T) {
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalMeter, []v3.AttributeKey{
|
||||
{Key: "host.name", DataType: v3.AttributeKeyDataTypeString},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, fieldKeys, 1)
|
||||
assert.Equal(t, telemetrytypes.SignalMetrics, fieldKeys[0].Signal)
|
||||
})
|
||||
|
||||
t.Run("rejects a filter without a key", func(t *testing.T) {
|
||||
_, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalTraces, []v3.AttributeKey{{DataType: v3.AttributeKeyDataTypeString}})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewLegacySignalFiltersFromSignalFilters(t *testing.T) {
|
||||
legacy := newLegacySignalFiltersFromSignalFilters(&quickfiltertypes.SignalFilters{
|
||||
Signal: quickfiltertypes.SignalLogs,
|
||||
Filters: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
||||
{Name: "severity_text", FieldContext: telemetrytypes.FieldContextLog, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "host.name", Signal: telemetrytypes.SignalMetrics},
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, quickfiltertypes.SignalLogs, legacy.Signal)
|
||||
require.Len(t, legacy.Filters, 5)
|
||||
assert.Equal(t, v3.AttributeKey{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString}, legacy.Filters[0])
|
||||
assert.Equal(t, v3.AttributeKeyTypeTag, legacy.Filters[1].Type)
|
||||
assert.Equal(t, v3.AttributeKeyDataTypeFloat64, legacy.Filters[2].DataType)
|
||||
assert.Equal(t, v3.AttributeKeyTypeUnspecified, legacy.Filters[3].Type, "contexts outside the v3 enum must render as unspecified")
|
||||
assert.Equal(t, v3.AttributeKey{Key: "host.name"}, legacy.Filters[4])
|
||||
}
|
||||
@@ -2,12 +2,11 @@ package implquickfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -42,18 +41,15 @@ func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([
|
||||
func (m *module) GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error) {
|
||||
storedFilter, err := m.store.GetBySignal(ctx, orgID, signal.StringValue())
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
return &quickfiltertypes.SignalFilters{
|
||||
Signal: signal,
|
||||
Filters: []telemetrytypes.TelemetryFieldKey{},
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no filter exists for this signal, return empty filters with the requested signal
|
||||
if storedFilter == nil {
|
||||
return &quickfiltertypes.SignalFilters{
|
||||
Signal: signal,
|
||||
Filters: []v3.AttributeKey{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Convert stored filter to signal filter
|
||||
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
|
||||
@@ -63,47 +59,26 @@ func (m *module) GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal
|
||||
}
|
||||
|
||||
// UpdateQuickFilters updates quick filters for a specific signal in an organization.
|
||||
func (module *module) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error {
|
||||
// Validate each filter
|
||||
for _, filter := range filters {
|
||||
if err := filter.Validate(); err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal filters to JSON
|
||||
filterJSON, err := json.Marshal(filters)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error marshalling filters")
|
||||
}
|
||||
|
||||
// Check if filter exists
|
||||
func (module *module) UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []telemetrytypes.TelemetryFieldKey) error {
|
||||
existingFilter, err := module.store.GetBySignal(ctx, orgID, signal.StringValue())
|
||||
if err != nil {
|
||||
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error checking existing filters")
|
||||
}
|
||||
|
||||
var filter *quickfiltertypes.StorableQuickFilter
|
||||
if existingFilter != nil {
|
||||
// Update in place
|
||||
if err := existingFilter.Update(filterJSON); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error updating existing filter")
|
||||
if err := existingFilter.Update(filters); err != nil {
|
||||
return err
|
||||
}
|
||||
filter = existingFilter
|
||||
} else {
|
||||
// Create new
|
||||
filter, err = quickfiltertypes.NewStorableQuickFilter(orgID, signal, filterJSON)
|
||||
filter, err = quickfiltertypes.NewStorableQuickFilter(orgID, signal, filters)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "error creating new filter")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Persist filter
|
||||
if err := module.store.Upsert(ctx, filter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return module.store.Upsert(ctx, filter)
|
||||
}
|
||||
|
||||
func (module *module) SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error {
|
||||
|
||||
@@ -4,20 +4,25 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
GetQuickFilters(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes.SignalFilters, error)
|
||||
UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []v3.AttributeKey) error
|
||||
UpdateQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []telemetrytypes.TelemetryFieldKey) error
|
||||
GetSignalFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) (*quickfiltertypes.SignalFilters, error)
|
||||
SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
// Legacy v1 endpoints, served by converting to and from the v3 attribute key shape.
|
||||
GetQuickFilters(http.ResponseWriter, *http.Request)
|
||||
UpdateQuickFilters(http.ResponseWriter, *http.Request)
|
||||
GetSignalFilters(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetQuickFiltersV2(http.ResponseWriter, *http.Request)
|
||||
UpdateQuickFiltersV2(http.ResponseWriter, *http.Request)
|
||||
GetSignalFiltersV2(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -470,7 +470,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
continue
|
||||
}
|
||||
// Type is resolved now; validate aggregation compatibility against it.
|
||||
if err := spec.Aggregations[i].ValidateForType(); err != nil {
|
||||
if err := spec.Aggregations[i].ValidateForTypeAndTemporality(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if reducedMetricsSet[spec.Aggregations[i].MetricName] {
|
||||
|
||||
@@ -450,7 +450,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
router.HandleFunc("/api/v1/disks", am.ViewAccess(aH.getDisks)).Methods(http.MethodGet)
|
||||
|
||||
// Quick Filters
|
||||
// Quick Filters (v1 routes serve the legacy v3 shape; v2 lives in signozapiserver)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetQuickFilters)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSignalFilters)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.Signoz.Handlers.QuickFilter.UpdateQuickFilters)).Methods(http.MethodPut)
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/preference"
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
@@ -93,6 +94,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
struct{ quickfilter.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -244,6 +244,8 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewDeleteOrphanUserRolesFactory(),
|
||||
sqlmigration.NewMigrateLambdaDashboardsFactory(),
|
||||
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
|
||||
sqlmigration.NewMigrateQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddQuickFilterTuplesFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -347,6 +349,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
handlers.QuickFilter,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
156
pkg/sqlmigration/119_migrate_quick_filters.go
Normal file
156
pkg/sqlmigration/119_migrate_quick_filters.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type storableQuickFilterRow struct {
|
||||
bun.BaseModel `bun:"table:quick_filter"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
Filter string `bun:"filter"`
|
||||
}
|
||||
|
||||
// legacyQuickFilterEntry carries both shapes a stored entry can be in: the
|
||||
// legacy key/type/dataType shape and the current name-carrying shape.
|
||||
type legacyQuickFilterEntry struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
DataType string `json:"dataType"`
|
||||
Signal string `json:"signal"`
|
||||
}
|
||||
|
||||
// quickFilterLegacyDataTypes maps the legacy datatype spellings that differ:
|
||||
// the fields API reports every numeric as "number", so both resolve to it.
|
||||
var quickFilterLegacyDataTypes = map[string]string{
|
||||
"int64": "number",
|
||||
"float64": "number",
|
||||
}
|
||||
|
||||
func quickFilterFieldDataType(legacyDataType string) string {
|
||||
if mapped, ok := quickFilterLegacyDataTypes[legacyDataType]; ok {
|
||||
return mapped
|
||||
}
|
||||
return legacyDataType
|
||||
}
|
||||
|
||||
// quickFilterFieldContext resolves legacy type spellings via the shared alias
|
||||
// table and normalizes anything unknown (e.g. "Sum") to unspecified, matching
|
||||
// what the v1 write path does at runtime.
|
||||
func quickFilterFieldContext(legacyType string) string {
|
||||
if fieldContext, ok := telemetrytypes.FieldContextFromText(legacyType); ok {
|
||||
return fieldContext.StringValue()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type migrateQuickFilters struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
settings factory.ProviderSettings
|
||||
}
|
||||
|
||||
func NewMigrateQuickFiltersFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("migrate_quick_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &migrateQuickFilters{sqlstore: sqlstore, settings: ps}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *migrateQuickFilters) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *migrateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var rows []*storableQuickFilterRow
|
||||
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var migrated, skipped int
|
||||
for _, row := range rows {
|
||||
migratedFilter, changed, ok := migrateQuickFilterEntries(row.Filter)
|
||||
if !ok {
|
||||
migration.settings.Logger.WarnContext(ctx, "quick filter could not be parsed, leaving it untouched", slog.String("quick_filter_id", row.ID), slog.String("raw_filter", row.Filter))
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
|
||||
migrated++
|
||||
if _, err := tx.NewUpdate().Model((*storableQuickFilterRow)(nil)).Set("filter = ?", migratedFilter).Where("id = ?", row.ID).Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
migration.settings.Logger.InfoContext(ctx, "migrated quick filters to telemetry field keys", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *migrateQuickFilters) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateQuickFilterEntries rewrites a stored filter list from the legacy
|
||||
// key/dataType/type shape to telemetry field keys; ok=false means unparseable.
|
||||
func migrateQuickFilterEntries(filter string) (migrated string, changed bool, ok bool) {
|
||||
var entriesRaw []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(filter), &entriesRaw); err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
migratedEntries := make([]json.RawMessage, 0, len(entriesRaw))
|
||||
for _, rawEntry := range entriesRaw {
|
||||
var entry legacyQuickFilterEntry
|
||||
if err := json.Unmarshal(rawEntry, &entry); err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
switch {
|
||||
case entry.Name != "":
|
||||
migratedEntries = append(migratedEntries, rawEntry)
|
||||
case entry.Key != "":
|
||||
migratedJSON, err := marshalUnescaped(telemetryFieldKeyOutput{
|
||||
Name: entry.Key,
|
||||
Signal: entry.Signal,
|
||||
FieldContext: quickFilterFieldContext(entry.Type),
|
||||
FieldDataType: quickFilterFieldDataType(entry.DataType),
|
||||
})
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
migratedEntries = append(migratedEntries, migratedJSON)
|
||||
changed = true
|
||||
default:
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return "", false, true
|
||||
}
|
||||
|
||||
migratedJSON, err := marshalUnescaped(migratedEntries)
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
return string(migratedJSON), true, true
|
||||
}
|
||||
89
pkg/sqlmigration/119_migrate_quick_filters_test.go
Normal file
89
pkg/sqlmigration/119_migrate_quick_filters_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigrateQuickFilterEntries(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
filter string
|
||||
expected string
|
||||
changed bool
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
description: "legacy tag and resource entries",
|
||||
filter: `[{"key":"service.name","dataType":"string","type":"resource"},{"key":"http.method","dataType":"string","type":"tag"}]`,
|
||||
expected: `[{"name":"service.name","signal":"","fieldContext":"resource","fieldDataType":"string"},{"name":"http.method","signal":"","fieldContext":"attribute","fieldDataType":"string"}]`,
|
||||
changed: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
description: "numeric datatypes resolve to number like the fields API reports them",
|
||||
filter: `[{"key":"duration_nano","dataType":"float64","type":"tag"},{"key":"code_line","dataType":"int64","type":"tag"}]`,
|
||||
expected: `[{"name":"duration_nano","signal":"","fieldContext":"attribute","fieldDataType":"number"},{"name":"code_line","signal":"","fieldContext":"attribute","fieldDataType":"number"}]`,
|
||||
changed: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
description: "meter junk type normalizes to unspecified",
|
||||
filter: `[{"key":"deployment.environment","dataType":"float64","type":"Sum"}]`,
|
||||
expected: `[{"name":"deployment.environment","signal":"","fieldContext":"","fieldDataType":"number"}]`,
|
||||
changed: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
description: "legacy entry with a signal keeps it",
|
||||
filter: `[{"key":"host.name","dataType":"string","type":"resource","signal":"metrics"}]`,
|
||||
expected: `[{"name":"host.name","signal":"metrics","fieldContext":"resource","fieldDataType":"string"}]`,
|
||||
changed: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
description: "already migrated entries are untouched",
|
||||
filter: `[{"name":"service.name","signal":"","fieldContext":"resource","fieldDataType":"string","description":"svc"}]`,
|
||||
changed: false,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
description: "mixed entries migrate only the legacy ones",
|
||||
filter: `[{"name":"service.name","fieldContext":"resource","fieldDataType":"string"},{"key":"hasError","dataType":"bool","type":"tag"}]`,
|
||||
expected: `[{"name":"service.name","fieldContext":"resource","fieldDataType":"string"},{"name":"hasError","signal":"","fieldContext":"attribute","fieldDataType":"bool"}]`,
|
||||
changed: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
description: "entries with neither name nor key are dropped",
|
||||
filter: `[{"dataType":"string","type":"tag"},{"key":"service.name","dataType":"string","type":"resource"}]`,
|
||||
expected: `[{"name":"service.name","signal":"","fieldContext":"resource","fieldDataType":"string"}]`,
|
||||
changed: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
description: "empty list is untouched",
|
||||
filter: `[]`,
|
||||
changed: false,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
description: "unparseable filter is reported",
|
||||
filter: `{"key":"not-a-list"}`,
|
||||
ok: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
migrated, changed, ok := migrateQuickFilterEntries(testCase.filter)
|
||||
require.Equal(t, testCase.ok, ok)
|
||||
assert.Equal(t, testCase.changed, changed)
|
||||
if testCase.changed {
|
||||
assert.JSONEq(t, testCase.expected, migrated)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
139
pkg/sqlmigration/120_add_quick_filter_tuples.go
Normal file
139
pkg/sqlmigration/120_add_quick_filter_tuples.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addQuickFilterTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddQuickFilterTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_quick_filter_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addQuickFilterTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addQuickFilterTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addQuickFilterTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// quick-filter moved from the legacy ViewAccess/AdminAccess role gate to
|
||||
// CheckResources, which on enterprise requires real tuples -- existing orgs
|
||||
// never had these written, only new orgs get them from the registry at bootstrap.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "quick-filter", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "quick-filter", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "quick-filter", "list"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "quick-filter", "read"},
|
||||
{authtypes.SigNozEditorRoleName, "metaresource", "quick-filter", "list"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "quick-filter", "read"},
|
||||
{authtypes.SigNozViewerRoleName, "metaresource", "quick-filter", "list"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addQuickFilterTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -428,20 +428,24 @@ func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
sb.SelectMore(fmt.Sprintf("`%s`", GroupByColumnAlias(i, g.Name)))
|
||||
}
|
||||
|
||||
aggCol, err := metricstelemetryschema.AggregationColumnForSamplesTable(
|
||||
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
|
||||
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
|
||||
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
|
||||
}
|
||||
|
||||
var aggCol string
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() &&
|
||||
query.Aggregations[0].Type == metrictypes.ExpHistogramType {
|
||||
// merging sketches already spans every series in the step, so neither a
|
||||
// samples-table value column nor the rate divisor applies
|
||||
aggCol = fmt.Sprintf("quantilesDDMerge(0.01, %f)(sketch)[1]", query.Aggregations[0].SpaceAggregation.Percentile())
|
||||
} else {
|
||||
col, err := metricstelemetryschema.AggregationColumnForSamplesTable(
|
||||
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
aggCol = col
|
||||
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
|
||||
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
|
||||
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
|
||||
}
|
||||
}
|
||||
|
||||
sb.SelectMore(fmt.Sprintf("%s AS value", aggCol))
|
||||
|
||||
@@ -126,6 +126,64 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_exp_histogram_percentile_delta",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.ExpHistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
// the sketch merge spans the whole step, so `rate` must not add a /step divisor
|
||||
name: "test_exp_histogram_percentile_delta_rate_time_aggregation",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.ExpHistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_histogram_percentile1",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
|
||||
@@ -374,7 +374,7 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m MetricAggregation) ValidateForType() error {
|
||||
func (m MetricAggregation) ValidateForTypeAndTemporality() error {
|
||||
if m.SpaceAggregation.IsPercentile() && !m.Type.IsPercentileSpaceAggregationAllowed() {
|
||||
return errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
@@ -384,6 +384,17 @@ func (m MetricAggregation) ValidateForType() error {
|
||||
m.Type.StringValue(),
|
||||
)
|
||||
}
|
||||
// reading a step's distribution out of a cumulative sketch would mean
|
||||
// subtracting the previous point's sketch, which ClickHouse cannot do
|
||||
if m.Type == metrictypes.ExpHistogramType && m.Temporality != metrictypes.Delta {
|
||||
return errors.Newf(
|
||||
errors.TypeUnsupported,
|
||||
errors.CodeUnsupported,
|
||||
"metric `%s` is an exponential histogram recorded with `%s` temporality, which cannot be queried; only `delta` exponential histograms are supported",
|
||||
m.MetricName,
|
||||
m.Temporality.StringValue(),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1517,10 +1517,11 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricAggregationValidateForType(t *testing.T) {
|
||||
func TestMetricAggregationValidateForTypeAndTemporality(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
metricType metrictypes.Type
|
||||
temporality metrictypes.Temporality
|
||||
spaceAggregation metrictypes.SpaceAggregation
|
||||
comparisonParam *metrictypes.ComparisonSpaceAggregationParam
|
||||
wantErr bool
|
||||
@@ -1532,11 +1533,33 @@ func TestMetricAggregationValidateForType(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "percentile on exponential histogram is allowed",
|
||||
name: "percentile on delta exponential histogram is allowed",
|
||||
metricType: metrictypes.ExpHistogramType,
|
||||
temporality: metrictypes.Delta,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "cumulative exponential histogram is not allowed",
|
||||
metricType: metrictypes.ExpHistogramType,
|
||||
temporality: metrictypes.Cumulative,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "exponential histogram with unresolved temporality is not allowed",
|
||||
metricType: metrictypes.ExpHistogramType,
|
||||
temporality: metrictypes.Unknown,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "cumulative histogram is unaffected by the exponential histogram rule",
|
||||
metricType: metrictypes.HistogramType,
|
||||
temporality: metrictypes.Cumulative,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "percentile on summary is not allowed",
|
||||
metricType: metrictypes.SummaryType,
|
||||
@@ -1562,10 +1585,11 @@ func TestMetricAggregationValidateForType(t *testing.T) {
|
||||
agg := MetricAggregation{
|
||||
MetricName: "test_metric",
|
||||
Type: tc.metricType,
|
||||
Temporality: tc.temporality,
|
||||
SpaceAggregation: tc.spaceAggregation,
|
||||
ComparisonSpaceAggregationParam: tc.comparisonParam,
|
||||
}
|
||||
err := agg.ValidateForType()
|
||||
err := agg.ValidateForTypeAndTemporality()
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/aiobservabilitytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
@@ -70,18 +70,27 @@ type StorableQuickFilter struct {
|
||||
}
|
||||
|
||||
type SignalFilters struct {
|
||||
Signal Signal `json:"signal"`
|
||||
Filters []v3.AttributeKey `json:"filters"`
|
||||
Signal Signal `json:"signal"`
|
||||
Filters []telemetrytypes.TelemetryFieldKey `json:"filters"`
|
||||
}
|
||||
|
||||
type UpdatableQuickFilters struct {
|
||||
Signal Signal `json:"signal"`
|
||||
Filters []v3.AttributeKey `json:"filters"`
|
||||
Signal Signal `json:"signal"`
|
||||
Filters []telemetrytypes.TelemetryFieldKey `json:"filters"`
|
||||
}
|
||||
|
||||
func validateFilters(filters []telemetrytypes.TelemetryFieldKey) error {
|
||||
for _, filter := range filters {
|
||||
if filter.Name == "" {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "filter name is required")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewStorableQuickFilter creates a new StorableQuickFilter after validation.
|
||||
func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filterJSON []byte) (*StorableQuickFilter, error) {
|
||||
if orgID.StringValue() == "" {
|
||||
func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filters []telemetrytypes.TelemetryFieldKey) (*StorableQuickFilter, error) {
|
||||
if orgID.IsZero() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required")
|
||||
}
|
||||
|
||||
@@ -89,9 +98,13 @@ func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filterJSON []byte)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var filters []v3.AttributeKey
|
||||
if err := json.Unmarshal(filterJSON, &filters); err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter JSON")
|
||||
if err := validateFilters(filters); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filterJSON, err := json.Marshal(filters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error marshalling filters")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
@@ -109,11 +122,15 @@ func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filterJSON []byte)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update updates an existing StorableQuickFilter with new filter data after validation.
|
||||
func (quickfilter *StorableQuickFilter) Update(filterJSON []byte) error {
|
||||
var filters []v3.AttributeKey
|
||||
if err := json.Unmarshal(filterJSON, &filters); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid filter JSON")
|
||||
// Update updates an existing StorableQuickFilter with new filters after validation.
|
||||
func (quickfilter *StorableQuickFilter) Update(filters []telemetrytypes.TelemetryFieldKey) error {
|
||||
if err := validateFilters(filters); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filterJSON, err := json.Marshal(filters)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error marshalling filters")
|
||||
}
|
||||
|
||||
quickfilter.Filter = string(filterJSON)
|
||||
@@ -127,7 +144,7 @@ func NewSignalFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFi
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "storableQuickFilter cannot be nil")
|
||||
}
|
||||
|
||||
var filters []v3.AttributeKey
|
||||
var filters []telemetrytypes.TelemetryFieldKey
|
||||
if storableQuickFilter.Filter != "" {
|
||||
err := json.Unmarshal([]byte(storableQuickFilter.Filter), &filters)
|
||||
if err != nil {
|
||||
@@ -143,170 +160,88 @@ func NewSignalFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFi
|
||||
|
||||
// NewDefaultQuickFilter generates default filters for all supported signals.
|
||||
func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
|
||||
tracesFilters := []map[string]interface{}{
|
||||
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "hasError", "dataType": "bool", "type": "tag"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "name", "dataType": "string", "type": "tag"},
|
||||
{"key": "rpc.method", "dataType": "string", "type": "tag"},
|
||||
{"key": "response_status_code", "dataType": "string", "type": "tag"},
|
||||
{"key": "http_host", "dataType": "string", "type": "tag"},
|
||||
{"key": "http.method", "dataType": "string", "type": "tag"},
|
||||
{"key": "http.route", "dataType": "string", "type": "tag"},
|
||||
{"key": "http_url", "dataType": "string", "type": "tag"},
|
||||
{"key": "trace_id", "dataType": "string", "type": "tag"},
|
||||
tracesFilters := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
||||
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "hasError", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeBool},
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "name", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "rpc.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "response_status_code", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http_host", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http.route", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http_url", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "trace_id", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
}
|
||||
|
||||
logsFilters := []map[string]interface{}{
|
||||
{"key": "severity_text", "dataType": "string", "type": "resource"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
|
||||
logsFilters := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "severity_text", FieldContext: telemetrytypes.FieldContextLog, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "host.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "k8s.cluster.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "k8s.deployment.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "k8s.namespace.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "k8s.pod.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
}
|
||||
|
||||
apiMonitoringFilters := []map[string]interface{}{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "rpc.method", "dataType": "string", "type": "tag"},
|
||||
apiMonitoringFilters := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "rpc.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
}
|
||||
|
||||
exceptionsFilters := []map[string]interface{}{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
|
||||
exceptionsFilters := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "host.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "k8s.cluster.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "k8s.deployment.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "k8s.namespace.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "k8s.pod.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
}
|
||||
|
||||
meterFilters := []map[string]interface{}{
|
||||
{"key": "deployment.environment", "dataType": "float64", "type": "Sum"},
|
||||
{"key": "service.name", "dataType": "float64", "type": "Sum"},
|
||||
{"key": "host.name", "dataType": "float64", "type": "Sum"},
|
||||
// Meter keys are label names with no context or datatype: the meter fields
|
||||
// API returns them as name+signal only, so the defaults mirror that shape.
|
||||
meterFilters := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "deployment.environment", Signal: telemetrytypes.SignalMetrics},
|
||||
{Name: "service.name", Signal: telemetrytypes.SignalMetrics},
|
||||
{Name: "host.name", Signal: telemetrytypes.SignalMetrics},
|
||||
}
|
||||
|
||||
// AI observability (builder_ai_query trace explorer), ordered by expected
|
||||
// usage: env scoping, the LLM identity keys, then service and the rest.
|
||||
aiObservabilityFilters := []map[string]interface{}{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": aiobservabilitytypes.GenAIOperationName, "dataType": "string", "type": "tag"},
|
||||
{"key": aiobservabilitytypes.GenAIProviderName, "dataType": "string", "type": "tag"},
|
||||
{"key": aiobservabilitytypes.GenAIRequestModel, "dataType": "string", "type": "tag"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": aiobservabilitytypes.GenAIToolName, "dataType": "string", "type": "tag"},
|
||||
{"key": aiobservabilitytypes.GenAIAgentName, "dataType": "string", "type": "tag"},
|
||||
aiObservabilityFilters := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "deployment.environment", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: aiobservabilitytypes.GenAIOperationName, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: aiobservabilitytypes.GenAIProviderName, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: aiobservabilitytypes.GenAIRequestModel, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: aiobservabilitytypes.GenAIToolName, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: aiobservabilitytypes.GenAIAgentName, FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
}
|
||||
|
||||
tracesJSON, err := json.Marshal(tracesFilters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal traces filters")
|
||||
defaults := []struct {
|
||||
signal Signal
|
||||
filters []telemetrytypes.TelemetryFieldKey
|
||||
}{
|
||||
{SignalTraces, tracesFilters},
|
||||
{SignalLogs, logsFilters},
|
||||
{SignalApiMonitoring, apiMonitoringFilters},
|
||||
{SignalExceptions, exceptionsFilters},
|
||||
{SignalMeter, meterFilters},
|
||||
{SignalAiObservability, aiObservabilityFilters},
|
||||
}
|
||||
|
||||
logsJSON, err := json.Marshal(logsFilters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal logs filters")
|
||||
storableQuickFilters := make([]*StorableQuickFilter, 0, len(defaults))
|
||||
for _, def := range defaults {
|
||||
storableQuickFilter, err := NewStorableQuickFilter(orgID, def.signal, def.filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storableQuickFilters = append(storableQuickFilters, storableQuickFilter)
|
||||
}
|
||||
|
||||
apiMonitoringJSON, err := json.Marshal(apiMonitoringFilters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal api monitoring filters")
|
||||
}
|
||||
|
||||
exceptionsJSON, err := json.Marshal(exceptionsFilters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal exceptions filters")
|
||||
}
|
||||
|
||||
meterJSON, err := json.Marshal(meterFilters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal meter filters")
|
||||
}
|
||||
|
||||
aiObservabilityJSON, err := json.Marshal(aiObservabilityFilters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal ai observability filters")
|
||||
}
|
||||
|
||||
timeRightNow := time.Now()
|
||||
|
||||
return []*StorableQuickFilter{
|
||||
{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: orgID,
|
||||
Filter: string(tracesJSON),
|
||||
Signal: SignalTraces,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: timeRightNow,
|
||||
UpdatedAt: timeRightNow,
|
||||
},
|
||||
},
|
||||
{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: orgID,
|
||||
Filter: string(logsJSON),
|
||||
Signal: SignalLogs,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: timeRightNow,
|
||||
UpdatedAt: timeRightNow,
|
||||
},
|
||||
},
|
||||
{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: orgID,
|
||||
Filter: string(apiMonitoringJSON),
|
||||
Signal: SignalApiMonitoring,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: timeRightNow,
|
||||
UpdatedAt: timeRightNow,
|
||||
},
|
||||
},
|
||||
{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: orgID,
|
||||
Filter: string(exceptionsJSON),
|
||||
Signal: SignalExceptions,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: timeRightNow,
|
||||
UpdatedAt: timeRightNow,
|
||||
},
|
||||
},
|
||||
{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: orgID,
|
||||
Filter: string(meterJSON),
|
||||
Signal: SignalMeter,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: timeRightNow,
|
||||
UpdatedAt: timeRightNow,
|
||||
},
|
||||
},
|
||||
{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: orgID,
|
||||
Filter: string(aiObservabilityJSON),
|
||||
Signal: SignalAiObservability,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: timeRightNow,
|
||||
UpdatedAt: timeRightNow,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
return storableQuickFilters, nil
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ var (
|
||||
const savedViewNameSuffixLen = 8
|
||||
|
||||
var (
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
SourceMetrics = Source{valuer.NewString("metrics")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
SourceMetrics = Source{valuer.NewString("metrics")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceAIObservability = Source{valuer.NewString("ai_observability")}
|
||||
)
|
||||
|
||||
type SavedView struct {
|
||||
@@ -117,12 +118,13 @@ func (Source) Enum() []any {
|
||||
SourceLogs,
|
||||
SourceMetrics,
|
||||
SourceMeter,
|
||||
SourceAIObservability,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Source) Validate() error {
|
||||
switch s {
|
||||
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter:
|
||||
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter, SourceAIObservability:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid source: %s", s.StringValue())
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestSourceValidate(t *testing.T) {
|
||||
{name: "logs", source: SourceLogs},
|
||||
{name: "metrics", source: SourceMetrics},
|
||||
{name: "meter", source: SourceMeter},
|
||||
{name: "ai_observability", source: SourceAIObservability},
|
||||
{name: "unknown is rejected", source: Source{valuer.NewString("bogus")}, expectError: true},
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,21 @@ func TestSavedViewSpecValidate(t *testing.T) {
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "builder_ai_query is valid",
|
||||
spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeList,
|
||||
RequestType: qbtypes.RequestTypeRaw,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
},
|
||||
}},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "graph panel query with no aggregation is still rejected",
|
||||
spec: SavedViewSpec{
|
||||
|
||||
459
tests/e2e/fixtures/alerts/alert-history.ts
Normal file
459
tests/e2e/fixtures/alerts/alert-history.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import type { Browser } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createMetricAlertViaApi,
|
||||
createNoDataAlertViaApi,
|
||||
createTracesAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
setRuleDisabledViaApi,
|
||||
} from '../../helpers/alerts/api';
|
||||
import {
|
||||
readTimelineTotal,
|
||||
waitForTimelineEntries,
|
||||
waitForTimelineStates,
|
||||
} from '../../helpers/alerts/history';
|
||||
import {
|
||||
seedAlertHistoryLogs,
|
||||
seedAlertHistoryMetrics,
|
||||
seedAlertHistoryTraces,
|
||||
} from '../../helpers/alerts/seeding';
|
||||
import { expect, test as base, withAdminPage } from './alert-rules';
|
||||
import {
|
||||
FIXTURE_ALERT_HISTORY_TIMEOUT,
|
||||
FIXTURE_EMPTY_HISTORY_TIMEOUT,
|
||||
FIXTURE_METRICS_HISTORY_TIMEOUT,
|
||||
FIXTURE_NODATA_HISTORY_TIMEOUT,
|
||||
FIXTURE_RESOLVED_HISTORY_TIMEOUT,
|
||||
FIXTURE_TRACES_HISTORY_TIMEOUT,
|
||||
WAIT_METRICS_TIMELINE_TIMEOUT,
|
||||
WAIT_NODATA_TIMELINE_TIMEOUT,
|
||||
} from './timeouts';
|
||||
|
||||
// Worker-scoped alert-history fixtures. Extends `alert-rules`, so a spec that
|
||||
// imports `test` from here also gets `alertChannel` / `alertList` / `ownedRules`
|
||||
// — the details specs need a history seed *and* their own throwaway rules.
|
||||
//
|
||||
// Every history row has to come from the ruler actually evaluating a rule (there
|
||||
// is no seeder endpoint for `rule_state_history_v0`), so each fixture pays a
|
||||
// real ruler wait: ~20-35s for logs, ~10s for metrics, ~105s for firing→resolved.
|
||||
// Worker scope means one wait per worker instead of one per test, and Playwright
|
||||
// creates each fixture lazily — a spec that never asks for `resolvedHistory`
|
||||
// never pays its 105s.
|
||||
|
||||
/** Service count for `alertHistory`. 25 yields multi-page timeline + pagination tests. */
|
||||
const LOGS_HISTORY_SERVICES = 25;
|
||||
|
||||
/** Service count for `resolvedHistory`. 3 services + 1m window = resolves in ~105s. */
|
||||
const RESOLVED_HISTORY_SERVICES = 3;
|
||||
|
||||
/** Hosts for `metricsHistory`. 2 rows fit one page, no related-logs links. */
|
||||
const METRICS_HISTORY_HOSTS = ['host-0', 'host-1'];
|
||||
|
||||
/** Service count for `tracesHistory`. 3 keeps wait short while proving traces link. */
|
||||
const TRACES_HISTORY_SERVICES = 3;
|
||||
|
||||
/** Team label for the v1 rule in `alertHistory`, so its header labels row is non-empty. */
|
||||
export const V1_RULE_TEAM_LABEL = 'e2e-platform';
|
||||
|
||||
export interface AlertHistorySeed {
|
||||
/** v2 (`schemaVersion: v2alpha1`) rule — the default history subject. */
|
||||
ruleId: string;
|
||||
/** Legacy v1 rule over the same logs. Its `threshold.name` is `warning`. */
|
||||
ruleIdV1: string;
|
||||
channelName: string;
|
||||
/** The `body CONTAINS` marker both rules match. */
|
||||
marker: string;
|
||||
/** The seeded `service.name` values, in creation order. */
|
||||
services: string[];
|
||||
/** Baseline `total` for {@link ruleId}, read after the rule was frozen. */
|
||||
total: number;
|
||||
/** Baseline `total` for {@link ruleIdV1}. */
|
||||
totalV1: number;
|
||||
}
|
||||
|
||||
export interface MetricsHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
metricName: string;
|
||||
hosts: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TracesHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
/** The span `name` the rule matches (`name = '<marker>'`). */
|
||||
marker: string;
|
||||
services: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ResolvedHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
marker: string;
|
||||
services: string[];
|
||||
/** Rows in the `firing` state — equals `stats.totalCurrentTriggers`. */
|
||||
firingCount: number;
|
||||
/** Rows in the `inactive` state, i.e. what the `Resolved` filter shows. */
|
||||
resolvedCount: number;
|
||||
}
|
||||
|
||||
export interface NoDataHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
export interface EmptyHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
async function cleanup(
|
||||
browser: Browser,
|
||||
{ ruleIds, channelId }: { ruleIds: string[]; channelId?: string },
|
||||
): Promise<void> {
|
||||
await withAdminPage(browser, async (page) => {
|
||||
for (const id of ruleIds) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteAlertViaApi(page, id);
|
||||
}
|
||||
if (channelId) {
|
||||
await deleteChannelViaApi(page, channelId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture setup functions ---
|
||||
|
||||
interface HistoryFixtureResult<T> {
|
||||
seed: T;
|
||||
ruleIds: string[];
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
async function createAlertHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<AlertHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert history ${stamp}`;
|
||||
|
||||
const result = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: LOGS_HISTORY_SERVICES,
|
||||
servicePrefix: `e2e-ah-svc`,
|
||||
});
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v2-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v2',
|
||||
});
|
||||
const ruleIdV1 = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v1-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v1',
|
||||
extraLabels: { team: V1_RULE_TEAM_LABEL },
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: LOGS_HISTORY_SERVICES });
|
||||
await waitForTimelineEntries(page, ruleIdV1, { min: LOGS_HISTORY_SERVICES });
|
||||
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
await setRuleDisabledViaApi(page, ruleIdV1, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
ruleIdV1,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
totalV1: await readTimelineTotal(page, ruleIdV1),
|
||||
},
|
||||
ruleIds: [ruleId, ruleIdV1],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.seed.total !== LOGS_HISTORY_SERVICES) {
|
||||
throw new Error(
|
||||
`alertHistory expected ${LOGS_HISTORY_SERVICES} timeline rows, got ${result.seed.total}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createMetricsHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<MetricsHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const metricName = `e2e_ah_probe_metric_${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-metrics-ch-${stamp}`,
|
||||
);
|
||||
|
||||
await seedAlertHistoryMetrics(page, {
|
||||
metricName,
|
||||
hosts: METRICS_HISTORY_HOSTS,
|
||||
});
|
||||
|
||||
const ruleId = await createMetricAlertViaApi(page, {
|
||||
name: `e2e-ah-metrics-rule-${stamp}`,
|
||||
metricName,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: METRICS_HISTORY_HOSTS.length,
|
||||
timeoutMs: WAIT_METRICS_TIMELINE_TIMEOUT,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
metricName,
|
||||
hosts: METRICS_HISTORY_HOSTS,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createTracesHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<TracesHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e-aht-span-${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-traces-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const services = await seedAlertHistoryTraces(page, {
|
||||
marker,
|
||||
services: TRACES_HISTORY_SERVICES,
|
||||
servicePrefix: 'e2e-aht-svc',
|
||||
});
|
||||
|
||||
const ruleId = await createTracesAlertViaApi(page, {
|
||||
name: `e2e-ah-traces-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: TRACES_HISTORY_SERVICES });
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createResolvedHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<ResolvedHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert resolved ${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-resolved-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: RESOLVED_HISTORY_SERVICES,
|
||||
ageSeconds: 40,
|
||||
minAgeSeconds: 28,
|
||||
servicePrefix: 'e2e-ahr-svc',
|
||||
});
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-resolved-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
evalWindow: '1m0s',
|
||||
});
|
||||
|
||||
const timeline = await waitForTimelineStates(page, ruleId, {
|
||||
states: {
|
||||
firing: RESOLVED_HISTORY_SERVICES,
|
||||
inactive: RESOLVED_HISTORY_SERVICES,
|
||||
},
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
|
||||
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createNoDataHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<NoDataHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-nodata-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const ruleId = await createNoDataAlertViaApi(page, {
|
||||
name: `e2e-ah-nodata-rule-${stamp}`,
|
||||
marker: `e2e alert nodata ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: 1,
|
||||
state: 'nodata',
|
||||
timeoutMs: WAIT_NODATA_TIMELINE_TIMEOUT,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: { ruleId, channelName: channel.name },
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createEmptyHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<EmptyHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-empty-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-empty-rule-${stamp}`,
|
||||
marker: `e2e alert never seeded ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: { ruleId, channelName: channel.name },
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture definitions ---
|
||||
|
||||
export const test = base.extend<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
{},
|
||||
{
|
||||
alertHistory: AlertHistorySeed;
|
||||
metricsHistory: MetricsHistorySeed;
|
||||
tracesHistory: TracesHistorySeed;
|
||||
resolvedHistory: ResolvedHistorySeed;
|
||||
noDataHistory: NoDataHistorySeed;
|
||||
emptyHistory: EmptyHistorySeed;
|
||||
}
|
||||
>({
|
||||
alertHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createAlertHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_ALERT_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
metricsHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createMetricsHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_METRICS_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
tracesHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createTracesHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_TRACES_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
resolvedHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } =
|
||||
await createResolvedHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_RESOLVED_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
noDataHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createNoDataHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_NODATA_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
emptyHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createEmptyHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_EMPTY_HISTORY_TIMEOUT },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
245
tests/e2e/fixtures/alerts/alert-rules.ts
Normal file
245
tests/e2e/fixtures/alerts/alert-rules.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { Browser, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createThresholdAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
} from '../../helpers/alerts/api';
|
||||
import { seedAlertRules } from '../../helpers/alerts/seeding';
|
||||
import type {
|
||||
AlertSchema,
|
||||
LogsAlertSeed,
|
||||
ThresholdAlertSeed,
|
||||
} from '../../helpers/alerts/types';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { expect, test as base } from '../auth';
|
||||
import { FIXTURE_ALERT_LIST_TIMEOUT } from './timeouts';
|
||||
|
||||
// Alert *rule* fixtures — the API-only half of the alerts suite. Nothing here
|
||||
// waits on the ruler: a rule is created and that's it. History rows need real
|
||||
// evaluations, so those fixtures live in `alert-history.ts`, which
|
||||
// extends this module — a spec importing from there gets both sets.
|
||||
//
|
||||
// Scopes, and why:
|
||||
// `alertChannel` — worker. Every rule payload has to reference a channel by
|
||||
// name, and one channel serves the whole worker.
|
||||
// `alertList` — worker. Read-only rule list the `tests/alerts/list` specs
|
||||
// page, search and sort through. Names and label values are stamped per
|
||||
// worker so parallel batches never count each other's rules.
|
||||
// `ownedRules` — test. Scenarios that rename/toggle/clone/delete a rule seed
|
||||
// their own and have it removed when they finish; mutating a shared seed
|
||||
// would break every scenario scheduled after it.
|
||||
|
||||
export interface AlertChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AlertListSeed {
|
||||
channelName: string;
|
||||
/** Rules are named `<namePrefix>-NN` — unique to this worker's batch. */
|
||||
namePrefix: string;
|
||||
/** Rules seeded ⇒ the `of N` total once the list is scoped to the prefix. */
|
||||
count: number;
|
||||
/** `team` label on the odd-indexed half of the batch, i.e. `count / 2` rules. */
|
||||
paymentsLabel: string;
|
||||
ruleIds: string[];
|
||||
}
|
||||
|
||||
export interface OwnedRules {
|
||||
/** Seed a metric threshold rule this test owns. */
|
||||
threshold(
|
||||
name: string,
|
||||
overrides?: Partial<Omit<ThresholdAlertSeed, 'name'>>,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Seed a logs rule this test owns. No telemetry is seeded for its marker, so
|
||||
* it never fires — enough for anything about the details shell.
|
||||
*
|
||||
* `schema: 'v1'` posts the legacy payload; the condition overrides exist so
|
||||
* a v1 *prefill* assertion can be made against values the create form would
|
||||
* not have produced by itself.
|
||||
*/
|
||||
logs(
|
||||
options: {
|
||||
name: string;
|
||||
schema?: AlertSchema;
|
||||
marker?: string;
|
||||
} & Partial<
|
||||
Pick<
|
||||
LogsAlertSeed,
|
||||
'severity' | 'extraLabels' | 'evalWindow' | 'target' | 'op' | 'matchType'
|
||||
>
|
||||
>,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Track a rule the *app* created (Clone / Duplicate) so teardown removes it
|
||||
* too. Lives here because the id may legitimately be missing and a
|
||||
* conditional inside a test body is a lint error.
|
||||
*/
|
||||
register(response: { json: () => Promise<unknown> }): Promise<void>;
|
||||
}
|
||||
|
||||
/** alertList size. 12 over page size 10 = short second page for pagination tests. */
|
||||
const LIST_SEED_COUNT = 12;
|
||||
|
||||
/**
|
||||
* Run `body` on a throwaway admin page. Worker hooks can't use the test-scoped
|
||||
* `authedPage`, and every API helper needs a page whose context carries the
|
||||
* admin storage state.
|
||||
*/
|
||||
export async function withAdminPage<T>(
|
||||
browser: Browser,
|
||||
body: (page: Page) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
return await body(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRules(browser: Browser, ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
await withAdminPage(browser, async (page) => {
|
||||
for (const id of ids) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteAlertViaApi(page, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture setup/teardown functions ---
|
||||
|
||||
async function createAlertChannel(
|
||||
browser: Browser,
|
||||
workerIndex: number,
|
||||
): Promise<AlertChannel> {
|
||||
return withAdminPage(browser, (page) =>
|
||||
createEmailChannelViaApi(page, `e2e-alerts-ch-w${workerIndex}-${Date.now()}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteAlertChannel(
|
||||
browser: Browser,
|
||||
channelId: string,
|
||||
): Promise<void> {
|
||||
await withAdminPage(browser, (page) => deleteChannelViaApi(page, channelId));
|
||||
}
|
||||
|
||||
async function createAlertList(
|
||||
browser: Browser,
|
||||
channelName: string,
|
||||
workerIndex: number,
|
||||
): Promise<AlertListSeed> {
|
||||
const stamp = `w${workerIndex}-${Date.now()}`;
|
||||
const namePrefix = `e2e-alert-list-${stamp}`;
|
||||
const teamSuffix = `-${stamp}`;
|
||||
|
||||
const ruleIds = await withAdminPage(browser, (page) =>
|
||||
seedAlertRules(page, {
|
||||
count: LIST_SEED_COUNT,
|
||||
channelName,
|
||||
namePrefix,
|
||||
teamSuffix,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
channelName,
|
||||
namePrefix,
|
||||
count: LIST_SEED_COUNT,
|
||||
paymentsLabel: `payments${teamSuffix}`,
|
||||
ruleIds,
|
||||
};
|
||||
}
|
||||
|
||||
function createOwnedRulesFactory(
|
||||
browser: Browser,
|
||||
channelName: string,
|
||||
ids: Set<string>,
|
||||
): OwnedRules {
|
||||
const seed = async (
|
||||
create: (page: Page) => Promise<string>,
|
||||
): Promise<string> => {
|
||||
const id = await withAdminPage(browser, create);
|
||||
ids.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
return {
|
||||
threshold: (name, overrides = {}) =>
|
||||
seed((page) =>
|
||||
createThresholdAlertViaApi(page, {
|
||||
name,
|
||||
target: 42,
|
||||
channels: [channelName],
|
||||
labels: { severity: 'critical' },
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
|
||||
seed((page) =>
|
||||
createLogsAlertViaApi(page, {
|
||||
name,
|
||||
marker: marker ?? `e2e alert never seeded ${name}`,
|
||||
channels: [channelName],
|
||||
schema,
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
register: async (response) => {
|
||||
const body = (await response.json()) as { data?: { id?: string } };
|
||||
const id = body.data?.id;
|
||||
if (id) {
|
||||
ids.add(String(id));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Fixture definitions ---
|
||||
|
||||
export const test = base.extend<
|
||||
{ ownedRules: OwnedRules },
|
||||
{ alertChannel: AlertChannel; alertList: AlertListSeed }
|
||||
>({
|
||||
alertChannel: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const channel = await createAlertChannel(browser, workerInfo.workerIndex);
|
||||
await use(channel);
|
||||
await deleteAlertChannel(browser, channel.id);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
alertList: [
|
||||
async ({ browser, alertChannel }, use, workerInfo) => {
|
||||
const seed = await createAlertList(
|
||||
browser,
|
||||
alertChannel.name,
|
||||
workerInfo.workerIndex,
|
||||
);
|
||||
await use(seed);
|
||||
await deleteRules(browser, seed.ruleIds);
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_ALERT_LIST_TIMEOUT },
|
||||
],
|
||||
|
||||
ownedRules: async ({ browser, alertChannel }, use) => {
|
||||
const ids = new Set<string>();
|
||||
const factory = createOwnedRulesFactory(browser, alertChannel.name, ids);
|
||||
await use(factory);
|
||||
await deleteRules(browser, [...ids]);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
96
tests/e2e/fixtures/alerts/timeouts.ts
Normal file
96
tests/e2e/fixtures/alerts/timeouts.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Centralized timeout constants for alert fixtures.
|
||||
*
|
||||
* **Ruler**: SigNoz's alert evaluation engine. Runs on ~15s cycles, checks each
|
||||
* rule's query against ClickHouse, writes results to `rule_state_history_v0`.
|
||||
* There's no API to force-evaluate or seed history directly, so fixtures must
|
||||
* poll the timeline endpoint until the ruler writes rows.
|
||||
*
|
||||
* Fixture timeouts are set generously because:
|
||||
* 1. CI environments are slower than local dev machines
|
||||
* 2. Ruler evaluation depends on Kafka/ClickHouse latency
|
||||
* 3. A timeout should mean "something is broken", not "it's just slow today"
|
||||
*
|
||||
* Typical measured times (local):
|
||||
* - API call (create/delete rule/channel): ~1-2s
|
||||
* - waitForTimelineEntries (logs, 25 services): ~20-35s
|
||||
* - waitForTimelineEntries (metrics, 2 hosts): ~10s
|
||||
* - waitForTimelineStates (firing→resolved): ~105s
|
||||
* - waitForTimelineEntries (nodata state): ~60-120s
|
||||
*/
|
||||
|
||||
// ─── Fixture-specific wait overrides (ms) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Metrics history wait override. Metrics push faster than logs, but ruler still
|
||||
* needs 2 evaluation windows to confirm state. 10s typical, 120s defensive.
|
||||
*/
|
||||
export const WAIT_METRICS_TIMELINE_TIMEOUT = 120_000;
|
||||
|
||||
/**
|
||||
* Nodata state detection. Ruler must evaluate twice with empty result set.
|
||||
* Takes longer than firing detection because it's an absence check.
|
||||
*/
|
||||
export const WAIT_NODATA_TIMELINE_TIMEOUT = 180_000;
|
||||
|
||||
// ─── Fixture timeouts (ms) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* alertList: seeds 12 rules via API.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + 12×createRule(24s) = ~26s.
|
||||
* Timeout: 120s (~5x headroom for CI).
|
||||
*/
|
||||
export const FIXTURE_ALERT_LIST_TIMEOUT = 120_000;
|
||||
|
||||
/**
|
||||
* alertHistory: seeds 25 logs + 2 rules, waits for ruler evaluation.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + seedLogs(10s) + 2×createRule(4s) +
|
||||
* 2×waitForEntries(70s) + 2×disableRule(4s) = ~90s.
|
||||
* Timeout: 240s (~2.5x headroom).
|
||||
*/
|
||||
export const FIXTURE_ALERT_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
/**
|
||||
* metricsHistory: seeds 2 hosts, waits for metrics ruler cycle.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + seedMetrics(5s) + createRule(2s) +
|
||||
* waitForEntries(10s actual, 120s budget) + disableRule(2s) = ~21s.
|
||||
* Timeout: 240s (matches alertHistory for consistency).
|
||||
*/
|
||||
export const FIXTURE_METRICS_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
/**
|
||||
* tracesHistory: seeds 3 trace services, waits for ruler.
|
||||
*
|
||||
* Breakdown: similar to alertHistory but fewer services = ~45s.
|
||||
* Timeout: 240s (~5x headroom).
|
||||
*/
|
||||
export const FIXTURE_TRACES_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
/**
|
||||
* resolvedHistory: waits for firing→resolved transition.
|
||||
*
|
||||
* Breakdown: setup(30s) + waitForStates(105s) = ~135s.
|
||||
* Timeout: 300s (~2x headroom). Longest because resolved requires
|
||||
* evalWindow expiry after data stops matching.
|
||||
*/
|
||||
export const FIXTURE_RESOLVED_HISTORY_TIMEOUT = 300_000;
|
||||
|
||||
/**
|
||||
* noDataHistory: waits for nodata state to appear.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + createRule(2s) + waitForEntries(60-120s).
|
||||
* Timeout: 300s. Nodata detection is slowest because ruler must confirm
|
||||
* absence across multiple evaluation cycles.
|
||||
*/
|
||||
export const FIXTURE_NODATA_HISTORY_TIMEOUT = 300_000;
|
||||
|
||||
/**
|
||||
* emptyHistory: creates rule then immediately disables it (no ruler wait).
|
||||
*
|
||||
* Breakdown: createChannel(2s) + createRule(2s) + disableRule(2s) = ~6s.
|
||||
* Timeout: 120s (generous for slow CI, no ruler dependency).
|
||||
*/
|
||||
export const FIXTURE_EMPTY_HISTORY_TIMEOUT = 120_000;
|
||||
@@ -1,81 +1,11 @@
|
||||
import {
|
||||
test as base,
|
||||
expect,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
|
||||
|
||||
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
|
||||
export const ADMIN: User = {
|
||||
email: process.env.SIGNOZ_E2E_USERNAME!,
|
||||
password: process.env.SIGNOZ_E2E_PASSWORD!,
|
||||
};
|
||||
|
||||
// Per-worker storageState cache. One login per unique user per worker.
|
||||
// Promise-valued so concurrent requests share the same in-flight work.
|
||||
// Held in memory only — no .auth/ dir, no JSON on disk.
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await login(page, user);
|
||||
await pinSidenav(page);
|
||||
const state = await ctx.storageState();
|
||||
await ctx.close();
|
||||
return state;
|
||||
})();
|
||||
|
||||
storageByUser.set(user.email, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function login(page: Page, user: User): Promise<void> {
|
||||
if (!user.email || !user.password) {
|
||||
throw new Error(
|
||||
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
|
||||
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
|
||||
);
|
||||
}
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(user.email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
// Post-login lands somewhere different depending on whether the org is
|
||||
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
|
||||
// welcome). Wait for URL to move off /login — whichever page follows
|
||||
// is fine, each spec navigates to the feature under test anyway.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
}
|
||||
|
||||
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
|
||||
// Server-side pref, so set once per user at login.
|
||||
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') || '',
|
||||
);
|
||||
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: true },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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<{
|
||||
/**
|
||||
@@ -95,7 +25,7 @@ export const test = base.extend<{
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
authedPage: async ({ browser, user }, use) => {
|
||||
const storageState = await storageFor(browser, user);
|
||||
const storageState = await storageStateFor(browser, user);
|
||||
const ctx = await browser.newContext({ storageState });
|
||||
const page = await ctx.newPage();
|
||||
// Opt-in CPU throttling to reproduce GitHub-Linux-runner conditions on
|
||||
|
||||
105
tests/e2e/helpers/alert-forms/constants.ts
Normal file
105
tests/e2e/helpers/alert-forms/constants.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// ─── Routes ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_NEW_PATH = '/alerts/new';
|
||||
|
||||
/**
|
||||
* The standalone edit route. Distinct from `/alerts/overview`, which renders the
|
||||
* *same* editor inside the details shell. The two are not interchangeable for v2
|
||||
* rules — see `edit/v2.spec.ts` EV2-12.
|
||||
*/
|
||||
export const ALERT_EDIT_PATH = '/alerts/edit';
|
||||
|
||||
// ─── Enums mirrored from the frontend ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* URL values of `AlertTypes` (`frontend/src/types/api/alerts/alertTypes.ts`).
|
||||
* Note `METRICS` maps to the *singular* `METRIC_BASED_ALERT` — the enum key and
|
||||
* its value disagree in the source, and the URL carries the value.
|
||||
*/
|
||||
export const AlertType = {
|
||||
METRICS: 'METRIC_BASED_ALERT',
|
||||
LOGS: 'LOGS_BASED_ALERT',
|
||||
TRACES: 'TRACES_BASED_ALERT',
|
||||
EXCEPTIONS: 'EXCEPTIONS_BASED_ALERT',
|
||||
ANOMALY: 'ANOMALY_BASED_ALERT',
|
||||
} as const;
|
||||
|
||||
export type AlertTypeValue = (typeof AlertType)[keyof typeof AlertType];
|
||||
|
||||
/** `AlertDetectionTypes` (`frontend/src/container/FormAlertRules/index.tsx:78-81`). */
|
||||
export const RuleType = {
|
||||
THRESHOLD: 'threshold_rule',
|
||||
ANOMALY: 'anomaly_rule',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertThresholdOperator` (`CreateAlertV2/context/types.ts:97-105`) and its
|
||||
* dropdown labels (`context/constants.ts:123-137`).
|
||||
*
|
||||
* Threshold-alert operators only. Anomaly alerts render a different, shorter
|
||||
* list (`ANOMALY_THRESHOLD_OPERATOR_OPTIONS`) with relabelled entries.
|
||||
*/
|
||||
export const ThresholdOperator = {
|
||||
ABOVE: { value: 'above', label: 'ABOVE' },
|
||||
BELOW: { value: 'below', label: 'BELOW' },
|
||||
EQUAL_TO: { value: 'equal', label: 'EQUAL TO' },
|
||||
NOT_EQUAL_TO: { value: 'not_equal', label: 'NOT EQUAL TO' },
|
||||
ABOVE_OR_EQUAL_TO: { value: 'above_or_equal', label: 'ABOVE OR EQUAL TO' },
|
||||
BELOW_OR_EQUAL_TO: { value: 'below_or_equal', label: 'BELOW OR EQUAL TO' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertThresholdMatchType` (`CreateAlertV2/context/types.ts:105-111`) and its
|
||||
* dropdown labels (`context/constants.ts:136-142`).
|
||||
*
|
||||
* Watch the plural: the enum *key* is `ALL_THE_TIME` but the wire value is
|
||||
* `all_the_times`, and the API rejects the singular outright — the same
|
||||
* key/value mismatch as `METRICS_BASED_ALERT` → `METRIC_BASED_ALERT`.
|
||||
*/
|
||||
export const ThresholdMatchType = {
|
||||
AT_LEAST_ONCE: { value: 'at_least_once', label: 'AT LEAST ONCE' },
|
||||
ALL_THE_TIME: { value: 'all_the_times', label: 'ALL THE TIME' },
|
||||
ON_AVERAGE: { value: 'on_average', label: 'ON AVERAGE' },
|
||||
IN_TOTAL: { value: 'in_total', label: 'IN TOTAL' },
|
||||
LAST: { value: 'last', label: 'LAST' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertListTabs` (`frontend/src/pages/AlertList/types.ts:7-9`). The values are
|
||||
* space-less — the tab *labels* read "Triggered Alerts" but the `tab` URL param
|
||||
* is `TriggeredAlerts`, and asserting the label form silently fails.
|
||||
*/
|
||||
export const AlertListTab = {
|
||||
TRIGGERED_ALERTS: 'TriggeredAlerts',
|
||||
ALERT_RULES: 'AlertRules',
|
||||
CONFIGURATION: 'Configuration',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The four cards a stock stack shows, in render order
|
||||
* (`CreateAlertRule/SelectAlertType/config.ts:10-31`). Anomaly is `unshift`ed to
|
||||
* the **front** of this list when the `ANOMALY_DETECTION` feature flag is active,
|
||||
* so both the count and the order change when it is enabled.
|
||||
*/
|
||||
export const STOCK_ALERT_TYPE_CARDS: AlertTypeValue[] = [
|
||||
AlertType.METRICS,
|
||||
AlertType.LOGS,
|
||||
AlertType.TRACES,
|
||||
AlertType.EXCEPTIONS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Rolling-window presets (`EvaluationSettings/constants.ts:9-18`) paired with the
|
||||
* button label each one produces. A value *outside* this set collapses to `custom`
|
||||
* on load (`utils.tsx:86-96`), which is what makes it a prefill assertion worth
|
||||
* having: `10m0s` proves the seed was read, `7m0s` proves the fallback fired.
|
||||
*/
|
||||
export const EVALUATION_WINDOW_PRESETS = {
|
||||
'5m0s': 'Last 5 minutes',
|
||||
'10m0s': 'Last 10 minutes',
|
||||
'15m0s': 'Last 15 minutes',
|
||||
'30m0s': 'Last 30 minutes',
|
||||
'1h0m0s': 'Last 1 hour',
|
||||
'2h0m0s': 'Last 2 hours',
|
||||
'4h0m0s': 'Last 4 hours',
|
||||
} as const;
|
||||
118
tests/e2e/helpers/alert-forms/navigation.ts
Normal file
118
tests/e2e/helpers/alert-forms/navigation.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
ALERTS_NEW_PATH,
|
||||
AlertType,
|
||||
type AlertTypeValue,
|
||||
RuleType,
|
||||
STOCK_ALERT_TYPE_CARDS,
|
||||
} from './constants';
|
||||
import { v1SaveButton } from './v1';
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the bare type-selection page. `isTypeSelectionMode` is
|
||||
* `!alertType && !ruleType && !compositeQuery`
|
||||
* (`container/CreateAlertRule/index.tsx:39-41`), so *any* of those three params
|
||||
* skips this page — including a stale `compositeQuery` left in the URL.
|
||||
*/
|
||||
export async function gotoAlertTypeSelection(page: Page): Promise<void> {
|
||||
await page.goto(ALERTS_NEW_PATH);
|
||||
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
|
||||
}
|
||||
|
||||
export function alertTypeCard(page: Page, type: AlertTypeValue): Locator {
|
||||
return page.getByTestId(`alert-type-card-${type}`);
|
||||
}
|
||||
|
||||
function alertTypeCards(page: Page): Locator {
|
||||
return page.locator('[data-testid^="alert-type-card-"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the anomaly card is on the page, i.e. whether `ANOMALY_DETECTION` is
|
||||
* active for this stack. It **is** active on the pytest-bootstrapped integration
|
||||
* stack, so every card-count assertion has to branch on it rather than hard-code
|
||||
* 4.
|
||||
*/
|
||||
export async function hasAnomalyAlertTypeCard(page: Page): Promise<boolean> {
|
||||
return (await alertTypeCard(page, AlertType.ANOMALY).count()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the type-selection page shows exactly the expected set of cards: the
|
||||
* four stock ones, plus anomaly *first* when the flag is on (`getOptionList`
|
||||
* `unshift`s it, `SelectAlertType/config.ts:33-40`).
|
||||
*
|
||||
* Written as an exact set rather than "at least four" so that adding a fifth
|
||||
* signal still fails this assertion — the flag branch is the only slack.
|
||||
*/
|
||||
export async function expectAlertTypeCardSet(page: Page): Promise<void> {
|
||||
const anomaly = await hasAnomalyAlertTypeCard(page);
|
||||
const expected = anomaly
|
||||
? [AlertType.ANOMALY, ...STOCK_ALERT_TYPE_CARDS]
|
||||
: STOCK_ALERT_TYPE_CARDS;
|
||||
|
||||
const cards = alertTypeCards(page);
|
||||
await expect(cards).toHaveCount(expected.length);
|
||||
|
||||
// Read the testids positionally so order is asserted too — anomaly being
|
||||
// unshifted rather than appended is the behaviour worth pinning.
|
||||
const rendered: (string | null)[] = [];
|
||||
for (let i = 0; i < expected.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
rendered.push(await cards.nth(i).getAttribute('data-testid'));
|
||||
}
|
||||
expect(rendered).toEqual(expected.map((type) => `alert-type-card-${type}`));
|
||||
}
|
||||
|
||||
export interface CreateAlertUrlOptions {
|
||||
alertType?: AlertTypeValue;
|
||||
ruleType?: string;
|
||||
/** Sets `showClassicCreateAlertsPage=true` ⇒ the v1 classic form. */
|
||||
classic?: boolean;
|
||||
/** Merged in last, so it can override anything above. */
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function createAlertUrl({
|
||||
alertType = AlertType.LOGS,
|
||||
ruleType = RuleType.THRESHOLD,
|
||||
classic = false,
|
||||
params = {},
|
||||
}: CreateAlertUrlOptions = {}): string {
|
||||
const search = new URLSearchParams({ alertType, ruleType });
|
||||
if (classic) {
|
||||
search.set('showClassicCreateAlertsPage', 'true');
|
||||
}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
search.set(key, value);
|
||||
}
|
||||
return `${ALERTS_NEW_PATH}?${search.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the **v2** builder and wait until it has settled. The wait is two-part on
|
||||
* purpose: the header proves the builder mounted, and the `compositeQuery` in the
|
||||
* URL proves `useShareBuilderUrl` has finished serialising the default query —
|
||||
* without the second half, an assertion on the URL races the builder's own
|
||||
* rewrite (the same trap `gotoAlertOverview` documents).
|
||||
*/
|
||||
export async function gotoCreateAlertV2(
|
||||
page: Page,
|
||||
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
|
||||
): Promise<void> {
|
||||
await page.goto(createAlertUrl({ ...options, classic: false }));
|
||||
await expect(page.getByTestId('alert-name-input')).toBeVisible();
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Open the **v1** classic create form and wait for its primary action. */
|
||||
export async function gotoCreateAlertV1(
|
||||
page: Page,
|
||||
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
|
||||
): Promise<void> {
|
||||
await page.goto(createAlertUrl({ ...options, classic: true }));
|
||||
await expect(v1SaveButton(page)).toBeVisible();
|
||||
}
|
||||
154
tests/e2e/helpers/alert-forms/shared.ts
Normal file
154
tests/e2e/helpers/alert-forms/shared.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// ─── Antd select helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read an antd multi-select's chosen values, for asserting what a threshold row
|
||||
* ended up pointing at.
|
||||
*/
|
||||
export function selectedTags(scope: Locator): Locator {
|
||||
return scope.locator('.ant-select-selection-item-content');
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently-open antd dropdown. Scoping option lookups to it matters because
|
||||
* antd keeps previously-opened dropdowns in the DOM with
|
||||
* `.ant-select-dropdown-hidden`, so an unscoped `.ant-select-item-option` can
|
||||
* resolve into a stale list.
|
||||
*
|
||||
* Adequate when only one select is ever open on the page. When several selects of
|
||||
* the *same kind* exist — the per-threshold channel selects — use
|
||||
* {@link ownDropdown} instead: `-hidden` is applied only after the close
|
||||
* transition, so "the open dropdown" is briefly ambiguous.
|
||||
*/
|
||||
export function openDropdown(page: Page): Locator {
|
||||
return page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
|
||||
}
|
||||
|
||||
/**
|
||||
* The dropdown belonging to one specific antd select, resolved through the
|
||||
* combobox's `aria-controls` → the listbox id it owns.
|
||||
*
|
||||
* This is the only unambiguous way to address one of several sibling selects'
|
||||
* option lists. Filtering on "the visible dropdown" is not enough: with four
|
||||
* threshold rows, row N's list is still mid-close while row N+1's opens, so the
|
||||
* option lookup lands in the wrong list and the click fails with "element is not
|
||||
* stable" and then "element is not visible".
|
||||
*/
|
||||
export async function ownDropdown(
|
||||
page: Page,
|
||||
select: Locator,
|
||||
): Promise<Locator> {
|
||||
const listId = await select
|
||||
.locator('input[role="combobox"]')
|
||||
.getAttribute('aria-controls');
|
||||
if (!listId) {
|
||||
throw new Error(
|
||||
'select has no aria-controls — not an antd combobox, or not yet opened',
|
||||
);
|
||||
}
|
||||
return page
|
||||
.locator('.ant-select-dropdown')
|
||||
.filter({ has: page.locator(`[id="${listId}"]`) });
|
||||
}
|
||||
|
||||
/**
|
||||
* An option in the open dropdown, matched on its **exact** label. Substring
|
||||
* matching is wrong here: `hasText: 'EQUAL TO'` also matches `NOT EQUAL TO`.
|
||||
*/
|
||||
export function dropdownOption(page: Page, label: string): Locator {
|
||||
return openDropdown(page)
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ has: page.getByText(label, { exact: true }) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a notification channel by exact name in one of the two channel selects —
|
||||
* v2's per-threshold one and v1's single `alert-channel-select`. Both are
|
||||
* `mode="multiple"` antd selects over the *same* global channel list, so both need
|
||||
* exactly this sequence; the shared body is why this is one function rather than
|
||||
* two near-copies.
|
||||
*
|
||||
* The list must be **searched**, not scrolled. Channels are global while the
|
||||
* `alertChannel` fixture is worker-scoped, so a shared stack accumulates one
|
||||
* channel per worker (plus anything a killed run leaked) and antd virtualises the
|
||||
* dropdown: measured on this stack, 31 channels render **10** options into the DOM,
|
||||
* and the wanted one is simply not there. Clicking by name without filtering first
|
||||
* is therefore not a slow path, it is a missing element — and it was the single
|
||||
* biggest source of flake in this suite. It fails as a plain click timeout
|
||||
* ("waiting for locator … .ant-select-item-option …"), which reads like a renamed
|
||||
* testid rather than a virtualised list.
|
||||
*/
|
||||
export async function pickChannelByName(
|
||||
page: Page,
|
||||
select: Locator,
|
||||
channelName: string,
|
||||
): Promise<void> {
|
||||
const tagsBefore = await selectedTags(select).count();
|
||||
await select.click();
|
||||
await expect(select).toHaveClass(/ant-select-open/);
|
||||
|
||||
// `fill` on the combobox input rather than `keyboard.type`: the query is a ~30
|
||||
// character channel name and every keystroke re-runs antd's filter, so typing it
|
||||
// costs ~2.5 s per pick — CV2-09 makes four of them, which was a quarter of that
|
||||
// test's 30 s budget. `fill` sets the value in one input event, which is all
|
||||
// rc-select's search needs.
|
||||
await select.locator('input[role="combobox"]').fill(channelName);
|
||||
const dropdown = await ownDropdown(page, select);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: channelName })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// A multi-select stays open after a pick and its dropdown overlays the controls
|
||||
// below, which the next interaction would otherwise hit instead.
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Wait for *this* select to report itself closed before returning. antd removes
|
||||
// `.ant-select-dropdown-hidden` only after the close transition, so a caller that
|
||||
// immediately opens the next row's select races a still-visible stale list: the
|
||||
// option lookup then resolves inside the previous row's dropdown and the click
|
||||
// fails with "element is not stable" followed by "element is not visible".
|
||||
await expect(select).not.toHaveClass(/ant-select-open/);
|
||||
|
||||
// Fail here rather than three assertions later: a silently-missed pick shows up
|
||||
// as "Save is still disabled", which points at the validator instead of at this.
|
||||
//
|
||||
// Counted, not name-matched: v2's select sets `maxTagTextLength={10}`
|
||||
// (`ThresholdItem.tsx:140`) so its tag reads `e2e-alerts…`, and v1's passes
|
||||
// `optionLabelProp="label"` to options that carry no `label` prop, so its tag
|
||||
// renders empty. Neither can ever contain the full channel name. The name itself
|
||||
// is verified where it actually matters — in the request body (CV2-20, CV1-08).
|
||||
await expect(selectedTags(select)).toHaveCount(tagsBefore + 1);
|
||||
}
|
||||
|
||||
// ─── SEED-CH1: a stack with no notification channels ───────────────────────
|
||||
|
||||
/**
|
||||
* Route-stub `GET /api/v1/channels` to an empty list for this page only.
|
||||
*
|
||||
* This is the **one** place the alerts suite mocks the network, and it is a
|
||||
* deliberate exception to the standing no-stubbing rule. The justification: zero
|
||||
* channels is a real product state — every fresh install has it — and it is the
|
||||
* only state that reaches the `disabled` broadcast switch and
|
||||
* the empty-channel dropdown content. It cannot be produced server-side, because
|
||||
* `alertChannel` is worker-scoped and parallel workers share one stack, so
|
||||
* deleting the channel would break every other scenario running at that moment.
|
||||
*
|
||||
* Both forms read the same endpoint through `api/channels/getAll`, so one stub
|
||||
* covers v1 and v2.
|
||||
*/
|
||||
export async function stubNoChannels(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/channels', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'success', data: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
10
tests/e2e/helpers/alert-forms/v1.ts
Normal file
10
tests/e2e/helpers/alert-forms/v1.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The v1 primary action. Its *label* is mode-dependent — *Create Rule* when
|
||||
* `isNewRule`, *Save Rule* when editing (`FormAlertRules/index.tsx:970`) — so
|
||||
* scenarios that care about the mode assert the text; the locator itself does not.
|
||||
*/
|
||||
export function v1SaveButton(page: Page): Locator {
|
||||
return page.getByTestId('alert-save-button');
|
||||
}
|
||||
226
tests/e2e/helpers/alert-forms/v2.ts
Normal file
226
tests/e2e/helpers/alert-forms/v2.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { EVALUATION_WINDOW_PRESETS } from './constants';
|
||||
import { pickChannelByName } from './shared';
|
||||
|
||||
// ─── v2 builder ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Footer buttons. The disabled Save/Test buttons are wrapped in a `<span>` inside
|
||||
* an antd `Tooltip` (`CreateAlertV2/Footer/Footer.tsx:198-204`) — the wrapper is
|
||||
* why {@link v2SaveTooltip} exists instead of reading a `title` attribute, and
|
||||
* why these are testids rather than accessible names: the name lookup also
|
||||
* matched the wrapper in some states.
|
||||
*/
|
||||
export function v2SaveButton(page: Page): Locator {
|
||||
return page.getByTestId('save-alert-rule-button');
|
||||
}
|
||||
|
||||
export function v2TestButton(page: Page): Locator {
|
||||
return page.getByTestId('test-notification-button');
|
||||
}
|
||||
|
||||
export function v2DiscardButton(page: Page): Locator {
|
||||
return page.getByTestId('discard-alert-rule-button');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the v2 Discard button — via `dispatchEvent`, because a real click cannot
|
||||
* reach it.
|
||||
*
|
||||
* The footer is `position: fixed; left: 63px` (the *collapsed* nav rail width) and
|
||||
* Discard is its left-most control, so the button occupies roughly x 75-170 at the
|
||||
* bottom of the viewport. The side navigation occupies x 0-240 whenever it is
|
||||
* 240px wide, which is: always when pinned — the default — and transiently when
|
||||
* not pinned, because a mouse travelling toward the button crosses the rail and
|
||||
* triggers `:not(.pinned).is-hovered`. Either way `document.elementFromPoint` at
|
||||
* the button's centre returns the nav's `.nav-item-data`, so the nav swallows the
|
||||
* click.
|
||||
*
|
||||
* `{ force: true }` does **not** help: it skips Playwright's actionability wait
|
||||
* but still delivers a real mouse event at those coordinates, which the nav
|
||||
* receives. `dispatchEvent('click')` bypasses hit-testing entirely and React's
|
||||
* delegated handler fires normally — verified: the page navigates to `/alerts`.
|
||||
*
|
||||
* This is a workaround for a **product** bug, not for a flaky test.
|
||||
* `create/edge.spec.ts` CE-09 is the skipped scenario that asserts the fixed
|
||||
* behaviour; unskipping it and reverting this helper to `.click()` belong in the
|
||||
* same commit as the fix.
|
||||
*/
|
||||
export async function v2ClickDiscard(page: Page): Promise<void> {
|
||||
await v2DiscardButton(page).dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the side navigation currently overlaps a point — the mechanism behind
|
||||
* {@link v2ClickDiscard}. Used by CE-09, which asserts the *absence* of that
|
||||
* overlap and is skipped until the footer is fixed.
|
||||
*/
|
||||
export async function elementAtPointClassName(
|
||||
page: Page,
|
||||
x: number,
|
||||
y: number,
|
||||
): Promise<string> {
|
||||
return page.evaluate(
|
||||
([px, py]) => {
|
||||
const el = document.elementFromPoint(px as number, py as number);
|
||||
return el ? String(el.className) : '';
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover the (disabled) Save button and return the antd tooltip's text — this is
|
||||
* the only way to read `validateCreateAlertState`'s message, since the button
|
||||
* cannot be clicked while a message exists.
|
||||
*/
|
||||
export async function v2SaveTooltip(page: Page): Promise<string> {
|
||||
// The tooltip anchors to the wrapper span, not the disabled button: a disabled
|
||||
// button emits no pointer events, so hovering it directly never opens.
|
||||
await v2SaveButton(page).locator('xpath=..').hover();
|
||||
const tooltip = page.locator('.ant-tooltip-inner').first();
|
||||
await expect(tooltip).toBeVisible();
|
||||
return (await tooltip.innerText()).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold rows. There is **no** `threshold-item-<id>` testid — the row is a bare
|
||||
* `className="threshold-item"` (`AlertCondition/ThresholdItem.tsx`), so rows are
|
||||
* addressed positionally.
|
||||
*/
|
||||
export function thresholdRows(page: Page): Locator {
|
||||
return page.locator('.threshold-item');
|
||||
}
|
||||
|
||||
export function thresholdRow(page: Page, index: number): Locator {
|
||||
return thresholdRows(page).nth(index);
|
||||
}
|
||||
|
||||
/** Assign a notification channel to the Nth v2 threshold. */
|
||||
export async function selectThresholdChannel(
|
||||
page: Page,
|
||||
index: number,
|
||||
channelName: string,
|
||||
): Promise<void> {
|
||||
await pickChannelByName(
|
||||
page,
|
||||
page.getByTestId('threshold-notification-channel-select').nth(index),
|
||||
channelName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a label through the v2 header editor. The input is a single field with two
|
||||
* phases — key, then value, each committed with Enter
|
||||
* (`CreateAlertHeader/LabelsInput.tsx:25-93`) — and a `key:value` string in the
|
||||
* first phase is accepted as a shortcut. This helper drives the two-phase path
|
||||
* because that is what a user does.
|
||||
*/
|
||||
export async function addAlertLabel(
|
||||
page: Page,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId('alert-add-label-button').click();
|
||||
const input = page.getByTestId('alert-add-label-input');
|
||||
await input.fill(key);
|
||||
await input.press('Enter');
|
||||
await input.fill(value);
|
||||
await input.press('Enter');
|
||||
|
||||
// Committing a label does *not* close the editor — `isAdding` stays true so a
|
||||
// user can type several in a row, which means `alert-add-label-button` is still
|
||||
// unmounted. Escape (with both fields empty) is what closes it, and without this
|
||||
// a second call to this helper waits forever for the add button.
|
||||
await input.press('Escape');
|
||||
await expect(page.getByTestId('alert-add-label-button')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle inside an `AdvancedOptionItem` (repeat notifications, send-if-missing,
|
||||
* enforce-minimum-datapoints). The `Switch` there carries no testid of its own, so
|
||||
* it is reached through the container's — hence the container testid being the
|
||||
* documented handle rather than the switch.
|
||||
*/
|
||||
export function advancedOptionToggle(
|
||||
page: Page,
|
||||
containerTestId: string,
|
||||
): Locator {
|
||||
return page.getByTestId(containerTestId).locator('[role="switch"]');
|
||||
}
|
||||
|
||||
// ─── Evaluation window + cadence ───────────────────────────────────────────
|
||||
|
||||
export function evaluationSettingsButton(page: Page): Locator {
|
||||
return page.getByTestId('evaluation-settings-button');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the evaluation-window popover. It is an antd `Popover`, so its content is
|
||||
* only in the DOM while open — every option lookup has to come after this.
|
||||
*/
|
||||
export async function openEvaluationSettings(page: Page): Promise<void> {
|
||||
await evaluationSettingsButton(page).click();
|
||||
await expect(page.locator('.evaluation-window-popover')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* A popover option. The popover renders two lists from one component, keyed by
|
||||
* `data-section-id` — `window-type` (Rolling / Cumulative) and `timeframe` — and
|
||||
* the testid carries both, so `timeframe-option-10m0s` cannot collide with a
|
||||
* window-type value.
|
||||
*/
|
||||
export function evaluationWindowOption(
|
||||
page: Page,
|
||||
section: 'window-type' | 'timeframe',
|
||||
value: string,
|
||||
): Locator {
|
||||
return page.getByTestId(`${section}-option-${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a rolling timeframe and wait for the trigger button to reflect it. The wait
|
||||
* matters: the popover closes on its own animation, and a spec that immediately
|
||||
* clicks Save can otherwise post the previous window.
|
||||
*/
|
||||
export async function selectEvaluationTimeframe(
|
||||
page: Page,
|
||||
value: keyof typeof EVALUATION_WINDOW_PRESETS,
|
||||
): Promise<void> {
|
||||
await openEvaluationSettings(page);
|
||||
await evaluationWindowOption(page, 'timeframe', value).click();
|
||||
await expect(evaluationSettingsButton(page)).toContainText(
|
||||
EVALUATION_WINDOW_PRESETS[value],
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the ADVANCED OPTIONS panel inside the alert-condition section.
|
||||
*
|
||||
* antd's `Collapse` renders its panel children lazily, so `evaluation-cadence-*`
|
||||
* and the two `AdvancedOptionItem` containers do not exist in the DOM at all until
|
||||
* this runs — an assertion on them without it fails as "not found" rather than as
|
||||
* "not visible", which reads like a missing testid.
|
||||
*/
|
||||
export async function expandAdvancedOptions(page: Page): Promise<void> {
|
||||
const header = page.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
if ((await header.getAttribute('aria-expanded')) !== 'true') {
|
||||
await header.click();
|
||||
}
|
||||
await expect(page.getByTestId('evaluation-cadence-input-group')).toBeVisible();
|
||||
}
|
||||
|
||||
/** The cadence duration field — `evaluation.spec.frequency`'s UI half. */
|
||||
export function evaluationCadenceInput(page: Page): Locator {
|
||||
return page.getByTestId('evaluation-cadence-duration-input');
|
||||
}
|
||||
|
||||
export function evaluationCadenceUnitSelect(page: Page): Locator {
|
||||
return page.getByTestId('evaluation-cadence-unit-select');
|
||||
}
|
||||
|
||||
export function labelPill(page: Page, key: string, value: string): Locator {
|
||||
return page.getByTestId(`label-pill-${key}-${value}`);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from './common';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_LIST_PATH = '/alerts';
|
||||
export const ALERT_OVERVIEW_PATH = '/alerts/overview';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ThresholdAlertSeed {
|
||||
/** Alert rule name. Keep unique per test to avoid collisions. */
|
||||
name: string;
|
||||
/** The critical-threshold target value to persist and later assert. */
|
||||
target: number;
|
||||
/**
|
||||
* Notification channel names for the critical threshold. At least one is
|
||||
* required by the API — seed one with {@link createEmailChannelViaApi}.
|
||||
*/
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
// ─── Payload ─────────────────────────────────────────────────────────────
|
||||
|
||||
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
|
||||
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
|
||||
// CreateAlertV2 UI posts to POST /api/v2/rules.
|
||||
function buildThresholdRulePayload({
|
||||
name,
|
||||
target,
|
||||
channels,
|
||||
}: ThresholdAlertSeed): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
annotations: {
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
summary:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
},
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow: '5m0s', frequency: '1m' },
|
||||
},
|
||||
notificationSettings: {
|
||||
groupBy: [],
|
||||
renotify: { enabled: false, interval: '30m', alertStates: [] },
|
||||
usePolicy: false,
|
||||
},
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
compositeQuery: {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_calls_total',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
filter: { expression: '' },
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
target,
|
||||
targetUnit: '',
|
||||
recoveryTarget: null,
|
||||
matchType: 'at_least_once',
|
||||
op: 'above',
|
||||
channels,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed an email notification channel via API. Returns its `{ id, name }`;
|
||||
* thresholds reference channels by name, cleanup deletes by id. `to` is never
|
||||
* delivered — the channel only needs to exist to satisfy rule validation.
|
||||
*/
|
||||
export async function createEmailChannelViaApi(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v1/channels', {
|
||||
data: {
|
||||
name,
|
||||
email_configs: [
|
||||
{ send_resolved: true, to: 'e2e@signoz.test', html: '', headers: {} },
|
||||
],
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v1/channels ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return { id: String(json.data.id), name };
|
||||
}
|
||||
|
||||
/** Delete a notification channel by ID (best-effort cleanup). */
|
||||
export async function deleteChannelViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v1/channels/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a v2 threshold alert via API. Returns the new rule ID. Pair with
|
||||
* {@link deleteAlertViaApi} in an `afterAll`/`afterEach` for cleanup.
|
||||
*/
|
||||
export async function createThresholdAlertViaApi(
|
||||
page: Page,
|
||||
seed: ThresholdAlertSeed,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/rules', {
|
||||
data: buildThresholdRulePayload(seed),
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v2/rules ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/** Delete a rule by ID. Tolerates an already-deleted rule (best-effort cleanup). */
|
||||
export async function deleteAlertViaApi(page: Page, id: string): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v2/rules/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the alert overview (edit) page for `ruleId` and wait until it has fully
|
||||
* settled: the condition editor is visible and the query builder has finished
|
||||
* serializing the loaded query into the URL.
|
||||
*/
|
||||
export async function gotoAlertOverview(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}`);
|
||||
await expect(page.getByTestId('threshold-value-input')).toBeVisible();
|
||||
// The builder rewrites location.search shortly after load (adds compositeQuery).
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
// Let post-load state updates flush so callers read the settled value.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout -- no DOM signal for the async settle
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
257
tests/e2e/helpers/alerts/api.ts
Normal file
257
tests/e2e/helpers/alerts/api.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from '../common';
|
||||
|
||||
import {
|
||||
buildThresholdRulePayload,
|
||||
logsCompositeQuery,
|
||||
metricsCompositeQuery,
|
||||
tracesCompositeQuery,
|
||||
v1RulePayload,
|
||||
v2RulePayload,
|
||||
} from './payloads';
|
||||
import type {
|
||||
AlertSchema,
|
||||
LogsAlertSeed,
|
||||
MetricAlertSeed,
|
||||
ThresholdAlertSeed,
|
||||
TracesAlertSeed,
|
||||
} from './types';
|
||||
|
||||
// ─── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed an email notification channel via API. Returns its `{ id, name }`;
|
||||
* thresholds reference channels by name, cleanup deletes by id. `to` is never
|
||||
* delivered — the channel only needs to exist to satisfy rule validation.
|
||||
*/
|
||||
export async function createEmailChannelViaApi(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v1/channels', {
|
||||
data: {
|
||||
name,
|
||||
email_configs: [
|
||||
{ send_resolved: true, to: 'e2e@signoz.test', html: '', headers: {} },
|
||||
],
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v1/channels ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return { id: String(json.data.id), name };
|
||||
}
|
||||
|
||||
/** Delete a notification channel by ID (best-effort cleanup). */
|
||||
export async function deleteChannelViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v1/channels/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a v2 threshold alert via API. Returns the new rule ID. Pair with
|
||||
* {@link deleteAlertViaApi} in an `afterAll`/`afterEach` for cleanup.
|
||||
*/
|
||||
export async function createThresholdAlertViaApi(
|
||||
page: Page,
|
||||
seed: ThresholdAlertSeed,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/rules', {
|
||||
data: buildThresholdRulePayload(seed),
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v2/rules ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/** Delete a rule by ID. Tolerates an already-deleted rule (best-effort cleanup). */
|
||||
export async function deleteAlertViaApi(page: Page, id: string): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v2/rules/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function postRule(
|
||||
page: Page,
|
||||
schema: AlertSchema,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const path = schema === 'v1' ? '/api/v1/rules' : '/api/v2/rules';
|
||||
const res = await page.request.post(path, {
|
||||
data: payload,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST ${path} ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return String(json.data.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logs threshold rule grouped by `service.name`. `schema: 'v1'` posts
|
||||
* the legacy payload to `/api/v1/rules`, which the UI then renders through the
|
||||
* v1 branch of `AlertHeader` / `ActionButtons` — both schemas serve the *same*
|
||||
* history APIs, so history scenarios can be parameterised over them.
|
||||
*/
|
||||
export async function createLogsAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
schema = 'v2',
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
severity = schema === 'v1' ? 'warning' : 'critical',
|
||||
extraLabels,
|
||||
alertOnAbsent,
|
||||
absentFor,
|
||||
target,
|
||||
op,
|
||||
matchType,
|
||||
}: LogsAlertSeed,
|
||||
): Promise<string> {
|
||||
const extraCondition =
|
||||
alertOnAbsent === undefined
|
||||
? undefined
|
||||
: { alertOnAbsent, absentFor: absentFor ?? 1 };
|
||||
const args = {
|
||||
name,
|
||||
alertType: 'LOGS_BASED_ALERT',
|
||||
compositeQuery: logsCompositeQuery(marker),
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
target,
|
||||
op,
|
||||
matchType,
|
||||
};
|
||||
return postRule(
|
||||
page,
|
||||
schema,
|
||||
schema === 'v1' ? v1RulePayload(args) : v2RulePayload(args),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEED-H's rule: traces-based over the seeded spans, grouped by `service.name`.
|
||||
* Its history rows carry `relatedTracesLink` and an empty `relatedLogsLink`, so
|
||||
* the popover offers "View Traces" only.
|
||||
*/
|
||||
export async function createTracesAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
}: TracesAlertSeed,
|
||||
): Promise<string> {
|
||||
return postRule(
|
||||
page,
|
||||
'v2',
|
||||
v2RulePayload({
|
||||
name,
|
||||
alertType: 'TRACES_BASED_ALERT',
|
||||
compositeQuery: tracesCompositeQuery(marker),
|
||||
channels,
|
||||
severity: 'critical',
|
||||
evalWindow,
|
||||
frequency,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** SEED-E's rule: metrics-based, so its history rows carry no related links. */
|
||||
export async function createMetricAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
metricName,
|
||||
channels,
|
||||
groupByKey = 'host',
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
}: MetricAlertSeed,
|
||||
): Promise<string> {
|
||||
return postRule(
|
||||
page,
|
||||
'v2',
|
||||
v2RulePayload({
|
||||
name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
compositeQuery: metricsCompositeQuery(metricName, groupByKey),
|
||||
channels,
|
||||
severity: 'critical',
|
||||
evalWindow,
|
||||
frequency,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEED-G's rule: a logs rule whose filter matches nothing, with
|
||||
* `alertOnAbsent` set — the only cheap way to get a `nodata` history row.
|
||||
* Seed no telemetry for its marker.
|
||||
*/
|
||||
export async function createNoDataAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
}: { name: string; marker: string; channels: string[] },
|
||||
): Promise<string> {
|
||||
return createLogsAlertViaApi(page, {
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
evalWindow: '5m0s',
|
||||
frequency: '15s',
|
||||
alertOnAbsent: true,
|
||||
absentFor: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze a rule's history. Rows are written on *state change* only, so the
|
||||
* firing wave lands once — but once the eval window rolls past the seeded
|
||||
* records the rule resolves and writes a second row per fingerprint, doubling
|
||||
* `total` mid-suite. Disable the rule as soon as the firing wave is confirmed.
|
||||
*/
|
||||
export async function setRuleDisabledViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
disabled: boolean,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.patch(`/api/v2/rules/${id}`, {
|
||||
data: { disabled },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PATCH /api/v2/rules/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
45
tests/e2e/helpers/alerts/constants.ts
Normal file
45
tests/e2e/helpers/alerts/constants.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_LIST_PATH = '/alerts';
|
||||
export const ALERT_OVERVIEW_PATH = '/alerts/overview';
|
||||
export const ALERT_HISTORY_PATH = '/alerts/history';
|
||||
|
||||
/**
|
||||
* Mirrors `TIMELINE_TABLE_PAGE_SIZE` in
|
||||
* `frontend/src/container/AlertHistory/constants.ts`. This 20 is what makes the
|
||||
* page-2 cursor `base64url({"offset":20,"limit":20})`, so the two must not drift.
|
||||
*/
|
||||
export const TIMELINE_PAGE_SIZE = 20;
|
||||
|
||||
/** The `relativeTime` the history page falls back to (`DEFAULT_TIME_RANGE`). */
|
||||
export const DEFAULT_RELATIVE_TIME = '30m';
|
||||
|
||||
/**
|
||||
* Page size the list specs pin in the URL, so the number of rendered rows never
|
||||
* depends on the viewport height.
|
||||
*/
|
||||
export const ALERT_LIST_PAGE_SIZE = 10;
|
||||
|
||||
/** Severities the list seed cycles through, so search/sort tests have more than one value. */
|
||||
export const SEED_B_SEVERITIES = ['critical', 'warning', 'info'] as const;
|
||||
|
||||
// ─── Wait timeouts (ms) ──────────────────────────────────────────────────
|
||||
// These timeouts gate on the "ruler" — SigNoz's alert evaluation engine that
|
||||
// runs on ~15s cycles and writes history rows to ClickHouse. No way to force
|
||||
// evaluation or seed history directly, so we poll until rows appear.
|
||||
|
||||
/**
|
||||
* Default timeout for waitForTimelineEntries.
|
||||
*
|
||||
* Logs rules need 2+ ruler cycles (~15s each) to see 25 services fire.
|
||||
* 90s = 6 cycles worst-case. Actual time: 20-35s for logs, ~10s for metrics.
|
||||
*/
|
||||
export const WAIT_TIMELINE_ENTRIES_DEFAULT = 90_000;
|
||||
|
||||
/**
|
||||
* Default timeout for waitForTimelineStates (firing + resolved).
|
||||
*
|
||||
* Resolved state appears after evalWindow expires with no matching data.
|
||||
* 1m window + 2 ruler cycles = ~105s observed. 180s = safe margin.
|
||||
*/
|
||||
export const WAIT_TIMELINE_STATES_DEFAULT = 180_000;
|
||||
359
tests/e2e/helpers/alerts/history.ts
Normal file
359
tests/e2e/helpers/alerts/history.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Response,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { authToken, requestUrl } from '../common';
|
||||
import { typeExpression } from '../query-builder';
|
||||
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
WAIT_TIMELINE_ENTRIES_DEFAULT,
|
||||
WAIT_TIMELINE_STATES_DEFAULT,
|
||||
} from './constants';
|
||||
import type { TimelineItem, TimelineResponse } from './types';
|
||||
|
||||
// ─── History API probes ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the timeline straight from the API. Used to gate on the ruler having
|
||||
* produced rows *before* a spec opens the UI — polling through the browser
|
||||
* would conflate "no rows yet" with "the table failed to render".
|
||||
*/
|
||||
export async function fetchTimeline(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
params: Record<string, string | number> = {},
|
||||
): Promise<TimelineResponse> {
|
||||
const token = await authToken(page);
|
||||
const now = Date.now();
|
||||
const query = new URLSearchParams({
|
||||
start: String(now - 30 * 60 * 1000),
|
||||
end: String(now),
|
||||
limit: '100',
|
||||
order: 'asc',
|
||||
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
||||
});
|
||||
const res = await page.request.get(
|
||||
`/api/v2/rules/${ruleId}/history/timeline?${query.toString()}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET /api/v2/rules/${ruleId}/history/timeline ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json()) as { data: TimelineResponse | null };
|
||||
return {
|
||||
items: json.data?.items ?? [],
|
||||
total: json.data?.total ?? 0,
|
||||
nextCursor: json.data?.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
function countStates(items: TimelineItem[]): Record<string, number> {
|
||||
return items.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.state] = (acc[item.state] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until at least `min` rows in state `state` exist. Takes ~20-35s for the
|
||||
* logs fixture (the rule fires on the first evaluation that sees the data) and
|
||||
* ~10s for the metrics one, so budget generously — a timeout here means the
|
||||
* marker aged out of the eval window, not that the assertion is wrong.
|
||||
*/
|
||||
export async function waitForTimelineEntries(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
{
|
||||
min,
|
||||
state = 'firing',
|
||||
timeoutMs = WAIT_TIMELINE_ENTRIES_DEFAULT,
|
||||
}: { min: number; state?: string; timeoutMs?: number },
|
||||
): Promise<TimelineResponse> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: TimelineResponse = { items: [], total: 0 };
|
||||
while (Date.now() < deadline) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
last = await fetchTimeline(page, ruleId);
|
||||
if (last.items.filter((item) => item.state === state).length >= min) {
|
||||
return last;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2_000);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`timeline for rule ${ruleId} never reached ${min} '${state}' rows within ${timeoutMs}ms ` +
|
||||
`(last: total=${last.total}, states=${JSON.stringify(countStates(last.items))})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until every requested state has at least the requested row count.
|
||||
* SEED-F's firing→resolved wave and SEED-G's `nodata` row both gate on this.
|
||||
*/
|
||||
export async function waitForTimelineStates(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
{
|
||||
states,
|
||||
timeoutMs = WAIT_TIMELINE_STATES_DEFAULT,
|
||||
}: { states: Record<string, number>; timeoutMs?: number },
|
||||
): Promise<TimelineResponse> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: TimelineResponse = { items: [], total: 0 };
|
||||
while (Date.now() < deadline) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
last = await fetchTimeline(page, ruleId);
|
||||
const seen = countStates(last.items);
|
||||
if (
|
||||
Object.entries(states).every(([state, min]) => (seen[state] ?? 0) >= min)
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 3_000);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`timeline for rule ${ruleId} never reached ${JSON.stringify(states)} within ${timeoutMs}ms ` +
|
||||
`(last states: ${JSON.stringify(countStates(last.items))})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The filtered row count the timeline reports. Ignores `limit`. */
|
||||
export async function readTimelineTotal(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<number> {
|
||||
return (await fetchTimeline(page, ruleId, { limit: 1 })).total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of `encodeCursor` in
|
||||
* `container/AlertHistory/Timeline/Table/useTimelineTableCursor.ts`, so specs
|
||||
* can assert the *exact* cursor the UI sends. Verified byte-identical to the
|
||||
* server's `nextCursor`.
|
||||
*/
|
||||
export function encodeTimelineCursor(
|
||||
page_: number,
|
||||
limit = TIMELINE_PAGE_SIZE,
|
||||
): string | undefined {
|
||||
if (page_ <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = (page_ - 1) * limit;
|
||||
return Buffer.from(JSON.stringify({ offset, limit }))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Labels on a timeline row, flattened to a plain object. */
|
||||
export function timelineLabelsToObject(
|
||||
item: TimelineItem,
|
||||
): Record<string, string> {
|
||||
return (item.labels ?? []).reduce<Record<string, string>>((acc, label) => {
|
||||
const name = label.key?.name;
|
||||
if (name) {
|
||||
acc[name] = String(label.value ?? '');
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the history tab for `ruleId` and wait until the timeline table has
|
||||
* mounted. `params` is merged into the query string, so scenarios can deep-link
|
||||
* `page`, `order`, `timelineFilter`, … in one call.
|
||||
*/
|
||||
export async function gotoAlertHistory(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<void> {
|
||||
// An absolute window and `relativeTime` are mutually exclusive in practice:
|
||||
// with both present the time picker normalises back to the relative range and
|
||||
// **drops** `startTime`/`endTime` from the URL, so the absolute window never
|
||||
// takes effect. Only send the default relative range when no absolute one was
|
||||
// asked for.
|
||||
const hasAbsoluteRange = !!params.startTime && !!params.endTime;
|
||||
const query = new URLSearchParams({
|
||||
ruleId,
|
||||
...(hasAbsoluteRange ? {} : { relativeTime: DEFAULT_RELATIVE_TIME }),
|
||||
...params,
|
||||
});
|
||||
await page.goto(`${ALERT_HISTORY_PATH}?${query.toString()}`);
|
||||
|
||||
// Race the table against the app's error boundary. The history page has been
|
||||
// observed crashing into it intermittently on load; without this the failure
|
||||
// reads as a 15s "timeline-table not found", which says nothing about why.
|
||||
const table = page.getByTestId('timeline-table');
|
||||
const crashed = page.getByText('Something went wrong :/');
|
||||
await expect(table.or(crashed)).toBeVisible();
|
||||
if (await crashed.isVisible()) {
|
||||
throw new Error(
|
||||
`alert history crashed into the app error boundary at ${page.url()} — ` +
|
||||
'a component threw during render; check the captured console output',
|
||||
);
|
||||
}
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
// The `timeline-table` node is rendered by the first paint, *before* the
|
||||
// timeline request settles — antd only overlays a spinner on it. Returning
|
||||
// here would leave that request in flight, and the next
|
||||
// `waitForHistoryResponse` in the spec would resolve with the page's own
|
||||
// load instead of the response its interaction produced. Wait the spinner
|
||||
// out so every caller starts from a quiet page.
|
||||
await expect(page.locator('.timeline-table .ant-spin-spinning')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Locators ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assert the table is back on page 1. Both the list and the timeline use nuqs
|
||||
* with `parseAsInteger.withDefault(1)`, which **removes** the `page` param when
|
||||
* it is reset rather than writing `page=1` — so "absent" and "1" are the same
|
||||
* state and a naive `?page=1` regex never matches.
|
||||
*/
|
||||
export async function expectFirstPage(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get('page') ?? '1')
|
||||
.toBe('1');
|
||||
}
|
||||
|
||||
export function timelineRows(page: Page): Locator {
|
||||
return page.getByTestId('timeline-row');
|
||||
}
|
||||
|
||||
export function timelineFooterRange(page: Page): Locator {
|
||||
return page.getByTestId('timeline-footer-range');
|
||||
}
|
||||
|
||||
export function statsCard(page: Page, title: string): Locator {
|
||||
return page.locator(`[data-testid="stats-card"][data-stats-title="${title}"]`);
|
||||
}
|
||||
|
||||
/** Open the ACTIONS popover on timeline row `index` (0-based). */
|
||||
export async function openTimelineRowActions(
|
||||
page: Page,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
await timelineRows(page)
|
||||
.nth(index)
|
||||
.getByTestId('timeline-row-actions')
|
||||
.click();
|
||||
}
|
||||
|
||||
// ─── History request matchers ──────────────────────────────────────────────
|
||||
|
||||
/** The four v2 endpoints one history page load hits. */
|
||||
export const HISTORY_ENDPOINTS = [
|
||||
'stats',
|
||||
'timeline',
|
||||
'top_contributors',
|
||||
'overall_status',
|
||||
] as const;
|
||||
|
||||
export type HistoryEndpoint = (typeof HISTORY_ENDPOINTS)[number];
|
||||
|
||||
/** Match a request against one history endpoint, whatever the rule id. */
|
||||
export function isHistoryRequest(
|
||||
request: Request,
|
||||
endpoint: HistoryEndpoint,
|
||||
): boolean {
|
||||
return new RegExp(`/api/v2/rules/[^/]+/history/${endpoint}`).test(
|
||||
request.url(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a history API response. Common pattern across history specs.
|
||||
*
|
||||
* Optionally narrow by HTTP status code or by the `filterExpression` the
|
||||
* request carried. The latter matters whenever a scenario reacts to *its own*
|
||||
* request: the page's own load is still in flight when the spec starts typing,
|
||||
* so an unqualified matcher happily resolves with that earlier response.
|
||||
*/
|
||||
export function waitForHistoryResponse(
|
||||
page: Page,
|
||||
endpoint: HistoryEndpoint,
|
||||
options?: { status?: number; filterExpression?: string },
|
||||
): Promise<Response> {
|
||||
return page.waitForResponse((res) => {
|
||||
if (!isHistoryRequest(res.request(), endpoint)) return false;
|
||||
if (options?.status !== undefined && res.status() !== options.status)
|
||||
return false;
|
||||
if (
|
||||
options?.filterExpression !== undefined &&
|
||||
(requestUrl(res.request()).searchParams.get('filterExpression') ?? '') !==
|
||||
options.filterExpression
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── History interactions ──────────────────────────────────────────────────
|
||||
|
||||
/** Apply a filter expression through the real editor + Run button. */
|
||||
export async function runFilterExpression(
|
||||
page: Page,
|
||||
expression: string,
|
||||
): Promise<void> {
|
||||
await typeExpression(page, expression);
|
||||
await page.getByRole('button', { name: /run query/i }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the timeline descending through the STATE header.
|
||||
*
|
||||
* The antd table is *uncontrolled* — it has `sorter: true` but no `sortOrder`,
|
||||
* so its internal cycle is none → ascend → descend regardless of the `order`
|
||||
* the hook already sends. Reaching `desc` therefore takes two clicks, and the
|
||||
* first one only resets the page (asc is nuqs's default, so it writes no param).
|
||||
*/
|
||||
export async function sortTimelineDescending(page: Page): Promise<void> {
|
||||
const header = page.getByRole('columnheader', { name: 'STATE' });
|
||||
const descRequest = page.waitForRequest(
|
||||
(req) =>
|
||||
isHistoryRequest(req, 'timeline') &&
|
||||
requestUrl(req).searchParams.get('order') === 'desc',
|
||||
);
|
||||
await header.click();
|
||||
await header.click();
|
||||
await descRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the LABELS cell of every rendered row. Scenarios that compare two
|
||||
* snapshots taken at different times (page 1 vs page 2, one timezone vs
|
||||
* another) cannot express that as a web-first assertion, so the read lives in a
|
||||
* helper rather than inline in the test.
|
||||
*/
|
||||
export async function timelineRowLabels(page: Page): Promise<string[]> {
|
||||
return timelineRows(page).getByTestId('timeline-row-labels').allInnerTexts();
|
||||
}
|
||||
|
||||
/** Snapshot the first row's CREATED AT cell. See {@link timelineRowLabels}. */
|
||||
export async function firstTimelineRowCreatedAt(page: Page): Promise<string> {
|
||||
return timelineRows(page)
|
||||
.first()
|
||||
.getByTestId('timeline-row-created-at')
|
||||
.innerText();
|
||||
}
|
||||
77
tests/e2e/helpers/alerts/navigation.ts
Normal file
77
tests/e2e/helpers/alerts/navigation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
ALERT_LIST_PAGE_SIZE,
|
||||
ALERT_OVERVIEW_PATH,
|
||||
ALERTS_LIST_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
} from './constants';
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the alert overview (edit) page for `ruleId` and wait until it has fully
|
||||
* settled: the condition editor is visible and the query builder has finished
|
||||
* serializing the loaded query into the URL.
|
||||
*/
|
||||
export async function gotoAlertOverview(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}`);
|
||||
// `.first()` because a rule may have several thresholds, and the editor renders
|
||||
// one input per threshold. Without it this is a strict-mode violation that only
|
||||
// appears once the *second* row has rendered — i.e. a timing-dependent failure
|
||||
// for multi-threshold rules.
|
||||
await expect(page.getByTestId('threshold-value-input').first()).toBeVisible();
|
||||
// The builder rewrites location.search shortly after load (adds compositeQuery).
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
// Let post-load state updates flush so callers read the settled value.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout -- no DOM signal for the async settle
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the alert details shell (Overview tab) for `ruleId` and wait until it has
|
||||
* mounted. Unlike {@link gotoAlertOverview} this does **not** wait for the
|
||||
* condition editor or the serialised query — use it for scenarios about the
|
||||
* shell itself (header, tabs, actions menu) rather than the rule's contents.
|
||||
*/
|
||||
export async function gotoAlertDetails(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(
|
||||
`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}&relativeTime=${DEFAULT_RELATIVE_TIME}`,
|
||||
);
|
||||
await expect(page.getByTestId('alert-details-root')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Rows currently rendered in the alert-rules table body. */
|
||||
export function alertRuleRows(page: Page): Locator {
|
||||
return page.locator('tbody tr');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the alert-rules list and wait until it has rows. `params` is merged into
|
||||
* the query string (`search`, `page`, `orderBy`, …); `limit` defaults to
|
||||
* {@link ALERT_LIST_PAGE_SIZE} so row counts are viewport-independent.
|
||||
*
|
||||
* Pass `expectRows: false` for scenarios whose filters are *meant* to match
|
||||
* nothing — the row wait would otherwise fail before the assertion runs.
|
||||
*/
|
||||
export async function gotoAlertList(
|
||||
page: Page,
|
||||
params: Record<string, string> = {},
|
||||
{ expectRows = true }: { expectRows?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const query = new URLSearchParams({
|
||||
limit: String(ALERT_LIST_PAGE_SIZE),
|
||||
...params,
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
324
tests/e2e/helpers/alerts/payloads.ts
Normal file
324
tests/e2e/helpers/alerts/payloads.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import type { ThresholdAlertSeed, ThresholdSeedSpec } from './types';
|
||||
|
||||
// ─── Payload builders ────────────────────────────────────────────────────
|
||||
|
||||
const ANNOTATIONS = {
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
summary:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
};
|
||||
|
||||
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
|
||||
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
|
||||
// CreateAlertV2 UI posts to POST /api/v2/rules.
|
||||
export function buildThresholdRulePayload({
|
||||
name,
|
||||
target,
|
||||
channels,
|
||||
labels,
|
||||
thresholds,
|
||||
evalWindow = '5m0s',
|
||||
frequency = '1m',
|
||||
groupBy = [],
|
||||
queryGroupBy = [],
|
||||
renotify = { enabled: false, interval: '30m', alertStates: [] },
|
||||
alertOnAbsent,
|
||||
recoveryTarget = null,
|
||||
}: ThresholdAlertSeed): Record<string, unknown> {
|
||||
const thresholdSpec = (
|
||||
thresholds ?? [{ name: 'critical', target, channels, recoveryTarget }]
|
||||
).map((spec: ThresholdSeedSpec) => ({
|
||||
name: spec.name,
|
||||
target: spec.target,
|
||||
targetUnit: spec.targetUnit ?? '',
|
||||
recoveryTarget: spec.recoveryTarget ?? null,
|
||||
matchType: spec.matchType ?? 'at_least_once',
|
||||
op: spec.op ?? 'above',
|
||||
channels: spec.channels,
|
||||
}));
|
||||
|
||||
return {
|
||||
alert: name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
...(labels ? { labels } : {}),
|
||||
annotations: ANNOTATIONS,
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow, frequency },
|
||||
},
|
||||
notificationSettings: {
|
||||
groupBy,
|
||||
renotify,
|
||||
usePolicy: false,
|
||||
},
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
...(alertOnAbsent
|
||||
? { alertOnAbsent: true, absentFor: alertOnAbsent.absentFor }
|
||||
: {}),
|
||||
compositeQuery: {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_calls_total',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
filter: { expression: '' },
|
||||
...(queryGroupBy.length > 0
|
||||
? {
|
||||
groupBy: queryGroupBy.map((key) => ({
|
||||
name: key,
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: thresholdSpec,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The v5 `queries[]` envelope is identical for both schema versions
|
||||
// (`AlertCompositeQuery` in pkg/types/ruletypes/alerting.go) — only the
|
||||
// threshold / evaluation / channel envelopes differ. That keeps one builder
|
||||
// per signal and a thin branch over the wrapper.
|
||||
export function logsCompositeQuery(marker: string): Record<string, unknown> {
|
||||
return {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
source: '',
|
||||
disabled: false,
|
||||
filter: { expression: `body CONTAINS '${marker}'` },
|
||||
groupBy: [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Same shape as the logs query, one signal over: the rule's signal is what
|
||||
// decides which related link the history rows carry (`links()` in
|
||||
// `pkg/modules/rulestatehistory/implrulestatehistory/links.go` returns *either*
|
||||
// a logs link *or* a traces link, never both).
|
||||
export function tracesCompositeQuery(marker: string): Record<string, unknown> {
|
||||
return {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
source: '',
|
||||
disabled: false,
|
||||
filter: { expression: `name = '${marker}'` },
|
||||
groupBy: [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function metricsCompositeQuery(
|
||||
metricName: string,
|
||||
groupByKey: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
disabled: false,
|
||||
filter: { expression: '' },
|
||||
groupBy: [
|
||||
{ name: groupByKey, fieldContext: 'attribute', fieldDataType: 'string' },
|
||||
],
|
||||
aggregations: [
|
||||
{
|
||||
metricName,
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'max',
|
||||
},
|
||||
],
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// `target 0 / op above / matchType at_least_once` fires on the first evaluation
|
||||
// that sees any matching record, which is what keeps the ruler wait to ~20-35s.
|
||||
export function v2RulePayload({
|
||||
name,
|
||||
alertType,
|
||||
compositeQuery,
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
}: {
|
||||
name: string;
|
||||
alertType: string;
|
||||
compositeQuery: Record<string, unknown>;
|
||||
channels: string[];
|
||||
severity: string;
|
||||
extraLabels?: Record<string, string>;
|
||||
evalWindow: string;
|
||||
frequency: string;
|
||||
extraCondition?: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType,
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
labels: { severity, ...extraLabels },
|
||||
annotations: ANNOTATIONS,
|
||||
evaluation: { kind: 'rolling', spec: { evalWindow, frequency } },
|
||||
notificationSettings: {
|
||||
groupBy: [],
|
||||
renotify: { enabled: false, interval: '30m', alertStates: [] },
|
||||
usePolicy: false,
|
||||
},
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
compositeQuery,
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: severity,
|
||||
target: 0,
|
||||
targetUnit: '',
|
||||
recoveryTarget: null,
|
||||
matchType: 'at_least_once',
|
||||
op: 'above',
|
||||
channels,
|
||||
},
|
||||
],
|
||||
},
|
||||
...extraCondition,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy schema: `evalWindow`/`frequency` sit at the top level, channels are
|
||||
// `preferredChannels`, and `condition.{op,target,matchType}` are the numeric
|
||||
// enum forms the v1 validator requires. `labels.severity` becomes the history
|
||||
// `threshold.name`.
|
||||
export function v1RulePayload({
|
||||
name,
|
||||
alertType,
|
||||
compositeQuery,
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
target = 0,
|
||||
op = '1',
|
||||
matchType = '1',
|
||||
}: {
|
||||
name: string;
|
||||
alertType: string;
|
||||
compositeQuery: Record<string, unknown>;
|
||||
channels: string[];
|
||||
severity: string;
|
||||
extraLabels?: Record<string, string>;
|
||||
evalWindow: string;
|
||||
frequency: string;
|
||||
extraCondition?: Record<string, unknown>;
|
||||
target?: number;
|
||||
op?: string;
|
||||
matchType?: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType,
|
||||
ruleType: 'threshold_rule',
|
||||
disabled: false,
|
||||
source: '',
|
||||
evalWindow,
|
||||
frequency,
|
||||
preferredChannels: channels,
|
||||
labels: { severity, ...extraLabels },
|
||||
annotations: ANNOTATIONS,
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
// Defaults match the history seeds' original shape — `target 0 / op above /
|
||||
// matchType at_least_once` fires on the first evaluation that sees data — so
|
||||
// overriding them is opt-in and cannot change what those seeds do.
|
||||
op,
|
||||
target,
|
||||
matchType,
|
||||
compositeQuery,
|
||||
...extraCondition,
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user