mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-31 10:10:36 +01:00
Compare commits
27 Commits
feat/alert
...
ns/saved-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
210cda03ec | ||
|
|
fe3314853d | ||
|
|
8111224728 | ||
|
|
f1f0e743f1 | ||
|
|
37746dc52c | ||
|
|
69bdf01a1c | ||
|
|
29375bb5fc | ||
|
|
6eae37409c | ||
|
|
ae522552f5 | ||
|
|
092468d6c8 | ||
|
|
0e4d5c87d0 | ||
|
|
dc387e1efe | ||
|
|
26b8d7d590 | ||
|
|
ef6ac791e8 | ||
|
|
b3ce3a5d1f | ||
|
|
8cddda3ed6 | ||
|
|
33fdde9939 | ||
|
|
affb94031d | ||
|
|
54787a63d8 | ||
|
|
06ab96984f | ||
|
|
104bcc55b9 | ||
|
|
ffbdf26ed7 | ||
|
|
97ee59636b | ||
|
|
6e87eff704 | ||
|
|
21ac0f44e0 | ||
|
|
735b9e7d68 | ||
|
|
29315d8c89 |
@@ -38,8 +38,7 @@ COPY ./pkg/ ./pkg/
|
||||
COPY ./templates /root/templates
|
||||
|
||||
COPY Makefile Makefile
|
||||
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise
|
||||
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
|
||||
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
|
||||
|
||||
COPY --from=build /opt/build ./web/
|
||||
|
||||
@@ -7745,6 +7745,101 @@ components:
|
||||
enum:
|
||||
- basic
|
||||
type: string
|
||||
SavedviewtypesDisplay:
|
||||
properties:
|
||||
color:
|
||||
type: string
|
||||
fontSize:
|
||||
type: string
|
||||
format:
|
||||
type: string
|
||||
maxLines:
|
||||
type: integer
|
||||
type: object
|
||||
SavedviewtypesGettableSavedView:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
sourcePage:
|
||||
$ref: '#/components/schemas/SavedviewtypesSourcePage'
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- createdAt
|
||||
- createdBy
|
||||
- updatedAt
|
||||
- updatedBy
|
||||
- sourcePage
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
SavedviewtypesPanelType:
|
||||
enum:
|
||||
- value
|
||||
- graph
|
||||
- table
|
||||
- list
|
||||
- trace
|
||||
type: string
|
||||
SavedviewtypesPostableSavedView:
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
sourcePage:
|
||||
$ref: '#/components/schemas/SavedviewtypesSourcePage'
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
required:
|
||||
- name
|
||||
- sourcePage
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
SavedviewtypesSavedViewSpec:
|
||||
properties:
|
||||
display:
|
||||
$ref: '#/components/schemas/SavedviewtypesDisplay'
|
||||
panelType:
|
||||
$ref: '#/components/schemas/SavedviewtypesPanelType'
|
||||
queries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
|
||||
type: array
|
||||
selectedFields:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
type: array
|
||||
required:
|
||||
- panelType
|
||||
- queries
|
||||
- selectedFields
|
||||
- display
|
||||
type: object
|
||||
SavedviewtypesSourcePage:
|
||||
enum:
|
||||
- traces
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
type: string
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
@@ -22577,6 +22672,299 @@ paths:
|
||||
summary: Test alert rule
|
||||
tags:
|
||||
- rules
|
||||
/api/v2/saved_views:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns saved views, optionally filtered by source page and name.
|
||||
operationId: ListSavedViews
|
||||
parameters:
|
||||
- in: query
|
||||
name: sourcePage
|
||||
schema:
|
||||
$ref: '#/components/schemas/SavedviewtypesSourcePage'
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/SavedviewtypesGettableSavedView'
|
||||
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:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List saved views
|
||||
tags:
|
||||
- saved_view
|
||||
post:
|
||||
deprecated: false
|
||||
description: Persists a saved view for the explore page. Returns the id of the
|
||||
created view.
|
||||
operationId: CreateSavedView
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SavedviewtypesPostableSavedView'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
type: string
|
||||
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:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Create saved view
|
||||
tags:
|
||||
- saved_view
|
||||
/api/v2/saved_views/{viewId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: Deletes a saved view by id.
|
||||
operationId: DeleteSavedView
|
||||
parameters:
|
||||
- in: path
|
||||
name: viewId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Delete saved view
|
||||
tags:
|
||||
- saved_view
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a saved view by id.
|
||||
operationId: GetSavedView
|
||||
parameters:
|
||||
- in: path
|
||||
name: viewId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SavedviewtypesGettableSavedView'
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get saved view
|
||||
tags:
|
||||
- saved_view
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces a saved view's name and query.
|
||||
operationId: UpdateSavedView
|
||||
parameters:
|
||||
- in: path
|
||||
name: viewId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SavedviewtypesPostableSavedView'
|
||||
responses:
|
||||
"200":
|
||||
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
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- EDITOR
|
||||
- tokenizer:
|
||||
- EDITOR
|
||||
summary: Update saved view
|
||||
tags:
|
||||
- saved_view
|
||||
/api/v2/sessions:
|
||||
delete:
|
||||
deprecated: false
|
||||
|
||||
@@ -227,14 +227,11 @@ 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/page.spec.ts --project=chromium
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "AL-01"
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
@@ -268,14 +265,7 @@ 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. |
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Playwright options
|
||||
|
||||
|
||||
28
frontend/src/api/alerts/ruleStats.ts
Normal file
28
frontend/src/api/alerts/ruleStats.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleStatsPayload } from 'types/api/alerts/def';
|
||||
import { RuleStatsProps } from 'types/api/alerts/ruleStats';
|
||||
|
||||
const ruleStats = async (
|
||||
props: RuleStatsProps,
|
||||
): Promise<SuccessResponse<AlertRuleStatsPayload> | ErrorResponse> => {
|
||||
try {
|
||||
const response = await axios.post(`/rules/${props.id}/history/stats`, {
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default ruleStats;
|
||||
33
frontend/src/api/alerts/timelineGraph.ts
Normal file
33
frontend/src/api/alerts/timelineGraph.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTimelineGraphResponsePayload } from 'types/api/alerts/def';
|
||||
import { GetTimelineGraphRequestProps } from 'types/api/alerts/timelineGraph';
|
||||
|
||||
const timelineGraph = async (
|
||||
props: GetTimelineGraphRequestProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTimelineGraphResponsePayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/rules/${props.id}/history/overall_status`,
|
||||
{
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default timelineGraph;
|
||||
36
frontend/src/api/alerts/timelineTable.ts
Normal file
36
frontend/src/api/alerts/timelineTable.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTimelineTableResponsePayload } from 'types/api/alerts/def';
|
||||
import { GetTimelineTableRequestProps } from 'types/api/alerts/timelineTable';
|
||||
|
||||
const timelineTable = async (
|
||||
props: GetTimelineTableRequestProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTimelineTableResponsePayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(`/rules/${props.id}/history/timeline`, {
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
offset: props.offset,
|
||||
limit: props.limit,
|
||||
order: props.order,
|
||||
state: props.state,
|
||||
// TODO(shaheer): implement filters
|
||||
filters: props.filters,
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default timelineTable;
|
||||
33
frontend/src/api/alerts/topContributors.ts
Normal file
33
frontend/src/api/alerts/topContributors.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { AlertRuleTopContributorsPayload } from 'types/api/alerts/def';
|
||||
import { TopContributorsProps } from 'types/api/alerts/topContributors';
|
||||
|
||||
const topContributors = async (
|
||||
props: TopContributorsProps,
|
||||
): Promise<
|
||||
SuccessResponse<AlertRuleTopContributorsPayload> | ErrorResponse
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/rules/${props.id}/history/top_contributors`,
|
||||
{
|
||||
start: props.start,
|
||||
end: props.end,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
error: null,
|
||||
message: response.data.status,
|
||||
payload: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorResponseHandler(error as AxiosError);
|
||||
}
|
||||
};
|
||||
|
||||
export default topContributors;
|
||||
491
frontend/src/api/generated/services/saved-view/index.ts
Normal file
491
frontend/src/api/generated/services/saved-view/index.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* ! 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 {
|
||||
CreateSavedView200,
|
||||
DeleteSavedViewPathParameters,
|
||||
GetSavedView200,
|
||||
GetSavedViewPathParameters,
|
||||
ListSavedViews200,
|
||||
ListSavedViewsParams,
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesPostableSavedViewDTO,
|
||||
UpdateSavedViewPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns saved views, optionally filtered by source page and name.
|
||||
* @summary List saved views
|
||||
*/
|
||||
export const listSavedViews = (
|
||||
params?: ListSavedViewsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListSavedViews200>({
|
||||
url: `/api/v2/saved_views`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListSavedViewsQueryKey = (params?: ListSavedViewsParams) => {
|
||||
return [`/api/v2/saved_views`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListSavedViewsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSavedViewsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListSavedViewsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof listSavedViews>>> = ({
|
||||
signal,
|
||||
}) => listSavedViews(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListSavedViewsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listSavedViews>>
|
||||
>;
|
||||
export type ListSavedViewsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List saved views
|
||||
*/
|
||||
|
||||
export function useListSavedViews<
|
||||
TData = Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListSavedViewsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listSavedViews>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListSavedViewsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List saved views
|
||||
*/
|
||||
export const invalidateListSavedViews = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListSavedViewsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListSavedViewsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists a saved view for the explore page. Returns the id of the created view.
|
||||
* @summary Create saved view
|
||||
*/
|
||||
export const createSavedView = (
|
||||
savedviewtypesPostableSavedViewDTO?: BodyType<SavedviewtypesPostableSavedViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateSavedView200>({
|
||||
url: `/api/v2/saved_views`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: savedviewtypesPostableSavedViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateSavedViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createSavedView'];
|
||||
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 createSavedView>>,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createSavedView(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateSavedViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createSavedView>>
|
||||
>;
|
||||
export type CreateSavedViewMutationBody =
|
||||
| BodyType<SavedviewtypesPostableSavedViewDTO>
|
||||
| undefined;
|
||||
export type CreateSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create saved view
|
||||
*/
|
||||
export const useCreateSavedView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createSavedView>>,
|
||||
TError,
|
||||
{ data?: BodyType<SavedviewtypesPostableSavedViewDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateSavedViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Deletes a saved view by id.
|
||||
* @summary Delete saved view
|
||||
*/
|
||||
export const deleteSavedView = (
|
||||
{ viewId }: DeleteSavedViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/saved_views/${viewId}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteSavedViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteSavedView'];
|
||||
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 deleteSavedView>>,
|
||||
{ pathParams: DeleteSavedViewPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteSavedView(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteSavedViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>
|
||||
>;
|
||||
|
||||
export type DeleteSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete saved view
|
||||
*/
|
||||
export const useDeleteSavedView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteSavedView>>,
|
||||
TError,
|
||||
{ pathParams: DeleteSavedViewPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteSavedViewMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a saved view by id.
|
||||
* @summary Get saved view
|
||||
*/
|
||||
export const getSavedView = (
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSavedView200>({
|
||||
url: `/api/v2/saved_views/${viewId}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSavedViewQueryKey = ({
|
||||
viewId,
|
||||
}: GetSavedViewPathParameters) => {
|
||||
return [`/api/v2/saved_views/${viewId}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSavedViewQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSavedViewQueryKey({ viewId });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getSavedView>>> = ({
|
||||
signal,
|
||||
}) => getSavedView({ viewId }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!viewId,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSavedViewQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSavedView>>
|
||||
>;
|
||||
export type GetSavedViewQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get saved view
|
||||
*/
|
||||
|
||||
export function useGetSavedView<
|
||||
TData = Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSavedView>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSavedViewQueryOptions({ viewId }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get saved view
|
||||
*/
|
||||
export const invalidateGetSavedView = async (
|
||||
queryClient: QueryClient,
|
||||
{ viewId }: GetSavedViewPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSavedViewQueryKey({ viewId }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces a saved view's name and query.
|
||||
* @summary Update saved view
|
||||
*/
|
||||
export const updateSavedView = (
|
||||
{ viewId }: UpdateSavedViewPathParameters,
|
||||
savedviewtypesPostableSavedViewDTO?: BodyType<SavedviewtypesPostableSavedViewDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/saved_views/${viewId}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: savedviewtypesPostableSavedViewDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateSavedViewMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateSavedView'];
|
||||
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 updateSavedView>>,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateSavedView(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateSavedViewMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateSavedView>>
|
||||
>;
|
||||
export type UpdateSavedViewMutationBody =
|
||||
| BodyType<SavedviewtypesPostableSavedViewDTO>
|
||||
| undefined;
|
||||
export type UpdateSavedViewMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update saved view
|
||||
*/
|
||||
export const useUpdateSavedView = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateSavedView>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateSavedViewPathParameters;
|
||||
data?: BodyType<SavedviewtypesPostableSavedViewDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateSavedViewMutationOptions(options));
|
||||
};
|
||||
@@ -8842,6 +8842,99 @@ export interface RuletypesRuleDTO {
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
basic = 'basic',
|
||||
}
|
||||
export interface SavedviewtypesDisplayDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
fontSize?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
format?: string;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
maxLines?: number;
|
||||
}
|
||||
|
||||
export enum SavedviewtypesSourcePageDTO {
|
||||
traces = 'traces',
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
}
|
||||
export enum SavedviewtypesPanelTypeDTO {
|
||||
value = 'value',
|
||||
graph = 'graph',
|
||||
table = 'table',
|
||||
list = 'list',
|
||||
trace = 'trace',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display: SavedviewtypesDisplayDTO;
|
||||
panelType: SavedviewtypesPanelTypeDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
queries: Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectedFields: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
}
|
||||
|
||||
export interface SavedviewtypesGettableSavedViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
sourcePage: SavedviewtypesSourcePageDTO;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy: string;
|
||||
}
|
||||
|
||||
export interface SavedviewtypesPostableSavedViewDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
sourcePage: SavedviewtypesSourcePageDTO;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -12029,6 +12122,57 @@ export type TestRule200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListSavedViewsParams = {
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sourcePage?: SavedviewtypesSourcePageDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type ListSavedViews200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
data: SavedviewtypesGettableSavedViewDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateSavedView200 = {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
data: string | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteSavedViewPathParameters = {
|
||||
viewId: string;
|
||||
};
|
||||
export type GetSavedViewPathParameters = {
|
||||
viewId: string;
|
||||
};
|
||||
export type GetSavedView200 = {
|
||||
data: SavedviewtypesGettableSavedViewDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateSavedViewPathParameters = {
|
||||
viewId: string;
|
||||
};
|
||||
export type GetSessionContext200 = {
|
||||
data: AuthtypesSessionContextDTO;
|
||||
/**
|
||||
|
||||
@@ -102,15 +102,6 @@ interface QuerySearchProps {
|
||||
showFilterSuggestionsWithoutMetric?: boolean;
|
||||
/** When set, the editor shows only the user expression; API/filter uses `initial AND (user)`. */
|
||||
initialExpression?: string;
|
||||
/** When set, replaces the generic value-suggestion API with a custom fetcher. */
|
||||
valueSuggestionsOverride?: (
|
||||
key: string,
|
||||
searchText: string,
|
||||
) => Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
function QuerySearch({
|
||||
@@ -124,7 +115,6 @@ function QuerySearch({
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
initialExpression,
|
||||
metricNamespace,
|
||||
valueSuggestionsOverride,
|
||||
}: QuerySearchProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
|
||||
@@ -494,24 +484,13 @@ function QuerySearch({
|
||||
const sanitizedSearchText = searchText ? searchText?.trim() : '';
|
||||
|
||||
try {
|
||||
const values = valueSuggestionsOverride
|
||||
? await valueSuggestionsOverride(key, sanitizedSearchText)
|
||||
: await getValueSuggestions({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
}).then((response) => {
|
||||
const responseData = response.data as any;
|
||||
const data = responseData.data || {};
|
||||
const values = data.values || {};
|
||||
return {
|
||||
stringValues: values.stringValues || [],
|
||||
numberValues: values.numberValues || [],
|
||||
complete: data.complete ?? false,
|
||||
};
|
||||
});
|
||||
const response = await getValueSuggestions({
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
});
|
||||
|
||||
// Skip updates if component unmounted or key changed
|
||||
if (
|
||||
@@ -523,6 +502,8 @@ function QuerySearch({
|
||||
}
|
||||
|
||||
// Process the response data
|
||||
const responseData = response.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
@@ -603,7 +584,6 @@ function QuerySearch({
|
||||
debouncedMetricName,
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
valueSuggestionsOverride,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ export const REACT_QUERY_KEY = {
|
||||
DELETE_DASHBOARD: 'DELETE_DASHBOARD',
|
||||
LOGS_PIPELINE_PREVIEW: 'LOGS_PIPELINE_PREVIEW',
|
||||
ALERT_RULE_DETAILS: 'ALERT_RULE_DETAILS',
|
||||
ALERT_RULE_STATS: 'ALERT_RULE_STATS',
|
||||
ALERT_RULE_TOP_CONTRIBUTORS: 'ALERT_RULE_TOP_CONTRIBUTORS',
|
||||
ALERT_RULE_TIMELINE_TABLE: 'ALERT_RULE_TIMELINE_TABLE',
|
||||
ALERT_RULE_TIMELINE_GRAPH: 'ALERT_RULE_TIMELINE_GRAPH',
|
||||
GET_CONSUMER_LAG_DETAILS: 'GET_CONSUMER_LAG_DETAILS',
|
||||
TOGGLE_ALERT_STATE: 'TOGGLE_ALERT_STATE',
|
||||
GET_ALL_ALERTS: 'GET_ALL_ALERTS',
|
||||
|
||||
@@ -29,7 +29,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -41,7 +40,6 @@ 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,10 +26,7 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -41,10 +38,7 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -56,10 +50,7 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,12 +103,7 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<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 ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -137,7 +123,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
<div className="count-label">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,11 +81,7 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useGetAlertRuleDetailsStats } from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { StatsTimeSeriesItem } from 'types/api/alerts/def';
|
||||
|
||||
import AverageResolutionCard from '../AverageResolutionCard/AverageResolutionCard';
|
||||
import StatsCard from '../StatsCard/StatsCard';
|
||||
@@ -26,59 +25,24 @@ type StatsCardsRendererProps = {
|
||||
};
|
||||
|
||||
// TODO(shaheer): render the DataStateRenderer inside the TotalTriggeredCard/AverageResolutionCard, it should display the title
|
||||
type AdaptedStatsData = {
|
||||
totalCurrentTriggers: number;
|
||||
totalPastTriggers: number;
|
||||
currentAvgResolutionTime: string;
|
||||
pastAvgResolutionTime: string;
|
||||
currentTriggersSeries: StatsTimeSeriesItem[];
|
||||
currentAvgResolutionTimeSeries: StatsTimeSeriesItem[];
|
||||
};
|
||||
|
||||
function StatsCardsRenderer({
|
||||
setTotalCurrentTriggers,
|
||||
}: StatsCardsRendererProps): JSX.Element {
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsStats();
|
||||
|
||||
const adaptedData = useMemo((): AdaptedStatsData | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
const statsData = data.data;
|
||||
|
||||
const adaptTimeSeries = (
|
||||
series: typeof statsData.currentTriggersSeries,
|
||||
): StatsTimeSeriesItem[] =>
|
||||
series?.values?.map((item) => ({
|
||||
timestamp: item.timestamp ?? 0,
|
||||
value: String(item.value ?? 0),
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
totalCurrentTriggers: statsData.totalCurrentTriggers,
|
||||
totalPastTriggers: statsData.totalPastTriggers,
|
||||
currentAvgResolutionTime: String(statsData.currentAvgResolutionTime),
|
||||
pastAvgResolutionTime: String(statsData.pastAvgResolutionTime),
|
||||
currentTriggersSeries: adaptTimeSeries(statsData.currentTriggersSeries),
|
||||
currentAvgResolutionTimeSeries: adaptTimeSeries(
|
||||
statsData.currentAvgResolutionTimeSeries,
|
||||
),
|
||||
};
|
||||
}, [data?.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (adaptedData?.totalCurrentTriggers !== undefined) {
|
||||
setTotalCurrentTriggers(adaptedData.totalCurrentTriggers);
|
||||
if (data?.payload?.data?.totalCurrentTriggers !== undefined) {
|
||||
setTotalCurrentTriggers(data.payload.data.totalCurrentTriggers);
|
||||
}
|
||||
}, [adaptedData, setTotalCurrentTriggers]);
|
||||
}, [data, setTotalCurrentTriggers]);
|
||||
|
||||
return (
|
||||
<DataStateRenderer
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
data={adaptedData}
|
||||
data={data?.payload?.data || null}
|
||||
>
|
||||
{(data): JSX.Element => {
|
||||
const {
|
||||
@@ -96,7 +60,7 @@ function StatsCardsRenderer({
|
||||
<TotalTriggeredCard
|
||||
totalCurrentTriggers={totalCurrentTriggers}
|
||||
totalPastTriggers={totalPastTriggers}
|
||||
timeSeries={currentTriggersSeries}
|
||||
timeSeries={currentTriggersSeries?.values}
|
||||
/>
|
||||
) : (
|
||||
<StatsCard
|
||||
@@ -113,7 +77,7 @@ function StatsCardsRenderer({
|
||||
<AverageResolutionCard
|
||||
currentAvgResolutionTime={currentAvgResolutionTime}
|
||||
pastAvgResolutionTime={pastAvgResolutionTime}
|
||||
timeSeries={currentAvgResolutionTimeSeries}
|
||||
timeSeries={currentAvgResolutionTimeSeries?.values}
|
||||
/>
|
||||
) : (
|
||||
<StatsCard
|
||||
|
||||
@@ -48,16 +48,11 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="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}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,10 +68,7 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
<div className="total-contribution">
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -81,10 +78,7 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,10 +31,7 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useGetAlertRuleDetailsTopContributors } from 'pages/AlertDetails/hooks';
|
||||
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleStats, AlertRuleTopContributors } from 'types/api/alerts/def';
|
||||
import { AlertRuleStats } from 'types/api/alerts/def';
|
||||
|
||||
import TopContributorsCard from '../TopContributorsCard/TopContributorsCard';
|
||||
|
||||
@@ -15,27 +13,15 @@ function TopContributorsRenderer({
|
||||
}: TopContributorsRendererProps): JSX.Element {
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTopContributors();
|
||||
const response = data?.payload?.data;
|
||||
|
||||
// TODO(shaheer): render the DataStateRenderer inside the TopContributorsCard, it should display the title and view all
|
||||
const adaptedData = useMemo((): AlertRuleTopContributors[] | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
return data.data.map((contributor) => ({
|
||||
fingerprint: contributor.fingerprint,
|
||||
count: contributor.count,
|
||||
labels: labelsArrayToObject(contributor.labels),
|
||||
relatedLogsLink: contributor.relatedLogsLink ?? '',
|
||||
relatedTracesLink: contributor.relatedTracesLink ?? '',
|
||||
}));
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<DataStateRenderer
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
data={adaptedData}
|
||||
data={response || null}
|
||||
>
|
||||
{(topContributorsData): JSX.Element => (
|
||||
<TopContributorsCard
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const ALERT_STATUS: Record<RuletypesAlertStateDTO, number> & {
|
||||
[key: string]: number;
|
||||
} = {
|
||||
[RuletypesAlertStateDTO.firing]: 0,
|
||||
[RuletypesAlertStateDTO.inactive]: 1,
|
||||
export const ALERT_STATUS: { [key: string]: number } = {
|
||||
firing: 0,
|
||||
inactive: 1,
|
||||
normal: 1,
|
||||
[RuletypesAlertStateDTO.pending]: 2,
|
||||
[RuletypesAlertStateDTO.recovering]: 2,
|
||||
'no-data': 3,
|
||||
[RuletypesAlertStateDTO.nodata]: 3,
|
||||
[RuletypesAlertStateDTO.disabled]: 4,
|
||||
muted: 5,
|
||||
'no-data': 2,
|
||||
disabled: 3,
|
||||
muted: 4,
|
||||
};
|
||||
|
||||
export const STATE_VS_COLOR: {
|
||||
@@ -22,10 +16,9 @@ export const STATE_VS_COLOR: {
|
||||
{
|
||||
0: { stroke: Color.BG_CHERRY_500, fill: Color.BG_CHERRY_500 },
|
||||
1: { stroke: Color.BG_FOREST_500, fill: Color.BG_FOREST_500 },
|
||||
2: { stroke: Color.BG_AMBER_500, fill: Color.BG_AMBER_500 },
|
||||
3: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
|
||||
4: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
|
||||
5: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
|
||||
2: { stroke: Color.BG_SIENNA_400, fill: Color.BG_SIENNA_400 },
|
||||
3: { stroke: Color.BG_VANILLA_400, fill: Color.BG_VANILLA_400 },
|
||||
4: { stroke: Color.BG_INK_100, fill: Color.BG_INK_100 },
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
|
||||
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
|
||||
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
|
||||
|
||||
import Graph from '../Graph/Graph';
|
||||
|
||||
@@ -20,20 +18,30 @@ function GraphWrapper({
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineGraphData();
|
||||
|
||||
const adaptedData = useMemo((): AlertRuleTimelineGraphResponse[] | null => {
|
||||
if (!data?.data) {
|
||||
return null;
|
||||
}
|
||||
return data.data.map((item) => ({
|
||||
start: item.start,
|
||||
end: item.end,
|
||||
state: item.state as AlertRuleTimelineGraphResponse['state'],
|
||||
}));
|
||||
}, [data?.data]);
|
||||
// TODO(shaheer): uncomment when the API is ready for
|
||||
// const { startTime } = useAlertHistoryQueryParams();
|
||||
|
||||
// const [isVerticalGraph, setIsVerticalGraph] = useState(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// const checkVerticalGraph = (): void => {
|
||||
// if (startTime) {
|
||||
// const startTimeDate = dayjs(Number(startTime));
|
||||
// const twentyFourHoursAgo = dayjs().subtract(
|
||||
// HORIZONTAL_GRAPH_HOURS_THRESHOLD,
|
||||
// DAYJS_MANIPULATE_TYPES.HOUR,
|
||||
// );
|
||||
|
||||
// setIsVerticalGraph(startTimeDate.isBefore(twentyFourHoursAgo));
|
||||
// }
|
||||
// };
|
||||
|
||||
// checkVerticalGraph();
|
||||
// }, [startTime]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
@@ -41,7 +49,7 @@ function GraphWrapper({
|
||||
isLoading={isLoading}
|
||||
isError={isError || !isValidRuleId || !ruleId}
|
||||
isRefetching={isRefetching}
|
||||
data={adaptedData}
|
||||
data={data?.payload?.data || null}
|
||||
>
|
||||
{(data): JSX.Element => <Graph type="horizontal" data={data} />}
|
||||
</DataStateRenderer>
|
||||
|
||||
@@ -1,35 +1,11 @@
|
||||
.timeline-table {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
min-height: 600px;
|
||||
|
||||
&__filter {
|
||||
padding: 12px 16px;
|
||||
background: var(--l1-background);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&__filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__filter-search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__filter--loading,
|
||||
&__filter--loading-skeleton {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
background: var(--l1-background);
|
||||
&-placeholder {
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
&-cell {
|
||||
padding: 12px 16px !important;
|
||||
vertical-align: baseline;
|
||||
@@ -47,9 +23,6 @@
|
||||
&-tbody > tr > td {
|
||||
border: none;
|
||||
}
|
||||
&-footer {
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
}
|
||||
|
||||
.label-filter {
|
||||
@@ -113,38 +86,4 @@
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
display: flex;
|
||||
align-self: flex-start;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
background: var(--l1-background);
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
.ant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +1,52 @@
|
||||
import { HTMLAttributes, useCallback, useMemo } from 'react';
|
||||
import { Button, Skeleton, Table } from 'antd';
|
||||
import { ChevronLeft, ChevronRight } from '@signozhq/icons';
|
||||
import { HTMLAttributes, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Table } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import {
|
||||
QuerySearchV2Provider,
|
||||
useExpression,
|
||||
useInputExpression,
|
||||
useQuerySearchOnChange,
|
||||
useQuerySearchOnRun,
|
||||
} from 'components/QueryBuilderV2';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
|
||||
import { initialFilters } from 'constants/queryBuilder';
|
||||
import {
|
||||
useGetAlertRuleDetailsTimelineTable,
|
||||
useTimelineTable,
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import { labelsArrayToObject } from 'container/AlertHistory/utils/labelAdapters';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useAlertHistoryFilterSuggestions } from './useAlertHistoryFilterSuggestions';
|
||||
import { timelineTableColumns } from './useTimelineTable';
|
||||
|
||||
import './Table.styles.scss';
|
||||
|
||||
export const ALERT_HISTORY_EXPRESSION_KEY = 'alertHistoryExpression';
|
||||
function TimelineTable(): JSX.Element {
|
||||
const [filters, setFilters] = useState<TagFilter>(initialFilters);
|
||||
|
||||
function TimelineTableContent(): JSX.Element {
|
||||
const expression = useExpression();
|
||||
const inputExpression = useInputExpression();
|
||||
const querySearchOnChange = useQuerySearchOnChange();
|
||||
const querySearchOnRun = useQuerySearchOnRun();
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
data,
|
||||
error,
|
||||
ruleId,
|
||||
refetch,
|
||||
cancel,
|
||||
} = useGetAlertRuleDetailsTimelineTable({ filterExpression: expression });
|
||||
|
||||
const apiError = useMemo(() => convertToApiError(error), [error]);
|
||||
|
||||
const { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys } =
|
||||
useAlertHistoryFilterSuggestions(ruleId ?? null);
|
||||
|
||||
const { timelineData, totalItems, nextCursor } = useMemo(() => {
|
||||
const response = data?.data;
|
||||
const items: AlertRuleTimelineTableResponse[] | undefined =
|
||||
response?.items?.map((item) => {
|
||||
return {
|
||||
ruleID: item.ruleId,
|
||||
ruleName: item.ruleName,
|
||||
overallState: item.overallState as string,
|
||||
overallStateChanged: item.overallStateChanged,
|
||||
state: item.state as string,
|
||||
stateChanged: item.stateChanged,
|
||||
unixMilli: item.unixMilli,
|
||||
fingerprint: item.fingerprint,
|
||||
value: item.value,
|
||||
labels: labelsArrayToObject(item.labels),
|
||||
relatedLogsLink: item.relatedLogsLink,
|
||||
relatedTracesLink: item.relatedTracesLink,
|
||||
};
|
||||
});
|
||||
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
|
||||
useGetAlertRuleDetailsTimelineTable({ filters });
|
||||
|
||||
const { timelineData, totalItems, labels } = useMemo(() => {
|
||||
const response = data?.payload?.data;
|
||||
return {
|
||||
timelineData: items,
|
||||
totalItems: response?.total ?? 0,
|
||||
nextCursor: response?.nextCursor,
|
||||
timelineData: response?.items,
|
||||
totalItems: response?.total,
|
||||
labels: response?.labels,
|
||||
};
|
||||
}, [data?.data]);
|
||||
}, [data?.payload?.data]);
|
||||
|
||||
const {
|
||||
paginationConfig,
|
||||
onChangeHandler,
|
||||
handleNextPage,
|
||||
handlePrevPage,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
} = useTimelineTable({
|
||||
const { paginationConfig, onChangeHandler } = useTimelineTable({
|
||||
totalItems: totalItems ?? 0,
|
||||
nextCursor,
|
||||
});
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const handleRunQuery = useCallback(
|
||||
(updatedExpression?: string): void => {
|
||||
const nextExpression = updatedExpression ?? inputExpression;
|
||||
querySearchOnRun(nextExpression);
|
||||
|
||||
if (nextExpression === expression) {
|
||||
refetch();
|
||||
}
|
||||
},
|
||||
[querySearchOnRun, refetch, inputExpression, expression],
|
||||
);
|
||||
|
||||
const queryData = useMemo(
|
||||
() => ({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.LOGS,
|
||||
filter: { expression },
|
||||
expression,
|
||||
}),
|
||||
[expression],
|
||||
);
|
||||
if (isError || !isValidRuleId || !ruleId) {
|
||||
return <div>{t('something_went_wrong')}</div>;
|
||||
}
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
labels: record.labels,
|
||||
});
|
||||
@@ -131,114 +54,24 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<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"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
dataSource={DataSource.LOGS}
|
||||
onRun={handleRunQuery}
|
||||
hardcodedAttributeKeys={hardcodedAttributeKeys}
|
||||
valueSuggestionsOverride={valueSuggestionsOverride}
|
||||
/>
|
||||
</div>
|
||||
<RunQueryBtn
|
||||
isLoadingQueries={isLoading || isRefetching}
|
||||
onStageRunQuery={(): void => handleRunQuery()}
|
||||
handleCancelQuery={cancel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="timeline-table__filter timeline-table__filter--loading">
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="timeline-table">
|
||||
<Table
|
||||
rowKey={(row): string => `${row.fingerprint}-${row.value}-${row.unixMilli}`}
|
||||
columns={timelineTableColumns({
|
||||
filters,
|
||||
labels: labels ?? {},
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
})}
|
||||
onRow={handleRowClick}
|
||||
dataSource={timelineData}
|
||||
pagination={false}
|
||||
pagination={paginationConfig}
|
||||
size="middle"
|
||||
onChange={onChangeHandler}
|
||||
loading={isLoading || isRefetching}
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<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"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
: ((paginationConfig.current ?? 1) - 1) *
|
||||
(paginationConfig.pageSize ?? 10) +
|
||||
1,
|
||||
Math.min(
|
||||
(paginationConfig.current ?? 1) * (paginationConfig.pageSize ?? 10),
|
||||
totalItems,
|
||||
),
|
||||
])}
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={!hasPrevPage}
|
||||
onClick={handlePrevPage}
|
||||
data-testid="timeline-prev-page"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={!hasNextPage}
|
||||
onClick={handleNextPage}
|
||||
data-testid="timeline-next-page"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineTable(): JSX.Element {
|
||||
return (
|
||||
<QuerySearchV2Provider
|
||||
queryParamKey={ALERT_HISTORY_EXPRESSION_KEY}
|
||||
initialExpression=""
|
||||
persistOnUnmount
|
||||
>
|
||||
<TimelineTableContent />
|
||||
</QuerySearchV2Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineTable;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
getRuleHistoryFilterValues,
|
||||
useGetRuleHistoryFilterKeys,
|
||||
} from 'api/generated/services/rules';
|
||||
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
import { fieldContextToSuggestionContext } from 'container/AlertHistory/Timeline/Table/utils';
|
||||
|
||||
export interface AlertHistoryFilterSuggestions {
|
||||
hardcodedAttributeKeys: QueryKeyDataSuggestionsProps[];
|
||||
valueSuggestionsOverride: (
|
||||
key: string,
|
||||
searchText: string,
|
||||
) => Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}>;
|
||||
isLoadingKeys: boolean;
|
||||
}
|
||||
|
||||
export function useAlertHistoryFilterSuggestions(
|
||||
ruleId: string | null,
|
||||
): AlertHistoryFilterSuggestions {
|
||||
const { startTime, endTime } = useAlertHistoryQueryParams();
|
||||
|
||||
const { data: filterKeysData, isLoading: isLoadingKeys } =
|
||||
useGetRuleHistoryFilterKeys(
|
||||
{ id: ruleId ?? '' },
|
||||
{ startUnixMilli: startTime, endUnixMilli: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: !!ruleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const hardcodedAttributeKeys = useMemo((): QueryKeyDataSuggestionsProps[] => {
|
||||
const keys = filterKeysData?.data?.keys;
|
||||
if (!keys) {
|
||||
// by default, when QuerySearch keys fails, we don't render fallback keys
|
||||
// we just return empty to let user write whatever they want with no
|
||||
// key suggestion
|
||||
return [];
|
||||
}
|
||||
return Object.values(keys).flatMap((items) =>
|
||||
items.map(
|
||||
(item) =>
|
||||
({
|
||||
label: item.name,
|
||||
name: item.name,
|
||||
type: item.fieldDataType || 'string',
|
||||
signal: 'logs' as const,
|
||||
fieldDataType: item.fieldDataType,
|
||||
fieldContext: fieldContextToSuggestionContext(item.fieldContext),
|
||||
}) satisfies QueryKeyDataSuggestionsProps,
|
||||
),
|
||||
);
|
||||
}, [filterKeysData]);
|
||||
|
||||
const valueSuggestionsOverride = useCallback(
|
||||
async (
|
||||
key: string,
|
||||
searchText: string,
|
||||
): Promise<{
|
||||
stringValues: string[];
|
||||
numberValues: number[];
|
||||
complete: boolean;
|
||||
}> => {
|
||||
if (!ruleId) {
|
||||
return {
|
||||
stringValues: [],
|
||||
numberValues: [],
|
||||
complete: true,
|
||||
};
|
||||
}
|
||||
const response = await getRuleHistoryFilterValues(
|
||||
{ id: ruleId },
|
||||
{
|
||||
name: key,
|
||||
searchText,
|
||||
startUnixMilli: startTime,
|
||||
endUnixMilli: endTime,
|
||||
},
|
||||
);
|
||||
const values = response.data?.values;
|
||||
return {
|
||||
stringValues: values?.stringValues ?? [],
|
||||
numberValues: values?.numberValues ?? [],
|
||||
complete: response.data?.complete ?? false,
|
||||
};
|
||||
},
|
||||
[ruleId, startTime, endTime],
|
||||
);
|
||||
|
||||
return { hardcodedAttributeKeys, valueSuggestionsOverride, isLoadingKeys };
|
||||
}
|
||||
@@ -1,15 +1,83 @@
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button, TableColumnsType as ColumnsType, Tooltip } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import { Ellipsis, Search } from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, TableColumnsType as ColumnsType } from 'antd';
|
||||
import ClientSideQBSearch, {
|
||||
AttributeKey,
|
||||
} from 'components/ClientSideQBSearch/ClientSideQBSearch';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { ConditionalAlertPopover } from 'container/AlertHistory/AlertPopover/AlertPopover';
|
||||
import { transformKeyValuesToAttributeValuesMap } from 'container/QueryBuilder/filters/utils';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { TimestampInput } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
|
||||
import AlertLabels from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import AlertLabels, {
|
||||
AlertLabelsProps,
|
||||
} from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import AlertState from 'pages/AlertDetails/AlertHeader/AlertState/AlertState';
|
||||
import { AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const transformLabelsToQbKeys = (
|
||||
labels: AlertRuleTimelineTableResponse['labels'],
|
||||
): AttributeKey[] => Object.keys(labels).flatMap((key) => [{ key }]);
|
||||
|
||||
function LabelFilter({
|
||||
filters,
|
||||
setFilters,
|
||||
labels,
|
||||
}: {
|
||||
setFilters: (filters: TagFilter) => void;
|
||||
filters: TagFilter;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
}): JSX.Element | null {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const { transformedKeys, attributesMap } = useMemo(
|
||||
() => ({
|
||||
transformedKeys: transformLabelsToQbKeys(labels || {}),
|
||||
attributesMap: transformKeyValuesToAttributeValuesMap(labels),
|
||||
}),
|
||||
[labels],
|
||||
);
|
||||
|
||||
const handleSearch = (tagFilters: TagFilter): void => {
|
||||
const tagFiltersLength = tagFilters.items.length;
|
||||
|
||||
if (
|
||||
(!tagFiltersLength && (!filters || !filters.items.length)) ||
|
||||
tagFiltersLength === filters?.items.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setFilters(tagFilters);
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientSideQBSearch
|
||||
onChange={handleSearch}
|
||||
filters={filters}
|
||||
className="alert-history-label-search"
|
||||
attributeKeys={transformedKeys}
|
||||
attributeValuesMap={attributesMap}
|
||||
suffixIcon={
|
||||
<Search
|
||||
size={14}
|
||||
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const timelineTableColumns = ({
|
||||
filters,
|
||||
labels,
|
||||
setFilters,
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
}: {
|
||||
filters: TagFilter;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
setFilters: (filters: TagFilter) => void;
|
||||
formatTimezoneAdjustedTimestamp: (
|
||||
input: TimestampInput,
|
||||
format?: string,
|
||||
@@ -21,16 +89,18 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule-state" data-testid="timeline-row-state">
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'LABELS',
|
||||
title: (
|
||||
<LabelFilter setFilters={setFilters} filters={filters} labels={labels} />
|
||||
),
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels" data-testid="timeline-row-labels">
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
),
|
||||
@@ -40,10 +110,7 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
<div className="alert-rule__created-at">
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -52,27 +119,15 @@ export const timelineTableColumns = ({
|
||||
title: 'ACTIONS',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (_, record): JSX.Element => {
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConditionalAlertPopover
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
);
|
||||
},
|
||||
render: (record): JSX.Element => (
|
||||
<ConditionalAlertPopover
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
Options,
|
||||
parseAsInteger,
|
||||
parseAsStringLiteral,
|
||||
useQueryState,
|
||||
UseQueryStateReturn,
|
||||
} from 'nuqs';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
|
||||
const defaultNuqsOptions: Options = {
|
||||
history: 'push',
|
||||
};
|
||||
|
||||
export const TIMELINE_TABLE_PARAMS = {
|
||||
PAGE: 'page',
|
||||
ORDER: 'order',
|
||||
} as const;
|
||||
|
||||
const ORDER_VALUES = ['asc', 'desc'] as const;
|
||||
export type OrderDirection = (typeof ORDER_VALUES)[number];
|
||||
|
||||
export const useTimelineTablePage = (): UseQueryStateReturn<number, number> =>
|
||||
useQueryState(
|
||||
TIMELINE_TABLE_PARAMS.PAGE,
|
||||
parseAsInteger.withDefault(1).withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export const useTimelineTableOrder = (): UseQueryStateReturn<
|
||||
OrderDirection,
|
||||
OrderDirection
|
||||
> =>
|
||||
useQueryState(
|
||||
TIMELINE_TABLE_PARAMS.ORDER,
|
||||
parseAsStringLiteral(ORDER_VALUES)
|
||||
.withDefault('asc')
|
||||
.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
export function encodeCursor(page: number, limit: number): string | undefined {
|
||||
if (page <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = (page - 1) * limit;
|
||||
// Backend uses base64.RawURLEncoding (URL-safe, no padding)
|
||||
return btoa(JSON.stringify({ offset, limit }))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function computeCursorForPage(page: number): string | undefined {
|
||||
return encodeCursor(page, TIMELINE_TABLE_PAGE_SIZE);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { TelemetrytypesFieldContextDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
|
||||
|
||||
const fieldContextToSuggestionMap: Record<
|
||||
TelemetrytypesFieldContextDTO,
|
||||
QueryKeyDataSuggestionsProps['fieldContext']
|
||||
> = {
|
||||
[TelemetrytypesFieldContextDTO.resource]: 'resource',
|
||||
[TelemetrytypesFieldContextDTO.span]: 'span',
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
|
||||
// no maps for the following values on suggestion context
|
||||
[TelemetrytypesFieldContextDTO.body]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.metric]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.log]: undefined,
|
||||
[TelemetrytypesFieldContextDTO['']]: undefined,
|
||||
};
|
||||
|
||||
export function fieldContextToSuggestionContext(
|
||||
fc: TelemetrytypesFieldContextDTO | undefined,
|
||||
): QueryKeyDataSuggestionsProps['fieldContext'] {
|
||||
if (fc === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return fieldContextToSuggestionMap[fc];
|
||||
}
|
||||
@@ -23,7 +23,6 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -34,7 +33,6 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -59,17 +57,14 @@ 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',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { Querybuildertypesv5LabelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { Labels } from 'types/api/alerts/def';
|
||||
|
||||
export function labelsArrayToObject(
|
||||
labels: Querybuildertypesv5LabelDTO[] | null | undefined,
|
||||
): Labels {
|
||||
if (!labels) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return labels.reduce<Labels>((acc, label) => {
|
||||
const key = label.key?.name ?? '';
|
||||
const value = String(label.value ?? '');
|
||||
if (key) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
@@ -94,8 +94,6 @@ 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,11 +117,7 @@ function AlertActionButtons({
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
@@ -133,7 +129,6 @@ function AlertActionButtons({
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
|
||||
@@ -47,29 +47,21 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<div data-testid="alert-header-state">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
</div>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
<div className="alert-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && (
|
||||
<div data-testid="alert-header-severity">
|
||||
<AlertSeverity severity={labels.severity} />
|
||||
</div>
|
||||
)}
|
||||
{labels?.severity && <AlertSeverity severity={labels.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()}
|
||||
/> */}
|
||||
<div data-testid="alert-header-labels">
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
</div>
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { BellOff, CircleCheck, CircleOff, Flame } from '@signozhq/icons';
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import './AlertState.styles.scss';
|
||||
|
||||
type AlertStateProps = {
|
||||
state: RuletypesAlertStateDTO | string;
|
||||
state: string;
|
||||
showLabel?: boolean;
|
||||
};
|
||||
|
||||
@@ -18,7 +17,7 @@ export default function AlertState({
|
||||
let label;
|
||||
const isDarkMode = useIsDarkMode();
|
||||
switch (state) {
|
||||
case RuletypesAlertStateDTO.nodata:
|
||||
case 'nodata':
|
||||
icon = (
|
||||
<CircleOff
|
||||
size={18}
|
||||
@@ -29,7 +28,7 @@ export default function AlertState({
|
||||
label = <span style={{ color: Color.BG_SIENNA_400 }}>No Data</span>;
|
||||
break;
|
||||
|
||||
case RuletypesAlertStateDTO.disabled:
|
||||
case 'disabled':
|
||||
icon = (
|
||||
<BellOff
|
||||
size={18}
|
||||
@@ -39,16 +38,15 @@ export default function AlertState({
|
||||
);
|
||||
label = <span style={{ color: Color.BG_VANILLA_400 }}>Muted</span>;
|
||||
break;
|
||||
|
||||
case RuletypesAlertStateDTO.firing:
|
||||
case 'firing':
|
||||
icon = (
|
||||
<Flame size={18} fill={Color.BG_CHERRY_500} color={Color.BG_CHERRY_500} />
|
||||
);
|
||||
label = <span style={{ color: Color.BG_CHERRY_500 }}>Firing</span>;
|
||||
break;
|
||||
|
||||
case RuletypesAlertStateDTO.inactive:
|
||||
case 'normal': // legacy
|
||||
case 'normal':
|
||||
case 'inactive':
|
||||
icon = (
|
||||
<CircleCheck
|
||||
size={18}
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useMutation, useQueryClient, useQuery } from 'react-query';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from 'react-query';
|
||||
import { generatePath, useLocation } from 'react-router-dom';
|
||||
import { TablePaginationConfig, TableProps } from 'antd';
|
||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
import { patchRulePartial } from 'api/alerts/patchRulePartial';
|
||||
import ruleStats from 'api/alerts/ruleStats';
|
||||
import timelineGraph from 'api/alerts/timelineGraph';
|
||||
import timelineTable from 'api/alerts/timelineTable';
|
||||
import topContributors from 'api/alerts/topContributors';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createRule,
|
||||
deleteRuleByID,
|
||||
getGetRuleByIDQueryKey,
|
||||
getGetRuleHistoryTimelineQueryOptions,
|
||||
invalidateGetRuleByID,
|
||||
invalidateListRules,
|
||||
updateRuleByID,
|
||||
useGetRuleByID,
|
||||
useGetRuleHistoryOverallStatus,
|
||||
useGetRuleHistoryStats,
|
||||
useGetRuleHistoryTopContributors,
|
||||
useListRules,
|
||||
} from 'api/generated/services/rules';
|
||||
import {
|
||||
Querybuildertypesv5OrderDirectionDTO,
|
||||
RuletypesAlertStateDTO,
|
||||
type GetRuleByID200,
|
||||
type GetRuleHistoryOverallStatus200,
|
||||
type GetRuleHistoryStats200,
|
||||
type GetRuleHistoryTimeline200,
|
||||
type GetRuleHistoryTopContributors200,
|
||||
type RenderErrorResponseDTO,
|
||||
type RuletypesPostableRuleDTO,
|
||||
import type {
|
||||
GetRuleByID200,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPostableRuleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { TabRoutes } from 'components/RouteTab/types';
|
||||
@@ -37,27 +31,35 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import ROUTES from 'constants/routes';
|
||||
import AlertHistory from 'container/AlertHistory';
|
||||
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
|
||||
import {
|
||||
computeCursorForPage,
|
||||
useTimelineTableOrder,
|
||||
useTimelineTablePage,
|
||||
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
|
||||
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
|
||||
import { urlKey } from 'container/AllError/utils';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import history from 'lib/history';
|
||||
import { History, Table } from '@signozhq/icons';
|
||||
import EditRules from 'pages/EditRules';
|
||||
import { OrderPreferenceItems } from 'pages/Logs/config';
|
||||
import BetaTag from 'periscope/components/BetaTag/BetaTag';
|
||||
import PaginationInfoText from 'periscope/components/PaginationInfoText/PaginationInfoText';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorResponse, SuccessResponse } from 'types/api';
|
||||
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
|
||||
import { AlertDef, AlertRuleTimelineTableResponse } from 'types/api/alerts/def';
|
||||
import {
|
||||
AlertDef,
|
||||
AlertRuleStatsPayload,
|
||||
AlertRuleTimelineGraphResponsePayload,
|
||||
AlertRuleTimelineTableResponse,
|
||||
AlertRuleTimelineTableResponsePayload,
|
||||
AlertRuleTopContributorsPayload,
|
||||
} from 'types/api/alerts/def';
|
||||
import APIError from 'types/api/error';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { nanoToMilli } from 'utils/timeUtils';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
export const useAlertHistoryQueryParams = (): {
|
||||
ruleId: string | null;
|
||||
@@ -127,7 +129,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<div className="tab-item">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
@@ -138,7 +140,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<div className="tab-item">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
@@ -199,7 +201,10 @@ type GetAlertRuleDetailsApiProps = {
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsStatsProps = GetAlertRuleDetailsApiProps & {
|
||||
data: GetRuleHistoryStats200 | undefined;
|
||||
data:
|
||||
| SuccessResponse<AlertRuleStatsPayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsStats =
|
||||
@@ -208,15 +213,18 @@ export const useGetAlertRuleDetailsStats =
|
||||
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } = useGetRuleHistoryStats(
|
||||
{ id: ruleId || '' },
|
||||
{ start: startTime, end: endTime },
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[REACT_QUERY_KEY.ALERT_RULE_STATS, ruleId, startTime, endTime],
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId && !!startTime && !!endTime,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
queryFn: () =>
|
||||
ruleStats({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
}),
|
||||
enabled: isValidRuleId && !!startTime && !!endTime,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -224,7 +232,10 @@ export const useGetAlertRuleDetailsStats =
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsTopContributorsProps = GetAlertRuleDetailsApiProps & {
|
||||
data: GetRuleHistoryTopContributors200 | undefined;
|
||||
data:
|
||||
| SuccessResponse<AlertRuleTopContributorsPayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsTopContributors =
|
||||
@@ -233,128 +244,90 @@ export const useGetAlertRuleDetailsTopContributors =
|
||||
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } =
|
||||
useGetRuleHistoryTopContributors(
|
||||
{ id: ruleId || '' },
|
||||
{ start: startTime, end: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId && !!startTime && !!endTime,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[REACT_QUERY_KEY.ALERT_RULE_TOP_CONTRIBUTORS, ruleId, startTime, endTime],
|
||||
{
|
||||
queryFn: () =>
|
||||
topContributors({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
}),
|
||||
enabled: isValidRuleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsTimelineTableProps = GetAlertRuleDetailsApiProps & {
|
||||
data: GetRuleHistoryTimeline200 | undefined;
|
||||
error: AxiosError<RenderErrorResponseDTO> | null;
|
||||
refetch: () => void;
|
||||
cancel: () => void;
|
||||
data:
|
||||
| SuccessResponse<AlertRuleTimelineTableResponsePayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsTimelineTable = ({
|
||||
filterExpression,
|
||||
filters,
|
||||
}: {
|
||||
filterExpression: string;
|
||||
filters: TagFilter;
|
||||
}): GetAlertRuleDetailsTimelineTableProps => {
|
||||
const queryClient = useQueryClient();
|
||||
const { ruleId, startTime, endTime, params } = useAlertHistoryQueryParams();
|
||||
const [page, setPage] = useTimelineTablePage();
|
||||
const [order] = useTimelineTableOrder();
|
||||
|
||||
const updatedOrder = useMemo(
|
||||
() =>
|
||||
order === 'asc'
|
||||
? Querybuildertypesv5OrderDirectionDTO.asc
|
||||
: Querybuildertypesv5OrderDirectionDTO.desc,
|
||||
[order],
|
||||
const { updatedOrder, offset } = useMemo(
|
||||
() => ({
|
||||
updatedOrder: params.get(urlKey.order) ?? OrderPreferenceItems.ASC,
|
||||
offset: parseInt(params.get(urlKey.offset) ?? '0', 10),
|
||||
}),
|
||||
[params],
|
||||
);
|
||||
|
||||
const timelineFilter = params.get('timelineFilter');
|
||||
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
const hasStartAndEnd = startTime !== null && endTime !== null;
|
||||
|
||||
const stateFilter = useMemo(() => {
|
||||
if (!timelineFilter || timelineFilter === TimelineFilter.ALL) {
|
||||
return undefined;
|
||||
}
|
||||
return timelineFilter === TimelineFilter.FIRED
|
||||
? RuletypesAlertStateDTO.firing
|
||||
: RuletypesAlertStateDTO.inactive;
|
||||
}, [timelineFilter]);
|
||||
|
||||
const filtersKey = `${filterExpression}|${stateFilter ?? ''}|${startTime}|${endTime}`;
|
||||
const prevFiltersKeyRef = useRef(filtersKey);
|
||||
const filtersChanged = prevFiltersKeyRef.current !== filtersKey;
|
||||
const cursor = computeCursorForPage(filtersChanged ? 1 : page);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevFiltersKeyRef.current !== filtersKey) {
|
||||
prevFiltersKeyRef.current = filtersKey;
|
||||
if (page > 1) {
|
||||
void setPage(1);
|
||||
}
|
||||
}
|
||||
}, [filtersKey, page, setPage]);
|
||||
|
||||
const queryParams = useMemo(
|
||||
() => ({
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
limit: TIMELINE_TABLE_PAGE_SIZE,
|
||||
order: updatedOrder,
|
||||
cursor,
|
||||
filterExpression: filterExpression || undefined,
|
||||
state: stateFilter,
|
||||
}),
|
||||
[startTime, endTime, updatedOrder, cursor, filterExpression, stateFilter],
|
||||
);
|
||||
|
||||
const queryOptions = getGetRuleHistoryTimelineQueryOptions(
|
||||
{ id: ruleId || '' },
|
||||
queryParams,
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[
|
||||
REACT_QUERY_KEY.ALERT_RULE_TIMELINE_TABLE,
|
||||
ruleId,
|
||||
startTime,
|
||||
endTime,
|
||||
timelineFilter,
|
||||
updatedOrder,
|
||||
offset,
|
||||
JSON.stringify(filters.items),
|
||||
],
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
queryFn: () =>
|
||||
timelineTable({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
limit: TIMELINE_TABLE_PAGE_SIZE,
|
||||
order: updatedOrder,
|
||||
offset,
|
||||
filters,
|
||||
...(timelineFilter && timelineFilter !== TimelineFilter.ALL
|
||||
? {
|
||||
state: timelineFilter === TimelineFilter.FIRED ? 'firing' : 'normal',
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
enabled: isValidRuleId && hasStartAndEnd,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { isLoading, isRefetching, isError, data, error, refetch } =
|
||||
useQuery(queryOptions);
|
||||
|
||||
const queryKeyRef = useRef(queryOptions.queryKey);
|
||||
queryKeyRef.current = queryOptions.queryKey;
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
void queryClient.cancelQueries({ queryKey: queryKeyRef.current });
|
||||
}, [queryClient]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
data,
|
||||
error: error as AxiosError<RenderErrorResponseDTO> | null,
|
||||
isValidRuleId,
|
||||
ruleId,
|
||||
refetch,
|
||||
cancel,
|
||||
};
|
||||
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
|
||||
};
|
||||
|
||||
export const useTimelineTable = ({
|
||||
totalItems,
|
||||
nextCursor,
|
||||
}: {
|
||||
totalItems: number;
|
||||
nextCursor?: string;
|
||||
}): {
|
||||
paginationConfig: TablePaginationConfig;
|
||||
onChangeHandler: (
|
||||
@@ -363,13 +336,16 @@ export const useTimelineTable = ({
|
||||
filters: any,
|
||||
extra: any,
|
||||
) => void;
|
||||
handleNextPage: () => void;
|
||||
handlePrevPage: () => void;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
} => {
|
||||
const [page, setPage] = useTimelineTablePage();
|
||||
const [, setOrder] = useTimelineTableOrder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const { search } = useLocation();
|
||||
|
||||
const params = useMemo(() => new URLSearchParams(search), [search]);
|
||||
|
||||
const offset = params.get('offset') ?? '0';
|
||||
|
||||
const onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'] =
|
||||
useCallback(
|
||||
@@ -381,52 +357,38 @@ export const useTimelineTable = ({
|
||||
| SorterResult<AlertRuleTimelineTableResponse>,
|
||||
) => {
|
||||
if (!Array.isArray(sorter)) {
|
||||
const { pageSize = 0, current = 0 } = pagination;
|
||||
const { order } = sorter;
|
||||
const updatedOrder = order === 'ascend' ? 'asc' : 'desc';
|
||||
void Promise.all([setOrder(updatedOrder), setPage(1)]);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
safeNavigate(
|
||||
`${pathname}?${createQueryParams({
|
||||
...Object.fromEntries(params),
|
||||
order: updatedOrder,
|
||||
offset: current * TIMELINE_TABLE_PAGE_SIZE - TIMELINE_TABLE_PAGE_SIZE,
|
||||
pageSize,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[setOrder, setPage],
|
||||
[pathname, safeNavigate],
|
||||
);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (!nextCursor) {
|
||||
return;
|
||||
}
|
||||
void setPage(page + 1);
|
||||
}, [nextCursor, page, setPage]);
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
if (page <= 1) {
|
||||
return;
|
||||
}
|
||||
void setPage(page - 1);
|
||||
}, [page, setPage]);
|
||||
const offsetInt = parseInt(offset, 10);
|
||||
const pageSize = params.get('pageSize') ?? String(TIMELINE_TABLE_PAGE_SIZE);
|
||||
const pageSizeInt = parseInt(pageSize, 10);
|
||||
|
||||
const paginationConfig: TablePaginationConfig = {
|
||||
pageSize: TIMELINE_TABLE_PAGE_SIZE,
|
||||
showTotal: (total, [start, end]) => (
|
||||
<span>
|
||||
<Typography.Text size="small">
|
||||
{start} — {end}
|
||||
</Typography.Text>
|
||||
<Typography.Text size="small"> of {total}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
current: page,
|
||||
pageSize: pageSizeInt,
|
||||
showTotal: PaginationInfoText,
|
||||
current: offsetInt / TIMELINE_TABLE_PAGE_SIZE + 1,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
total: totalItems,
|
||||
};
|
||||
|
||||
return {
|
||||
paginationConfig,
|
||||
onChangeHandler,
|
||||
handleNextPage,
|
||||
handlePrevPage,
|
||||
hasNextPage: !!nextCursor,
|
||||
hasPrevPage: page > 1,
|
||||
};
|
||||
return { paginationConfig, onChangeHandler };
|
||||
};
|
||||
|
||||
export const useAlertRuleStatusToggle = ({
|
||||
@@ -619,7 +581,10 @@ export const useAlertRuleDelete = ({
|
||||
};
|
||||
|
||||
type GetAlertRuleDetailsTimelineGraphProps = GetAlertRuleDetailsApiProps & {
|
||||
data: GetRuleHistoryOverallStatus200 | undefined;
|
||||
data:
|
||||
| SuccessResponse<AlertRuleTimelineGraphResponsePayload, unknown>
|
||||
| ErrorResponse
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const useGetAlertRuleDetailsTimelineGraphData =
|
||||
@@ -629,18 +594,20 @@ export const useGetAlertRuleDetailsTimelineGraphData =
|
||||
const isValidRuleId = ruleId !== null && String(ruleId).length !== 0;
|
||||
const hasStartAndEnd = startTime !== null && endTime !== null;
|
||||
|
||||
const { isLoading, isRefetching, isError, data } =
|
||||
useGetRuleHistoryOverallStatus(
|
||||
{ id: ruleId || '' },
|
||||
{ start: startTime, end: endTime },
|
||||
{
|
||||
query: {
|
||||
enabled: isValidRuleId && hasStartAndEnd,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
const { isLoading, isRefetching, isError, data } = useQuery(
|
||||
[REACT_QUERY_KEY.ALERT_RULE_TIMELINE_GRAPH, ruleId, startTime, endTime],
|
||||
{
|
||||
queryFn: () =>
|
||||
timelineGraph({
|
||||
id: ruleId || '',
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
}),
|
||||
enabled: isValidRuleId && hasStartAndEnd,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
return { isLoading, isRefetching, isError, data, isValidRuleId, ruleId };
|
||||
};
|
||||
|
||||
@@ -13,8 +13,6 @@ interface Tab {
|
||||
disabled?: boolean;
|
||||
icon?: string | JSX.Element;
|
||||
isBeta?: boolean;
|
||||
/** Optional `data-testid` for the tab button. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
interface TimelineTabsProps {
|
||||
@@ -65,7 +63,6 @@ function Tabs2({
|
||||
disabled={tab.disabled}
|
||||
icon={tab.icon}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
data-testid={tab.testId}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RuletypesAlertStateDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AlertLabelsProps } from 'pages/AlertDetails/AlertHeader/AlertLabels/AlertLabels';
|
||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||
|
||||
// default match type for threshold
|
||||
@@ -73,6 +73,10 @@ export interface StatsTimeSeriesItem {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type AlertRuleStatsPayload = {
|
||||
data: AlertRuleStats;
|
||||
};
|
||||
|
||||
export interface AlertRuleTopContributors {
|
||||
fingerprint: number;
|
||||
labels: Labels;
|
||||
@@ -80,6 +84,9 @@ export interface AlertRuleTopContributors {
|
||||
relatedLogsLink: string;
|
||||
relatedTracesLink: string;
|
||||
}
|
||||
export type AlertRuleTopContributorsPayload = {
|
||||
data: AlertRuleTopContributors[];
|
||||
};
|
||||
|
||||
export interface AlertRuleTimelineTableResponse {
|
||||
ruleID: string;
|
||||
@@ -92,12 +99,24 @@ export interface AlertRuleTimelineTableResponse {
|
||||
labels: Labels;
|
||||
fingerprint: number;
|
||||
value: number;
|
||||
relatedLogsLink?: string;
|
||||
relatedTracesLink?: string;
|
||||
relatedTracesLink: string;
|
||||
relatedLogsLink: string;
|
||||
}
|
||||
export type AlertRuleTimelineTableResponsePayload = {
|
||||
data: {
|
||||
items: AlertRuleTimelineTableResponse[];
|
||||
total: number;
|
||||
labels: AlertLabelsProps['labels'];
|
||||
};
|
||||
};
|
||||
|
||||
type AlertState = 'firing' | 'normal' | 'nodata' | 'muted';
|
||||
|
||||
export interface AlertRuleTimelineGraphResponse {
|
||||
start: number;
|
||||
end: number;
|
||||
state: RuletypesAlertStateDTO;
|
||||
state: AlertState;
|
||||
}
|
||||
export type AlertRuleTimelineGraphResponsePayload = {
|
||||
data: AlertRuleTimelineGraphResponse[];
|
||||
};
|
||||
|
||||
7
frontend/src/types/api/alerts/ruleStats.ts
Normal file
7
frontend/src/types/api/alerts/ruleStats.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface RuleStatsProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
7
frontend/src/types/api/alerts/timelineGraph.ts
Normal file
7
frontend/src/types/api/alerts/timelineGraph.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface GetTimelineGraphRequestProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
13
frontend/src/types/api/alerts/timelineTable.ts
Normal file
13
frontend/src/types/api/alerts/timelineTable.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { TagFilter } from '../queryBuilder/queryBuilderData';
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface GetTimelineTableRequestProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
order: string;
|
||||
filters?: TagFilter;
|
||||
state?: string;
|
||||
}
|
||||
7
frontend/src/types/api/alerts/topContributors.ts
Normal file
7
frontend/src/types/api/alerts/topContributors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { AlertDef } from './def';
|
||||
|
||||
export interface TopContributorsProps {
|
||||
id: AlertDef['id'];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
@@ -75,6 +76,7 @@ type provider struct {
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -110,6 +112,7 @@ func NewFactory(
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.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(
|
||||
@@ -148,6 +151,7 @@ func NewFactory(
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -188,6 +192,7 @@ func newProvider(
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -227,6 +232,7 @@ func newProvider(
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -359,6 +365,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addSavedViewRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
101
pkg/apiserver/signozapiserver/savedview.go
Normal file
101
pkg/apiserver/signozapiserver/savedview.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/saved_views", handler.New(provider.authzMiddleware.ViewAccess(provider.savedViewHandler.ListV2), handler.OpenAPIDef{
|
||||
ID: "ListSavedViews",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "List saved views",
|
||||
Description: "Returns saved views, optionally filtered by source page and name.",
|
||||
Request: nil,
|
||||
RequestQuery: new(savedviewtypes.ListSavedViewsParams),
|
||||
RequestContentType: "",
|
||||
Response: new([]*savedviewtypes.GettableSavedView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.CreateV2), handler.OpenAPIDef{
|
||||
ID: "CreateSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Create saved view",
|
||||
Description: "Persists a saved view for the explore page. Returns the id of the created view.",
|
||||
Request: new(savedviewtypes.PostableSavedView),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(valuer.UUID),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.ViewAccess(provider.savedViewHandler.GetV2), handler.OpenAPIDef{
|
||||
ID: "GetSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Get saved view",
|
||||
Description: "Returns a saved view by id.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(savedviewtypes.GettableSavedView),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.UpdateV2), handler.OpenAPIDef{
|
||||
ID: "UpdateSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Update saved view",
|
||||
Description: "Replaces a saved view's name and query.",
|
||||
Request: new(savedviewtypes.UpdatableSavedView),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/saved_views/{viewId}", handler.New(provider.authzMiddleware.EditAccess(provider.savedViewHandler.Delete), handler.OpenAPIDef{
|
||||
ID: "DeleteSavedView",
|
||||
Tags: []string{"saved_view"},
|
||||
Summary: "Delete saved view",
|
||||
Description: "Deletes a saved view by id.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -485,11 +485,9 @@ func (s *store) buildFilterClause(ctx context.Context, orgID valuer.UUID, filter
|
||||
}
|
||||
|
||||
fieldKeys, _, err := s.telemetryMetadataStore.GetKeysMulti(ctx, orgID, selectors)
|
||||
if err != nil || fieldKeys == nil {
|
||||
if err != nil || len(fieldKeys) == 0 {
|
||||
fieldKeys = map[string][]*telemetrytypes.TelemetryFieldKey{}
|
||||
}
|
||||
for _, sel := range selectors {
|
||||
if len(fieldKeys[sel.Name]) == 0 {
|
||||
for _, sel := range selectors {
|
||||
fieldKeys[sel.Name] = []*telemetrytypes.TelemetryFieldKey{{
|
||||
Name: sel.Name,
|
||||
Signal: sel.Signal,
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -23,6 +25,85 @@ func NewHandler(module savedview.Module) savedview.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
// legacyExtraData mirrors the frontend's extraData JSON shape so /api/v1
|
||||
// responses can synthesize the same shape back for the legacy frontend.
|
||||
type legacyExtraData struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
SelectColumns []telemetrytypes.TelemetryFieldKey `json:"selectColumns,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MaxLines int `json:"maxLines,omitempty"`
|
||||
FontSize string `json:"fontSize,omitempty"`
|
||||
}
|
||||
|
||||
func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.PostableSavedView {
|
||||
var legacy legacyExtraData
|
||||
if v.ExtraData != "" {
|
||||
// Best-effort: malformed/older extraData shapes never fail the request
|
||||
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
|
||||
}
|
||||
|
||||
return savedviewtypes.PostableSavedView{
|
||||
Name: v.Name,
|
||||
SourcePage: savedviewtypes.SourcePage{String: valuer.NewString(v.SourcePage)},
|
||||
SavedViewData: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
|
||||
Queries: v.CompositeQuery.Queries,
|
||||
SelectedFields: legacy.SelectColumns,
|
||||
Display: savedviewtypes.Display{
|
||||
MaxLines: legacy.MaxLines,
|
||||
FontSize: legacy.FontSize,
|
||||
Format: legacy.Format,
|
||||
Color: legacy.Color,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newLegacyViewFromGettable(v *savedviewtypes.GettableSavedView) (*v3.SavedView, error) {
|
||||
extraData, err := json.Marshal(legacyExtraData{
|
||||
Color: v.Spec.Display.Color,
|
||||
SelectColumns: v.Spec.SelectedFields,
|
||||
Format: v.Spec.Display.Format,
|
||||
MaxLines: v.Spec.Display.MaxLines,
|
||||
FontSize: v.Spec.Display.FontSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
|
||||
}
|
||||
|
||||
return &v3.SavedView{
|
||||
ID: v.ID,
|
||||
Name: v.Name,
|
||||
CreatedAt: v.CreatedAt,
|
||||
CreatedBy: v.CreatedBy,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
UpdatedBy: v.UpdatedBy,
|
||||
SourcePage: v.SourcePage.StringValue(),
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
PanelType: v3.PanelType(v.Spec.PanelType.StringValue()),
|
||||
// Saved views are only ever created from the explorer's builder mode.
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
Queries: v.Spec.Queries,
|
||||
},
|
||||
ExtraData: string(extraData),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newLegacyViewsFromGettable(views []*savedviewtypes.GettableSavedView) ([]*v3.SavedView, error) {
|
||||
out := make([]*v3.SavedView, 0, len(views))
|
||||
for _, view := range views {
|
||||
legacyView, err := newLegacyViewFromGettable(view)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, legacyView)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -44,7 +125,7 @@ func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, view)
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, newPostableSavedViewFromLegacyView(&view))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -76,7 +157,13 @@ func (handler *handler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, view)
|
||||
legacyView, err := newLegacyViewFromGettable(view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, legacyView)
|
||||
}
|
||||
|
||||
func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -106,7 +193,7 @@ func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, view)
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, newPostableSavedViewFromLegacyView(&view))
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -153,13 +240,18 @@ func (handler *handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sourcePage := r.URL.Query().Get("sourcePage")
|
||||
name := r.URL.Query().Get("name")
|
||||
category := r.URL.Query().Get("category")
|
||||
|
||||
queries, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, sourcePage, name, category)
|
||||
views, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, savedviewtypes.SourcePage{String: valuer.NewString(sourcePage)}, name)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, queries)
|
||||
legacyViews, err := newLegacyViewsFromGettable(views)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, legacyViews)
|
||||
}
|
||||
|
||||
134
pkg/modules/savedview/implsavedview/handler_v2.go
Normal file
134
pkg/modules/savedview/implsavedview/handler_v2.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (handler *handler) CreateV2(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
var view savedviewtypes.PostableSavedView
|
||||
if err := binding.JSON.BindBody(r.Body, &view, binding.WithDisallowUnknownFields(true)); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := view.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, uuid)
|
||||
}
|
||||
|
||||
func (handler *handler) GetV2(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
viewID := mux.Vars(r)["viewId"]
|
||||
viewUUID, err := valuer.NewUUID(viewID)
|
||||
if err != nil {
|
||||
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
|
||||
return
|
||||
}
|
||||
|
||||
view, err := handler.module.GetView(ctx, claims.OrgID, viewUUID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateV2(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
viewID := mux.Vars(r)["viewId"]
|
||||
viewUUID, err := valuer.NewUUID(viewID)
|
||||
if err != nil {
|
||||
render.Error(w, errors.Wrapf(err, errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to parse view id"))
|
||||
return
|
||||
}
|
||||
var view savedviewtypes.UpdatableSavedView
|
||||
if err := binding.JSON.BindBody(r.Body, &view, binding.WithDisallowUnknownFields(true)); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := view.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, view)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListV2(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
params := new(savedviewtypes.ListSavedViewsParams)
|
||||
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
if err := params.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
queries, err := handler.module.GetViewsForFilters(r.Context(), claims.OrgID, params.SourcePage, params.Name)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(w, http.StatusOK, queries)
|
||||
}
|
||||
@@ -2,15 +2,10 @@ package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -24,155 +19,96 @@ func NewModule(sqlstore sqlstore.SQLStore) savedview.Module {
|
||||
return &module{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error) {
|
||||
var views []savedviewtypes.SavedView
|
||||
var err error
|
||||
if len(category) == 0 {
|
||||
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").Scan(ctx)
|
||||
} else {
|
||||
err = module.sqlstore.BunDB().NewSelect().Model(&views).Where("org_id = ? AND source_page = ? AND category LIKE ? AND name LIKE ?", orgID, sourcePage, "%"+category+"%", "%"+name+"%").Scan(ctx)
|
||||
}
|
||||
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.GettableSavedView, error) {
|
||||
var views []*savedviewtypes.StorableSavedView
|
||||
err := module.sqlstore.BunDB().NewSelect().Model(&views).
|
||||
Where("org_id = ? AND source_page = ? AND name LIKE ?", orgID, sourcePage, "%"+name+"%").
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
|
||||
}
|
||||
|
||||
var savedViews []*v3.SavedView
|
||||
for _, view := range views {
|
||||
var compositeQuery v3.CompositeQuery
|
||||
err = json.Unmarshal([]byte(view.Data), &compositeQuery)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in unmarshalling explorer query data: %s", err.Error())
|
||||
}
|
||||
savedViews = append(savedViews, &v3.SavedView{
|
||||
ID: view.ID,
|
||||
Name: view.Name,
|
||||
CreatedAt: view.CreatedAt,
|
||||
CreatedBy: view.CreatedBy,
|
||||
UpdatedAt: view.UpdatedAt,
|
||||
UpdatedBy: view.UpdatedBy,
|
||||
Tags: strings.Split(view.Tags, ","),
|
||||
SourcePage: view.SourcePage,
|
||||
CompositeQuery: &compositeQuery,
|
||||
ExtraData: view.ExtraData,
|
||||
})
|
||||
}
|
||||
return savedViews, nil
|
||||
return savedviewtypes.NewGettableSavedViewsFromStorable(views), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error) {
|
||||
data, err := json.Marshal(view.CompositeQuery)
|
||||
func (module *module) CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling explorer query data")
|
||||
}
|
||||
|
||||
uuid := valuer.GenerateUUID()
|
||||
createdAt := time.Now()
|
||||
updatedAt := time.Now()
|
||||
|
||||
claims, errv2 := authtypes.ClaimsFromContext(ctx)
|
||||
if errv2 != nil {
|
||||
return valuer.UUID{}, errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
|
||||
}
|
||||
|
||||
createBy := claims.Email
|
||||
updatedBy := claims.Email
|
||||
dbView := savedviewtypes.NewStorableSavedView(orgID, claims.Email, claims.Email, view)
|
||||
|
||||
dbView := savedviewtypes.SavedView{
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
UserAuditable: types.UserAuditable{
|
||||
CreatedBy: createBy,
|
||||
UpdatedBy: updatedBy,
|
||||
},
|
||||
OrgID: orgID,
|
||||
Identifiable: types.Identifiable{
|
||||
ID: uuid,
|
||||
},
|
||||
Name: view.Name,
|
||||
Category: view.Category,
|
||||
SourcePage: view.SourcePage,
|
||||
Tags: strings.Join(view.Tags, ","),
|
||||
Data: string(data),
|
||||
ExtraData: view.ExtraData,
|
||||
}
|
||||
|
||||
_, err = module.sqlstore.BunDB().NewInsert().Model(&dbView).Exec(ctx)
|
||||
_, err = module.sqlstore.BunDB().NewInsert().Model(dbView).Exec(ctx)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, errors.WrapInternalf(err, errors.CodeInternal, "error in creating saved view")
|
||||
}
|
||||
return uuid, nil
|
||||
return dbView.ID, nil
|
||||
}
|
||||
|
||||
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error) {
|
||||
var view savedviewtypes.SavedView
|
||||
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.GettableSavedView, error) {
|
||||
var view savedviewtypes.StorableSavedView
|
||||
err := module.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, uuid.StringValue()).Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved view")
|
||||
return nil, module.sqlstore.WrapNotFoundErrf(err, savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", uuid.StringValue())
|
||||
}
|
||||
|
||||
var compositeQuery v3.CompositeQuery
|
||||
err = json.Unmarshal([]byte(view.Data), &compositeQuery)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in unmarshalling explorer query data")
|
||||
}
|
||||
return &v3.SavedView{
|
||||
ID: view.ID,
|
||||
Name: view.Name,
|
||||
Category: view.Category,
|
||||
CreatedAt: view.CreatedAt,
|
||||
CreatedBy: view.CreatedBy,
|
||||
UpdatedAt: view.UpdatedAt,
|
||||
UpdatedBy: view.UpdatedBy,
|
||||
SourcePage: view.SourcePage,
|
||||
Tags: strings.Split(view.Tags, ","),
|
||||
CompositeQuery: &compositeQuery,
|
||||
ExtraData: view.ExtraData,
|
||||
}, nil
|
||||
return savedviewtypes.NewGettableSavedViewFromStorable(&view), nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error {
|
||||
data, err := json.Marshal(view.CompositeQuery)
|
||||
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling explorer query data")
|
||||
}
|
||||
|
||||
claims, errv2 := authtypes.ClaimsFromContext(ctx)
|
||||
if errv2 != nil {
|
||||
return errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
|
||||
}
|
||||
|
||||
updatedAt := time.Now()
|
||||
updatedBy := claims.Email
|
||||
dbView := savedviewtypes.NewStorableSavedView(orgID, claims.Email, claims.Email, view)
|
||||
|
||||
_, err = module.sqlstore.BunDB().NewUpdate().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Set("updated_at = ?, updated_by = ?, name = ?, category = ?, source_page = ?, tags = ?, data = ?, extra_data = ?",
|
||||
updatedAt, updatedBy, view.Name, view.Category, view.SourcePage, strings.Join(view.Tags, ","), data, view.ExtraData).
|
||||
res, err := module.sqlstore.BunDB().NewUpdate().
|
||||
Model(&savedviewtypes.StorableSavedView{}).
|
||||
Set("updated_at = ?, updated_by = ?, name = ?, source_page = ?, data = ?",
|
||||
dbView.UpdatedAt, dbView.UpdatedBy, dbView.Name, dbView.SourcePage, dbView.Data).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the updated saved view")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", uuid.StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
|
||||
_, err := module.sqlstore.BunDB().NewDelete().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
res, err := module.sqlstore.BunDB().NewDelete().
|
||||
Model(&savedviewtypes.StorableSavedView{}).
|
||||
Where("id = ?", uuid.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in deleting explorer query")
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in deleting saved view")
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the deleted saved view")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", uuid.StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
savedViews := []*savedviewtypes.SavedView{}
|
||||
savedViews := []*savedviewtypes.StorableSavedView{}
|
||||
|
||||
err := module.
|
||||
sqlstore.
|
||||
|
||||
270
pkg/modules/savedview/implsavedview/module_test.go
Normal file
270
pkg/modules/savedview/implsavedview/module_test.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{
|
||||
MaxOpenConns: 1,
|
||||
MaxConnLifetime: 0,
|
||||
},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: dbPath,
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.BunDB().NewCreateTable().
|
||||
Model((*savedviewtypes.StorableSavedView)(nil)).
|
||||
IfNotExists().
|
||||
Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func testPostableSavedView(name string, sourcePage savedviewtypes.SourcePage) savedviewtypes.PostableSavedView {
|
||||
return savedviewtypes.PostableSavedView{
|
||||
Name: name,
|
||||
SourcePage: sourcePage,
|
||||
SavedViewData: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
PanelType: savedviewtypes.PanelTypeGraph,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func contextWithClaims(orgID, email string) context.Context {
|
||||
return authtypes.NewContextWithClaims(context.Background(), authtypes.Claims{
|
||||
OrgID: orgID,
|
||||
Email: email,
|
||||
})
|
||||
}
|
||||
|
||||
func TestModule_CreateAndGetView(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
|
||||
id, err := m.CreateView(ctx, orgID, testPostableSavedView("my view", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
require.False(t, id.IsZero())
|
||||
|
||||
got, err := m.GetView(ctx, orgID, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, got.ID)
|
||||
assert.Equal(t, "my view", got.Name)
|
||||
assert.Equal(t, savedviewtypes.SourcePageLogs, got.SourcePage)
|
||||
assert.Equal(t, "creator@signoz.io", got.CreatedBy)
|
||||
assert.Equal(t, "creator@signoz.io", got.UpdatedBy)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Spec.PanelType)
|
||||
}
|
||||
|
||||
func TestModule_GetView_ScopedToOrg(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgA := valuer.GenerateUUID().StringValue()
|
||||
orgB := valuer.GenerateUUID().StringValue()
|
||||
|
||||
id, err := m.CreateView(contextWithClaims(orgA, "a@signoz.io"), orgA, testPostableSavedView("org a's view", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = m.GetView(contextWithClaims(orgB, "b@signoz.io"), orgB, id)
|
||||
require.Error(t, err, "a view created under org A must not be visible to org B")
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
}
|
||||
|
||||
func TestModule_UpdateView(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
|
||||
id, err := m.CreateView(ctx, orgID, testPostableSavedView("original", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
|
||||
updated := testPostableSavedView("renamed", savedviewtypes.SourcePageTraces)
|
||||
updated.Spec.PanelType = savedviewtypes.PanelTypeTable
|
||||
|
||||
updateCtx := contextWithClaims(orgID, "updater@signoz.io")
|
||||
require.NoError(t, m.UpdateView(updateCtx, orgID, id, updated))
|
||||
|
||||
got, err := m.GetView(ctx, orgID, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "renamed", got.Name)
|
||||
assert.Equal(t, savedviewtypes.SourcePageTraces, got.SourcePage)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
|
||||
assert.Equal(t, "updater@signoz.io", got.UpdatedBy)
|
||||
assert.Equal(t, "creator@signoz.io", got.CreatedBy, "creator is untouched by an update")
|
||||
}
|
||||
|
||||
func TestModule_UpdateView_NotFound(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "someone@signoz.io")
|
||||
|
||||
err := m.UpdateView(ctx, orgID, valuer.GenerateUUID(), testPostableSavedView("does not exist", savedviewtypes.SourcePageLogs))
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
}
|
||||
|
||||
func TestModule_UpdateView_ScopedToOrg(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgA := valuer.GenerateUUID().StringValue()
|
||||
orgB := valuer.GenerateUUID().StringValue()
|
||||
|
||||
id, err := m.CreateView(contextWithClaims(orgA, "a@signoz.io"), orgA, testPostableSavedView("org a's view", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.UpdateView(contextWithClaims(orgB, "b@signoz.io"), orgB, id, testPostableSavedView("hijacked", savedviewtypes.SourcePageLogs))
|
||||
require.Error(t, err, "org B must not be able to update org A's view")
|
||||
}
|
||||
|
||||
func TestModule_DeleteView(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
|
||||
id, err := m.CreateView(ctx, orgID, testPostableSavedView("my view", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, m.DeleteView(ctx, orgID, id))
|
||||
|
||||
_, err = m.GetView(ctx, orgID, id)
|
||||
require.Error(t, err, "deleted view should no longer be gettable")
|
||||
}
|
||||
|
||||
func TestModule_DeleteView_NotFound(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "someone@signoz.io")
|
||||
|
||||
err := m.DeleteView(ctx, orgID, valuer.GenerateUUID())
|
||||
require.Error(t, err)
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound), "expected a not-found error, got %v", err)
|
||||
}
|
||||
|
||||
func TestModule_DeleteView_ScopedToOrg(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgA := valuer.GenerateUUID().StringValue()
|
||||
orgB := valuer.GenerateUUID().StringValue()
|
||||
|
||||
id, err := m.CreateView(contextWithClaims(orgA, "a@signoz.io"), orgA, testPostableSavedView("org a's view", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.DeleteView(contextWithClaims(orgB, "b@signoz.io"), orgB, id)
|
||||
require.Error(t, err, "org B must not be able to delete org A's view")
|
||||
assert.True(t, errors.Ast(err, errors.TypeNotFound))
|
||||
}
|
||||
|
||||
func TestModule_GetViewsForFilters(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
ctx := contextWithClaims(orgID, "creator@signoz.io")
|
||||
|
||||
_, err := m.CreateView(ctx, orgID, testPostableSavedView("logs overview", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
_, err = m.CreateView(ctx, orgID, testPostableSavedView("logs errors", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
_, err = m.CreateView(ctx, orgID, testPostableSavedView("traces overview", savedviewtypes.SourcePageTraces))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("filters by source page", func(t *testing.T) {
|
||||
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePageLogs, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, views, 2)
|
||||
})
|
||||
|
||||
t.Run("filters by name substring", func(t *testing.T) {
|
||||
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePageLogs, "errors")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, views, 1)
|
||||
assert.Equal(t, "logs errors", views[0].Name)
|
||||
})
|
||||
|
||||
t.Run("source page filter is an exact match, not a wildcard", func(t *testing.T) {
|
||||
// source_page is matched with =, not LIKE, so a zero-value
|
||||
// SourcePage doesn't mean "any" -- it matches nothing.
|
||||
views, err := m.GetViewsForFilters(ctx, orgID, savedviewtypes.SourcePage{}, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, views)
|
||||
})
|
||||
|
||||
t.Run("scoped to org", func(t *testing.T) {
|
||||
otherOrgID := valuer.GenerateUUID().StringValue()
|
||||
views, err := m.GetViewsForFilters(ctx, otherOrgID, savedviewtypes.SourcePageLogs, "")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, views)
|
||||
})
|
||||
}
|
||||
|
||||
func TestModule_Collect(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
m := NewModule(store)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
ctx := contextWithClaims(orgID.StringValue(), "creator@signoz.io")
|
||||
|
||||
_, err := m.CreateView(ctx, orgID.StringValue(), testPostableSavedView("logs a", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
_, err = m.CreateView(ctx, orgID.StringValue(), testPostableSavedView("logs b", savedviewtypes.SourcePageLogs))
|
||||
require.NoError(t, err)
|
||||
_, err = m.CreateView(ctx, orgID.StringValue(), testPostableSavedView("traces a", savedviewtypes.SourcePageTraces))
|
||||
require.NoError(t, err)
|
||||
|
||||
stats, err := m.Collect(context.Background(), orgID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(3), stats["savedview.count"])
|
||||
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
|
||||
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
|
||||
}
|
||||
@@ -4,19 +4,19 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
GetViewsForFilters(ctx context.Context, orgID string, sourcePage string, name string, category string) ([]*v3.SavedView, error)
|
||||
GetViewsForFilters(ctx context.Context, orgID string, sourcePage savedviewtypes.SourcePage, name string) ([]*savedviewtypes.GettableSavedView, error)
|
||||
|
||||
CreateView(ctx context.Context, orgID string, view v3.SavedView) (valuer.UUID, error)
|
||||
CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error)
|
||||
|
||||
GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*v3.SavedView, error)
|
||||
GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.GettableSavedView, error)
|
||||
|
||||
UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view v3.SavedView) error
|
||||
UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error
|
||||
|
||||
DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error
|
||||
|
||||
@@ -33,9 +33,22 @@ type Handler interface {
|
||||
// Updates the saved view
|
||||
Update(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Deletes the saved view
|
||||
// Deletes the saved view. Shared by both API generations -- delete has no
|
||||
// request/response body to reshape.
|
||||
Delete(http.ResponseWriter, *http.Request)
|
||||
|
||||
// Lists the saved views
|
||||
List(http.ResponseWriter, *http.Request)
|
||||
|
||||
// CreateV2 is the /api/v2/saved_views typed-spec variant of Create.
|
||||
CreateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
// GetV2 is the /api/v2/saved_views typed-spec variant of Get.
|
||||
GetV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
// UpdateV2 is the /api/v2/saved_views typed-spec variant of Update.
|
||||
UpdateV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
// ListV2 is the /api/v2/saved_views typed-spec variant of List.
|
||||
ListV2(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/promote"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rawdataexport"
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/savedview"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
@@ -88,6 +89,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ tracedetail.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -231,6 +231,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlstore, sqlschema, dashboardStore, tagModule),
|
||||
sqlmigration.NewFillDashboardMeterSourceFactory(sqlstore, dashboardStore),
|
||||
sqlmigration.NewUpdateRoleTransactionGroupsFactory(),
|
||||
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -330,6 +331,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
144
pkg/sqlmigration/106_restructure_saved_view_spec.go
Normal file
144
pkg/sqlmigration/106_restructure_saved_view_spec.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
)
|
||||
|
||||
type restructureSavedViewSpec struct {
|
||||
store sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewRestructureSavedViewSpecFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("restructure_saved_view_spec"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &restructureSavedViewSpec{store: store}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *restructureSavedViewSpec) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// legacySavedViewCompositeQuery is the bare shape saved_view.data held
|
||||
// before this migration -- just the relevant fields of composite query.
|
||||
// Queries is kept as raw JSON since the migration only needs to relocate it, not interpret it.
|
||||
type legacySavedViewCompositeQuery struct {
|
||||
PanelType string `json:"panelType"`
|
||||
Queries json.RawMessage `json:"queries"`
|
||||
}
|
||||
|
||||
// legacySavedViewExtraData mirrors the frontend defined extraData JSON shape.
|
||||
type legacySavedViewExtraData struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
SelectColumns json.RawMessage `json:"selectColumns,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MaxLines int `json:"maxLines,omitempty"`
|
||||
FontSize string `json:"fontSize,omitempty"`
|
||||
}
|
||||
|
||||
type savedViewDisplay struct {
|
||||
MaxLines int `json:"maxLines"`
|
||||
FontSize string `json:"fontSize"`
|
||||
Format string `json:"format"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
type savedViewSpec struct {
|
||||
PanelType string `json:"panelType"`
|
||||
Queries json.RawMessage `json:"queries"`
|
||||
SelectedFields json.RawMessage `json:"selectedFields"`
|
||||
Display savedViewDisplay `json:"display"`
|
||||
}
|
||||
|
||||
type savedViewData struct {
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Spec savedViewSpec `json:"spec"`
|
||||
}
|
||||
|
||||
func (migration *restructureSavedViewSpec) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var savedViews []struct {
|
||||
ID string `bun:"id"`
|
||||
Data string `bun:"data"`
|
||||
ExtraData string `bun:"extra_data"`
|
||||
}
|
||||
|
||||
err = tx.NewSelect().
|
||||
Table("saved_views").
|
||||
Column("id", "data", "extra_data").
|
||||
Scan(ctx, &savedViews)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, savedView := range savedViews {
|
||||
var compositeQuery legacySavedViewCompositeQuery
|
||||
if err := json.Unmarshal([]byte(savedView.Data), &compositeQuery); err != nil {
|
||||
continue // skip the row on error rather than fail the whole migration
|
||||
}
|
||||
|
||||
var extraData legacySavedViewExtraData
|
||||
if savedView.ExtraData != "" {
|
||||
// best-effort: malformed/older extraData shapes never fail the migration,
|
||||
// they just leave selectedFields/display empty.
|
||||
_ = json.Unmarshal([]byte(savedView.ExtraData), &extraData)
|
||||
}
|
||||
|
||||
dataJSON, err := json.Marshal(savedViewData{
|
||||
SchemaVersion: "v2",
|
||||
Spec: savedViewSpec{
|
||||
PanelType: compositeQuery.PanelType,
|
||||
Queries: compositeQuery.Queries,
|
||||
SelectedFields: extraData.SelectColumns,
|
||||
Display: savedViewDisplay{
|
||||
MaxLines: extraData.MaxLines,
|
||||
FontSize: extraData.FontSize,
|
||||
Format: extraData.Format,
|
||||
Color: extraData.Color,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.NewUpdate().
|
||||
Table("saved_views").
|
||||
Set("data = ?", string(dataJSON)).
|
||||
Where("id = ?", savedView.ID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, column := range []string{"category", "tags"} {
|
||||
if err := migration.store.Dialect().DropColumn(ctx, tx, "saved_views", column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// matching the singular table-name convention.
|
||||
if _, err := tx.ExecContext(ctx, "ALTER TABLE saved_views RENAME TO saved_view"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *restructureSavedViewSpec) Down(context.Context, *bun.DB) error {
|
||||
// this migration is not reversible as we're transforming the structure
|
||||
return nil
|
||||
}
|
||||
@@ -2,30 +2,145 @@ package savedviewtypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type SavedView struct {
|
||||
bun.BaseModel `bun:"table:saved_views"`
|
||||
var (
|
||||
ErrCodeSavedViewInvalidInput = errors.MustNewCode("saved_view_invalid_input")
|
||||
ErrCodeSavedViewNotFound = errors.MustNewCode("saved_view_not_found")
|
||||
)
|
||||
|
||||
type GettableSavedView struct {
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
CreatedAt time.Time `json:"createdAt" required:"true"`
|
||||
CreatedBy string `json:"createdBy" required:"true"`
|
||||
UpdatedAt time.Time `json:"updatedAt" required:"true"`
|
||||
UpdatedBy string `json:"updatedBy" required:"true"`
|
||||
SourcePage SourcePage `json:"sourcePage" required:"true"`
|
||||
SavedViewData
|
||||
}
|
||||
|
||||
type PostableSavedView struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
SourcePage SourcePage `json:"sourcePage" required:"true"`
|
||||
SavedViewData
|
||||
}
|
||||
|
||||
type UpdatableSavedView = PostableSavedView
|
||||
|
||||
type ListSavedViewsParams struct {
|
||||
SourcePage SourcePage `query:"sourcePage"`
|
||||
Name string `query:"name"`
|
||||
}
|
||||
|
||||
// StorableSavedView has schemaVersion + spec stored JSON-encoded in Data.
|
||||
type StorableSavedView struct {
|
||||
bun.BaseModel `bun:"table:saved_view"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
OrgID string `json:"orgId" bun:"org_id,notnull"`
|
||||
Name string `json:"name" bun:"name,type:text,notnull"`
|
||||
Category string `json:"category" bun:"category,type:text,notnull"`
|
||||
SourcePage string `json:"sourcePage" bun:"source_page,type:text,notnull"`
|
||||
Tags string `json:"tags" bun:"tags,type:text"`
|
||||
Data string `json:"data" bun:"data,type:text,notnull"`
|
||||
ExtraData string `json:"extraData" bun:"extra_data,type:text"`
|
||||
OrgID string `json:"orgId" bun:"org_id,notnull"`
|
||||
Name string `json:"name" bun:"name,type:text,notnull"`
|
||||
SourcePage SourcePage `json:"sourcePage" bun:"source_page,type:text,notnull"`
|
||||
Data SavedViewData `json:"data" bun:"data,type:text,notnull"`
|
||||
}
|
||||
|
||||
func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
|
||||
type SourcePage struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
SourcePageTraces = SourcePage{valuer.NewString("traces")}
|
||||
SourcePageLogs = SourcePage{valuer.NewString("logs")}
|
||||
SourcePageMetrics = SourcePage{valuer.NewString("metrics")}
|
||||
SourcePageMeter = SourcePage{valuer.NewString("meter")}
|
||||
)
|
||||
|
||||
func (SourcePage) Enum() []any {
|
||||
return []any{
|
||||
SourcePageTraces,
|
||||
SourcePageLogs,
|
||||
SourcePageMetrics,
|
||||
SourcePageMeter,
|
||||
}
|
||||
}
|
||||
|
||||
func (s SourcePage) Validate() error {
|
||||
switch s {
|
||||
case SourcePageTraces, SourcePageLogs, SourcePageMetrics, SourcePageMeter:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid source page: %s", s.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PostableSavedView) Validate() error {
|
||||
if err := p.SourcePage.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return p.SavedViewData.Validate()
|
||||
}
|
||||
|
||||
func (p *ListSavedViewsParams) Validate() error {
|
||||
if p.SourcePage.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.SourcePage.Validate()
|
||||
}
|
||||
|
||||
func NewStorableSavedView(orgID string, createdBy string, updatedBy string, view PostableSavedView) *StorableSavedView {
|
||||
now := time.Now()
|
||||
return &StorableSavedView{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: updatedBy},
|
||||
OrgID: orgID,
|
||||
Name: view.Name,
|
||||
SourcePage: view.SourcePage,
|
||||
Data: view.SavedViewData,
|
||||
}
|
||||
}
|
||||
|
||||
func NewGettableSavedViewFromStorable(view *StorableSavedView) *GettableSavedView {
|
||||
data := view.Data
|
||||
if data.Spec.SelectedFields == nil {
|
||||
data.Spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
|
||||
}
|
||||
|
||||
return &GettableSavedView{
|
||||
ID: view.ID,
|
||||
Name: view.Name,
|
||||
CreatedAt: view.CreatedAt,
|
||||
CreatedBy: view.CreatedBy,
|
||||
UpdatedAt: view.UpdatedAt,
|
||||
UpdatedBy: view.UpdatedBy,
|
||||
SourcePage: view.SourcePage,
|
||||
SavedViewData: data,
|
||||
}
|
||||
}
|
||||
|
||||
func NewGettableSavedViewsFromStorable(views []*StorableSavedView) []*GettableSavedView {
|
||||
out := make([]*GettableSavedView, 0, len(views))
|
||||
for _, view := range views {
|
||||
out = append(out, NewGettableSavedViewFromStorable(view))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func NewStatsFromSavedViews(savedViews []*StorableSavedView) map[string]any {
|
||||
stats := make(map[string]any)
|
||||
for _, savedView := range savedViews {
|
||||
key := "savedview.source." + strings.ToLower(string(savedView.SourcePage)) + ".count"
|
||||
key := "savedview.source." + strings.ToLower(savedView.SourcePage.StringValue()) + ".count"
|
||||
if _, ok := stats[key]; !ok {
|
||||
stats[key] = int64(1)
|
||||
} else {
|
||||
|
||||
178
pkg/types/savedviewtypes/savedview_test.go
Normal file
178
pkg/types/savedviewtypes/savedview_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validPostableSavedView() PostableSavedView {
|
||||
return PostableSavedView{
|
||||
Name: "my view",
|
||||
SourcePage: SourcePageLogs,
|
||||
SavedViewData: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcePageValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sourcePage SourcePage
|
||||
expectError bool
|
||||
}{
|
||||
{name: "traces", sourcePage: SourcePageTraces},
|
||||
{name: "logs", sourcePage: SourcePageLogs},
|
||||
{name: "metrics", sourcePage: SourcePageMetrics},
|
||||
{name: "meter", sourcePage: SourcePageMeter},
|
||||
{name: "unknown is rejected", sourcePage: SourcePage{valuer.NewString("bogus")}, expectError: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.sourcePage.Validate()
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostableSavedViewValidate(t *testing.T) {
|
||||
t.Run("valid view", func(t *testing.T) {
|
||||
view := validPostableSavedView()
|
||||
assert.NoError(t, view.Validate())
|
||||
})
|
||||
|
||||
t.Run("invalid source page is rejected", func(t *testing.T) {
|
||||
view := validPostableSavedView()
|
||||
view.SourcePage = SourcePage{valuer.NewString("bogus")}
|
||||
assert.Error(t, view.Validate())
|
||||
})
|
||||
|
||||
t.Run("invalid saved view data is rejected", func(t *testing.T) {
|
||||
view := validPostableSavedView()
|
||||
view.SchemaVersion = "v1"
|
||||
assert.Error(t, view.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestListSavedViewsParamsValidate(t *testing.T) {
|
||||
t.Run("zero source page is allowed", func(t *testing.T) {
|
||||
params := ListSavedViewsParams{}
|
||||
assert.NoError(t, params.Validate())
|
||||
})
|
||||
|
||||
t.Run("valid source page is allowed", func(t *testing.T) {
|
||||
params := ListSavedViewsParams{SourcePage: SourcePageLogs}
|
||||
assert.NoError(t, params.Validate())
|
||||
})
|
||||
|
||||
t.Run("invalid source page is rejected", func(t *testing.T) {
|
||||
params := ListSavedViewsParams{SourcePage: SourcePage{valuer.NewString("bogus")}}
|
||||
assert.Error(t, params.Validate())
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewStorableSavedView(t *testing.T) {
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
view := validPostableSavedView()
|
||||
|
||||
storable := NewStorableSavedView(orgID, "creator@signoz.io", "updater@signoz.io", view)
|
||||
|
||||
assert.False(t, storable.ID.IsZero())
|
||||
assert.Equal(t, orgID, storable.OrgID)
|
||||
assert.Equal(t, "creator@signoz.io", storable.CreatedBy)
|
||||
assert.Equal(t, "updater@signoz.io", storable.UpdatedBy)
|
||||
assert.Equal(t, view.Name, storable.Name)
|
||||
assert.Equal(t, view.SourcePage, storable.SourcePage)
|
||||
assert.Equal(t, view.SavedViewData, storable.Data)
|
||||
assert.False(t, storable.CreatedAt.IsZero())
|
||||
assert.Equal(t, storable.CreatedAt, storable.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestNewGettableSavedViewFromStorable(t *testing.T) {
|
||||
t.Run("nil selected fields are normalized to an empty slice", func(t *testing.T) {
|
||||
storable := &StorableSavedView{
|
||||
Name: "my view",
|
||||
SourcePage: SourcePageLogs,
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries(), SelectedFields: nil},
|
||||
},
|
||||
}
|
||||
|
||||
gettable := NewGettableSavedViewFromStorable(storable)
|
||||
|
||||
require.NotNil(t, gettable.Spec.SelectedFields)
|
||||
assert.Empty(t, gettable.Spec.SelectedFields)
|
||||
})
|
||||
|
||||
t.Run("existing selected fields are preserved", func(t *testing.T) {
|
||||
fields := []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}
|
||||
storable := &StorableSavedView{
|
||||
Name: "my view",
|
||||
SourcePage: SourcePageLogs,
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries(), SelectedFields: fields},
|
||||
},
|
||||
}
|
||||
|
||||
gettable := NewGettableSavedViewFromStorable(storable)
|
||||
|
||||
assert.Equal(t, fields, gettable.Spec.SelectedFields)
|
||||
})
|
||||
|
||||
t.Run("all fields carried over", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
storable := &StorableSavedView{
|
||||
Name: "my view",
|
||||
SourcePage: SourcePageTraces,
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{PanelType: PanelTypeTable, Queries: validQueries()},
|
||||
},
|
||||
}
|
||||
storable.ID = valuer.GenerateUUID()
|
||||
storable.CreatedAt = now
|
||||
storable.UpdatedAt = now
|
||||
storable.CreatedBy = "creator@signoz.io"
|
||||
storable.UpdatedBy = "updater@signoz.io"
|
||||
|
||||
gettable := NewGettableSavedViewFromStorable(storable)
|
||||
|
||||
assert.Equal(t, storable.ID, gettable.ID)
|
||||
assert.Equal(t, storable.Name, gettable.Name)
|
||||
assert.Equal(t, storable.CreatedAt, gettable.CreatedAt)
|
||||
assert.Equal(t, storable.CreatedBy, gettable.CreatedBy)
|
||||
assert.Equal(t, storable.UpdatedAt, gettable.UpdatedAt)
|
||||
assert.Equal(t, storable.UpdatedBy, gettable.UpdatedBy)
|
||||
assert.Equal(t, storable.SourcePage, gettable.SourcePage)
|
||||
assert.Equal(t, storable.Data.SchemaVersion, gettable.SchemaVersion)
|
||||
assert.Equal(t, storable.Data.Spec.PanelType, gettable.Spec.PanelType)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewStatsFromSavedViews(t *testing.T) {
|
||||
views := []*StorableSavedView{
|
||||
{SourcePage: SourcePageLogs},
|
||||
{SourcePage: SourcePageLogs},
|
||||
{SourcePage: SourcePageTraces},
|
||||
}
|
||||
|
||||
stats := NewStatsFromSavedViews(views)
|
||||
|
||||
assert.Equal(t, int64(3), stats["savedview.count"])
|
||||
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
|
||||
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
|
||||
assert.NotContains(t, stats, "savedview.source.metrics.count")
|
||||
}
|
||||
81
pkg/types/savedviewtypes/spec.go
Normal file
81
pkg/types/savedviewtypes/spec.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// SavedViewSchemaVersion is the only schemaVersion currently.
|
||||
const SavedViewSchemaVersion = "v2"
|
||||
|
||||
// Display holds view-rendering preferences.
|
||||
type Display struct {
|
||||
MaxLines int `json:"maxLines"`
|
||||
FontSize string `json:"fontSize"`
|
||||
Format string `json:"format"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// SavedViewSpec is the typed content of a saved view, mirroring the dashboardtypes v2 spec pattern.
|
||||
type SavedViewSpec struct {
|
||||
PanelType PanelType `json:"panelType" required:"true"`
|
||||
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false"`
|
||||
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" required:"true" nullable:"false"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
}
|
||||
|
||||
// SavedViewData is what's persisted as saved view data.
|
||||
type SavedViewData struct {
|
||||
SchemaVersion string `json:"schemaVersion" required:"true"`
|
||||
Spec SavedViewSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// PanelType is the explore-page panel a saved view renders as.
|
||||
type PanelType struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
var (
|
||||
PanelTypeValue = PanelType{valuer.NewString("value")}
|
||||
PanelTypeGraph = PanelType{valuer.NewString("graph")}
|
||||
PanelTypeTable = PanelType{valuer.NewString("table")}
|
||||
PanelTypeList = PanelType{valuer.NewString("list")}
|
||||
PanelTypeTrace = PanelType{valuer.NewString("trace")}
|
||||
)
|
||||
|
||||
func (PanelType) Enum() []any {
|
||||
return []any{
|
||||
PanelTypeValue,
|
||||
PanelTypeGraph,
|
||||
PanelTypeTable,
|
||||
PanelTypeList,
|
||||
PanelTypeTrace,
|
||||
}
|
||||
}
|
||||
|
||||
func (p PanelType) Validate() error {
|
||||
switch p {
|
||||
case PanelTypeValue, PanelTypeGraph, PanelTypeTable, PanelTypeList, PanelTypeTrace:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid panel type: %s", p.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SavedViewSpec) Validate() error {
|
||||
if err := s.PanelType.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate()
|
||||
}
|
||||
|
||||
func (d *SavedViewData) Validate() error {
|
||||
if d.SchemaVersion != SavedViewSchemaVersion {
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion, d.SchemaVersion)
|
||||
}
|
||||
|
||||
return d.Spec.Validate()
|
||||
}
|
||||
134
pkg/types/savedviewtypes/spec_test.go
Normal file
134
pkg/types/savedviewtypes/spec_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func validQueries() []qbtypes.QueryEnvelope {
|
||||
return []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelTypeValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
panelType PanelType
|
||||
expectError bool
|
||||
}{
|
||||
{name: "value", panelType: PanelTypeValue},
|
||||
{name: "graph", panelType: PanelTypeGraph},
|
||||
{name: "table", panelType: PanelTypeTable},
|
||||
{name: "list", panelType: PanelTypeList},
|
||||
{name: "trace", panelType: PanelTypeTrace},
|
||||
{name: "unknown is rejected", panelType: PanelType{valuer.NewString("bogus")}, expectError: true},
|
||||
{name: "empty is rejected", panelType: PanelType{}, expectError: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.panelType.Validate()
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedViewSpecValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
spec SavedViewSpec
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid spec",
|
||||
spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid panel type is rejected before queries are checked",
|
||||
spec: SavedViewSpec{PanelType: PanelType{valuer.NewString("bogus")}, Queries: validQueries()},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no queries is rejected",
|
||||
spec: SavedViewSpec{PanelType: PanelTypeGraph},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "selected fields and display are not required",
|
||||
spec: SavedViewSpec{
|
||||
PanelType: PanelTypeTable,
|
||||
Queries: validQueries(),
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
|
||||
Display: Display{MaxLines: 3, FontSize: "small", Format: "table", Color: "blue"},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.spec.Validate()
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedViewDataValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data SavedViewData
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid data",
|
||||
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "wrong schema version is rejected",
|
||||
data: SavedViewData{SchemaVersion: "v1", Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty schema version is rejected",
|
||||
data: SavedViewData{Spec: SavedViewSpec{PanelType: PanelTypeGraph, Queries: validQueries()}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid spec is rejected",
|
||||
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{PanelType: PanelTypeGraph}},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.data.Validate()
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
import type { Browser } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createMetricAlertViaApi,
|
||||
createNoDataAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
readTimelineTotal,
|
||||
seedAlertHistoryLogs,
|
||||
seedAlertHistoryMetrics,
|
||||
setRuleDisabledViaApi,
|
||||
waitForTimelineEntries,
|
||||
waitForTimelineStates,
|
||||
} from '../helpers/alerts';
|
||||
import { expect, test as base, withAdminPage } from './alert-rules';
|
||||
|
||||
// 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 the logs fixtures, ~10s for metrics, ~105s for
|
||||
// the firing→resolved wave. 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.
|
||||
//
|
||||
// See `tests/e2e/specs/alerts/alerts-e2e-coverage.md` §3 for the recipes and the
|
||||
// empirically-measured timings each budget here is derived from.
|
||||
|
||||
/** Distinct `service.name` values SEED-A seeds ⇒ its timeline row count. */
|
||||
const SEED_A_SERVICES = 25;
|
||||
|
||||
/** SEED-F seeds fewer services and a 1m window so it resolves inside ~105s. */
|
||||
const SEED_F_SERVICES = 3;
|
||||
|
||||
/** SEED-E's group-by values ⇒ a 2-row history that fits on one page. */
|
||||
const SEED_E_HOSTS = ['host-0', 'host-1'];
|
||||
|
||||
/** Non-severity label on SEED-C, so the v1 header's labels row is non-empty. */
|
||||
export const SEED_C_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 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const test = base.extend<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
{},
|
||||
{
|
||||
alertHistory: AlertHistorySeed;
|
||||
metricsHistory: MetricsHistorySeed;
|
||||
resolvedHistory: ResolvedHistorySeed;
|
||||
noDataHistory: NoDataHistorySeed;
|
||||
emptyHistory: EmptyHistorySeed;
|
||||
}
|
||||
>({
|
||||
/**
|
||||
* SEED-A (25-row firing history, v2) **plus** SEED-C (the same logs seen
|
||||
* through a legacy v1 rule). Both rules share one seeded log batch, so the
|
||||
* two ruler waves overlap and the fixture costs roughly one wait, not two.
|
||||
*/
|
||||
alertHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert history ${stamp}`;
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
let ruleIdV1 = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
|
||||
channelId = channel.id;
|
||||
|
||||
// Seed and create in the same breath: the rules only fire while the
|
||||
// records are inside the 5m eval window.
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: SEED_A_SERVICES,
|
||||
servicePrefix: `e2e-ah-svc`,
|
||||
});
|
||||
|
||||
ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v2-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v2',
|
||||
});
|
||||
ruleIdV1 = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v1-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v1',
|
||||
// The v1 header renders `labels` minus `severity`, so without a
|
||||
// second label its labels row is present but empty (AD-02).
|
||||
extraLabels: { team: SEED_C_TEAM_LABEL },
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: SEED_A_SERVICES });
|
||||
await waitForTimelineEntries(page, ruleIdV1, { min: SEED_A_SERVICES });
|
||||
|
||||
// Freeze both before the eval window rolls past the seeded records —
|
||||
// otherwise the resolve wave doubles `total` mid-suite.
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
await setRuleDisabledViaApi(page, ruleIdV1, true);
|
||||
|
||||
return {
|
||||
ruleId,
|
||||
ruleIdV1,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
totalV1: await readTimelineTotal(page, ruleIdV1),
|
||||
};
|
||||
});
|
||||
|
||||
if (seed.total !== SEED_A_SERVICES) {
|
||||
// A different total means the fixture is not what the scenarios were
|
||||
// written against — most likely the resolve wave landed before the
|
||||
// PATCH froze the rule. Fail loudly here rather than let every
|
||||
// downstream count assertion fail with a confusing off-by-N.
|
||||
throw new Error(
|
||||
`SEED-A expected ${SEED_A_SERVICES} timeline rows, got ${seed.total}`,
|
||||
);
|
||||
}
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId, ruleIdV1], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 240_000 },
|
||||
],
|
||||
|
||||
/**
|
||||
* SEED-E — a metrics rule over two hosts. Two things SEED-A can't give:
|
||||
* history rows with **no** related links (links are derived from the rule's
|
||||
* signal), and a 2-row history that fits on a single page.
|
||||
*/
|
||||
metricsHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
const metricName = `e2e_ah_probe_metric_${stamp}`;
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-metrics-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
|
||||
await seedAlertHistoryMetrics(page, {
|
||||
metricName,
|
||||
hosts: SEED_E_HOSTS,
|
||||
});
|
||||
|
||||
ruleId = await createMetricAlertViaApi(page, {
|
||||
name: `e2e-ah-metrics-rule-${stamp}`,
|
||||
metricName,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: SEED_E_HOSTS.length,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
metricName,
|
||||
hosts: SEED_E_HOSTS,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
};
|
||||
});
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 240_000 },
|
||||
],
|
||||
|
||||
/**
|
||||
* SEED-F — firing **and** resolved, without touching the seeder: a 1m eval
|
||||
* window means the seeded records fall out of it fast, so the rule resolves
|
||||
* on its own in ~105s. This is the only fixture that produces a non-zero
|
||||
* average resolution time and a 3-segment overall-status graph.
|
||||
*/
|
||||
resolvedHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert resolved ${stamp}`;
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-resolved-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: SEED_F_SERVICES,
|
||||
ageSeconds: 40,
|
||||
minAgeSeconds: 28,
|
||||
servicePrefix: 'e2e-ahr-svc',
|
||||
});
|
||||
|
||||
ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-resolved-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
evalWindow: '1m0s',
|
||||
});
|
||||
|
||||
const timeline = await waitForTimelineStates(page, ruleId, {
|
||||
states: {
|
||||
firing: SEED_F_SERVICES,
|
||||
inactive: SEED_F_SERVICES,
|
||||
},
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
|
||||
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
|
||||
};
|
||||
});
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 300_000 },
|
||||
],
|
||||
|
||||
/**
|
||||
* SEED-G — a `nodata` row, reached the same way
|
||||
* `integration/testdata/alerts/test_scenarios/no_data_rule_test` does:
|
||||
* `alertOnAbsent` on a query that matches nothing.
|
||||
*/
|
||||
noDataHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-nodata-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
|
||||
ruleId = await createNoDataAlertViaApi(page, {
|
||||
name: `e2e-ah-nodata-rule-${stamp}`,
|
||||
// Deliberately unseeded — the query must match nothing.
|
||||
marker: `e2e alert nodata ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: 1,
|
||||
state: 'nodata',
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return { ruleId, channelName: channel.name };
|
||||
});
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 300_000 },
|
||||
],
|
||||
|
||||
/**
|
||||
* A rule that will never have history: its query matches nothing and it is
|
||||
* disabled immediately. Covers "no history yet" (empty table, zero stats) and
|
||||
* "no key suggestions" without waiting on the ruler at all.
|
||||
*/
|
||||
emptyHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-empty-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
|
||||
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 { ruleId, channelName: channel.name };
|
||||
});
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 120_000 },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,201 +0,0 @@
|
||||
import type { Browser, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
type AlertSchema,
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createThresholdAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
seedAlertRules,
|
||||
type ThresholdAlertSeed,
|
||||
} from '../helpers/alerts';
|
||||
import { newAdminContext } from '../helpers/auth';
|
||||
import { expect, test as base } from './auth';
|
||||
|
||||
// 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 `fixtures/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. SEED-B, the 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.
|
||||
*/
|
||||
logs(options: {
|
||||
name: string;
|
||||
schema?: AlertSchema;
|
||||
marker?: string;
|
||||
}): 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>;
|
||||
}
|
||||
|
||||
/** SEED-B size. 12 over a pinned page size of 10 ⇒ a short second page. */
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const test = base.extend<
|
||||
{ ownedRules: OwnedRules },
|
||||
{ alertChannel: AlertChannel; alertList: AlertListSeed }
|
||||
>({
|
||||
alertChannel: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const channel = await withAdminPage(browser, (page) =>
|
||||
createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-alerts-ch-w${workerInfo.workerIndex}-${Date.now()}`,
|
||||
),
|
||||
);
|
||||
|
||||
await use(channel);
|
||||
|
||||
await withAdminPage(browser, (page) =>
|
||||
deleteChannelViaApi(page, channel.id),
|
||||
);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
alertList: [
|
||||
async ({ browser, alertChannel }, use, workerInfo) => {
|
||||
const stamp = `w${workerInfo.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: alertChannel.name,
|
||||
namePrefix,
|
||||
teamSuffix,
|
||||
}),
|
||||
);
|
||||
|
||||
await use({
|
||||
channelName: alertChannel.name,
|
||||
namePrefix,
|
||||
count: LIST_SEED_COUNT,
|
||||
paymentsLabel: `payments${teamSuffix}`,
|
||||
ruleIds,
|
||||
});
|
||||
|
||||
await deleteRules(browser, ruleIds);
|
||||
},
|
||||
{ scope: 'worker', timeout: 120_000 },
|
||||
],
|
||||
|
||||
ownedRules: async ({ browser, alertChannel }, use) => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
const seed = async (
|
||||
create: (page: Page) => Promise<string>,
|
||||
): Promise<string> => {
|
||||
const id = await withAdminPage(browser, create);
|
||||
ids.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
await use({
|
||||
threshold: (name, overrides = {}) =>
|
||||
seed((page) =>
|
||||
createThresholdAlertViaApi(page, {
|
||||
name,
|
||||
target: 42,
|
||||
channels: [alertChannel.name],
|
||||
labels: { severity: 'critical' },
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
logs: ({ name, schema = 'v2', marker }) =>
|
||||
seed((page) =>
|
||||
createLogsAlertViaApi(page, {
|
||||
name,
|
||||
marker: marker ?? `e2e alert never seeded ${name}`,
|
||||
channels: [alertChannel.name],
|
||||
schema,
|
||||
}),
|
||||
),
|
||||
|
||||
register: async (response) => {
|
||||
const body = (await response.json()) as { data?: { id?: string } };
|
||||
const id = body.data?.id;
|
||||
if (id) {
|
||||
ids.add(String(id));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Best-effort: `deleteAlertViaApi` tolerates a rule a scenario already
|
||||
// deleted through the UI.
|
||||
await deleteRules(browser, [...ids]);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,11 +1,81 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
test as base,
|
||||
expect,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
// 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 };
|
||||
// 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()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<{
|
||||
/**
|
||||
@@ -25,7 +95,7 @@ export const test = base.extend<{
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
authedPage: async ({ browser, user }, use) => {
|
||||
const storageState = await storageStateFor(browser, user);
|
||||
const storageState = await storageFor(browser, user);
|
||||
const ctx = await browser.newContext({ storageState });
|
||||
const page = await ctx.newPage();
|
||||
// Opt-in CPU throttling to reproduce GitHub-Linux-runner conditions on
|
||||
|
||||
@@ -1,34 +1,11 @@
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
} from '@playwright/test';
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import { authToken, requestUrl, seederUrl } from './common';
|
||||
import { typeExpression } from './query-builder';
|
||||
import { authToken } from './common';
|
||||
|
||||
// ─── 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;
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,11 +19,6 @@ export interface ThresholdAlertSeed {
|
||||
* required by the API — seed one with {@link createEmailChannelViaApi}.
|
||||
*/
|
||||
channels: string[];
|
||||
/**
|
||||
* Rule labels. `severity` drives the list's Severity column and is one of
|
||||
* the things its search box matches on, so list specs set it explicitly.
|
||||
*/
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ─── Payload ─────────────────────────────────────────────────────────────
|
||||
@@ -58,7 +30,6 @@ function buildThresholdRulePayload({
|
||||
name,
|
||||
target,
|
||||
channels,
|
||||
labels,
|
||||
}: ThresholdAlertSeed): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
@@ -68,7 +39,6 @@ function buildThresholdRulePayload({
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
...(labels ? { labels } : {}),
|
||||
annotations: {
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
@@ -216,926 +186,3 @@ export async function gotoAlertOverview(
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Alert history fixtures
|
||||
//
|
||||
// There is no seeder endpoint that writes `rule_state_history_v0`, so every
|
||||
// history row here comes from the ruler actually evaluating a rule: seed
|
||||
// telemetry, create a rule whose query matches it, then poll the timeline until
|
||||
// the firing wave lands. See `tests/e2e/specs/alerts/alerts-e2e-coverage.md` §3.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Rule schema flavour. `v1` is the legacy payload posted to `/api/v1/rules`. */
|
||||
export type AlertSchema = 'v1' | 'v2';
|
||||
|
||||
export interface LogsAlertSeed {
|
||||
name: string;
|
||||
/** Substring the rule matches on (`body CONTAINS '<marker>'`). */
|
||||
marker: string;
|
||||
/** Channel *names* (not ids) — the API validates the reference. */
|
||||
channels: string[];
|
||||
schema?: AlertSchema;
|
||||
/** Go duration, e.g. `5m0s`. Shrink it to make the rule resolve fast. */
|
||||
evalWindow?: string;
|
||||
frequency?: string;
|
||||
/** Becomes the history `threshold.name` for v1 rules (`processRuleDefaults`). */
|
||||
severity?: string;
|
||||
/**
|
||||
* Extra rule labels merged alongside `severity`. They show up in the details
|
||||
* header's labels row (which renders `labels` minus `severity`) *and* as extra
|
||||
* history `filter_keys`, so add them only where a scenario needs them.
|
||||
*/
|
||||
extraLabels?: Record<string, string>;
|
||||
/** `condition.alertOnAbsent` — the only route to a `nodata` row. */
|
||||
alertOnAbsent?: boolean;
|
||||
/** `condition.absentFor`, in minutes. */
|
||||
absentFor?: number;
|
||||
}
|
||||
|
||||
export interface MetricAlertSeed {
|
||||
name: string;
|
||||
metricName: string;
|
||||
channels: string[];
|
||||
/** Attribute the history rows group by. Defaults to `host`. */
|
||||
groupByKey?: string;
|
||||
evalWindow?: string;
|
||||
frequency?: string;
|
||||
}
|
||||
|
||||
export interface LogsSeedOptions {
|
||||
marker: string;
|
||||
/** Number of distinct `service.name` values ⇒ number of timeline rows. */
|
||||
services: number;
|
||||
recordsPerService?: number;
|
||||
/** Oldest record age in seconds; records spread from here up to `minAgeSeconds`. */
|
||||
ageSeconds?: number;
|
||||
minAgeSeconds?: number;
|
||||
/** Prefix for the generated `service.name` values. */
|
||||
servicePrefix?: string;
|
||||
}
|
||||
|
||||
export interface MetricsSeedOptions {
|
||||
metricName: string;
|
||||
/** Distinct attribute values ⇒ number of timeline rows. */
|
||||
hosts: string[];
|
||||
pointsPerHost?: number;
|
||||
groupByKey?: string;
|
||||
}
|
||||
|
||||
/** One row of `GET /api/v2/rules/{id}/history/timeline`. */
|
||||
export interface TimelineItem {
|
||||
state: string;
|
||||
unixMilli: number;
|
||||
fingerprint: string;
|
||||
value: number;
|
||||
labels: {
|
||||
key?: { name?: string };
|
||||
value?: string | number | boolean | null;
|
||||
}[];
|
||||
relatedLogsLink?: string;
|
||||
relatedTracesLink?: string;
|
||||
}
|
||||
|
||||
export interface TimelineResponse {
|
||||
items: TimelineItem[];
|
||||
total: number;
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
// ─── 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}})',
|
||||
};
|
||||
|
||||
// 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.
|
||||
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: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
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`.
|
||||
function v1RulePayload({
|
||||
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',
|
||||
disabled: false,
|
||||
source: '',
|
||||
evalWindow,
|
||||
frequency,
|
||||
preferredChannels: channels,
|
||||
labels: { severity, ...extraLabels },
|
||||
annotations: ANNOTATIONS,
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
op: '1',
|
||||
target: 0,
|
||||
matchType: '1',
|
||||
compositeQuery,
|
||||
...extraCondition,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Seeding telemetry ───────────────────────────────────────────────────
|
||||
|
||||
async function postToSeeder(
|
||||
page: Page,
|
||||
path: string,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
const url = `${seederUrl()}${path}`;
|
||||
// The seeder shares one ClickHouse client, so concurrent POSTs from parallel
|
||||
// workers collide with a transient 500 "concurrent queries within the same
|
||||
// session". Retry those; anything else is real.
|
||||
const maxAttempts = 6;
|
||||
let lastStatus = 0;
|
||||
let lastText = '';
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await page.request.post(url, {
|
||||
data,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (res.ok()) {
|
||||
return;
|
||||
}
|
||||
lastStatus = res.status();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
lastText = await res.text();
|
||||
if (!(lastStatus === 500 && lastText.includes('concurrent'))) {
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 150 * (attempt + 1) + Math.floor(Math.random() * 100));
|
||||
});
|
||||
}
|
||||
throw new Error(`seeder POST ${path} ${lastStatus}: ${lastText}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed log records the history rules match on. Returns the generated
|
||||
* `service.name` values, which become the timeline rows' `groupBy` labels
|
||||
* (N distinct services ⇒ N distinct fingerprints ⇒ N timeline rows).
|
||||
*
|
||||
* Seed these **immediately** before creating the rule: the rule only fires
|
||||
* while the records are still inside its eval window, and a stale marker
|
||||
* silently never fires.
|
||||
*/
|
||||
export async function seedAlertHistoryLogs(
|
||||
page: Page,
|
||||
{
|
||||
marker,
|
||||
services,
|
||||
recordsPerService = 2,
|
||||
ageSeconds = 150,
|
||||
minAgeSeconds = 30,
|
||||
servicePrefix = 'e2e-ah-svc',
|
||||
}: LogsSeedOptions,
|
||||
): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
const span = Math.max(ageSeconds - minAgeSeconds, 1);
|
||||
const serviceNames: string[] = [];
|
||||
const records: Record<string, unknown>[] = [];
|
||||
|
||||
for (let i = 0; i < services; i += 1) {
|
||||
const service = `${servicePrefix}-${i}`;
|
||||
serviceNames.push(service);
|
||||
for (let r = 0; r < recordsPerService; r += 1) {
|
||||
const fraction =
|
||||
(i * recordsPerService + r) / (services * recordsPerService);
|
||||
const offset = ageSeconds - Math.floor(fraction * span);
|
||||
records.push({
|
||||
timestamp: new Date(now - offset * 1000).toISOString(),
|
||||
body: marker,
|
||||
resources: { 'service.name': service },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await postToSeeder(page, '/telemetry/logs', records);
|
||||
return serviceNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a throwaway gauge the metrics rule alerts on. Cheaper than the logs
|
||||
* fixture (~10s to fire) and its history rows carry neither `relatedLogsLink`
|
||||
* nor `relatedTracesLink` — the "no links available" case.
|
||||
*/
|
||||
export async function seedAlertHistoryMetrics(
|
||||
page: Page,
|
||||
{
|
||||
metricName,
|
||||
hosts,
|
||||
pointsPerHost = 3,
|
||||
groupByKey = 'host',
|
||||
}: MetricsSeedOptions,
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
const points: Record<string, unknown>[] = [];
|
||||
for (const host of hosts) {
|
||||
for (let p = 0; p < pointsPerHost; p += 1) {
|
||||
points.push({
|
||||
metric_name: metricName,
|
||||
labels: { [groupByKey]: host },
|
||||
timestamp: new Date(now - (pointsPerHost - p) * 20 * 1000).toISOString(),
|
||||
value: 10 + p,
|
||||
type_: 'Gauge',
|
||||
temporality: 'Unspecified',
|
||||
is_monotonic: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
await postToSeeder(page, '/telemetry/metrics', points);
|
||||
}
|
||||
|
||||
// ─── Rule creation ───────────────────────────────────────────────────────
|
||||
|
||||
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,
|
||||
}: 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,
|
||||
};
|
||||
return postRule(
|
||||
page,
|
||||
schema,
|
||||
schema === 'v1' ? v1RulePayload(args) : v2RulePayload(args),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Severities SEED-B cycles through, so list search/sort has more than one value. */
|
||||
export const SEED_B_SEVERITIES = ['critical', 'warning', 'info'] as const;
|
||||
|
||||
export interface AlertRulesSeedOptions {
|
||||
count: number;
|
||||
channelName: string;
|
||||
/** Rules are named `<namePrefix>-NN`. Keep it unique per batch. */
|
||||
namePrefix?: string;
|
||||
/**
|
||||
* Appended to both `team` label values. Every list spec seeds its own batch
|
||||
* and they run in parallel, so a bare `team: payments` would also match the
|
||||
* neighbouring batches — which is exactly what the label-search scenario
|
||||
* counts. Leave it empty only when nothing asserts an exact label count.
|
||||
*/
|
||||
teamSuffix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEED-B: `count` metric threshold rules sharing one channel. Severities cycle
|
||||
* through {@link SEED_B_SEVERITIES} and every rule carries a `team` label, so
|
||||
* the list's "Alert Name, Severity and Labels" search has hits *and* misses for
|
||||
* all three. Even-indexed rules are `platform`, odd ones `payments` — i.e. half
|
||||
* the batch each. Returns the ids in creation order.
|
||||
*/
|
||||
export async function seedAlertRules(
|
||||
page: Page,
|
||||
{
|
||||
count,
|
||||
channelName,
|
||||
namePrefix = 'e2e-alert-list',
|
||||
teamSuffix = '',
|
||||
}: AlertRulesSeedOptions,
|
||||
): Promise<string[]> {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
// Sequential on purpose: the rules API is not the thing under test and
|
||||
// parallel POSTs make failures harder to attribute.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const id = await createThresholdAlertViaApi(page, {
|
||||
name: `${namePrefix}-${String(i).padStart(2, '0')}`,
|
||||
target: 100 + i,
|
||||
channels: [channelName],
|
||||
labels: {
|
||||
severity: SEED_B_SEVERITIES[i % SEED_B_SEVERITIES.length],
|
||||
team: `${i % 2 === 0 ? 'platform' : 'payments'}${teamSuffix}`,
|
||||
},
|
||||
});
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ─── 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 = 90_000,
|
||||
}: { 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 = 180_000,
|
||||
}: { 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();
|
||||
}
|
||||
|
||||
// ─── 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(),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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();
|
||||
}
|
||||
|
||||
@@ -1,123 +1,34 @@
|
||||
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
/** 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!,
|
||||
};
|
||||
import type { Browser, BrowserContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* `browser.newContext()` only inherits `use.baseURL` while a *test* is in
|
||||
* scope. Worker-scoped fixtures (and their teardown) run outside that, where a
|
||||
* relative `page.goto('/login')` fails with "Cannot navigate to invalid URL" —
|
||||
* so pass it explicitly whenever we know it. Left empty when the var is unset
|
||||
* so the config's staging default still applies inside a test.
|
||||
*/
|
||||
const contextDefaults: { baseURL?: string } = process.env.SIGNOZ_E2E_BASE_URL
|
||||
? { baseURL: process.env.SIGNOZ_E2E_BASE_URL }
|
||||
: {};
|
||||
|
||||
// Per-worker storageState cache. One UI login per unique user per worker
|
||||
// process, shared by everything in that worker: the `authedPage` fixture, the
|
||||
// worker-scoped seed fixtures, and their teardown. Promise-valued so concurrent
|
||||
// callers await the same in-flight login rather than racing several of their
|
||||
// own. Held in memory only — no .auth/ dir, no JSON on disk.
|
||||
//
|
||||
// This cache is why `newAdminContext` is cheap. It used to log in through the
|
||||
// UI on every call, and the alerts fixtures call it a dozen-plus times per
|
||||
// worker (channel, rule list, five history seeds, one per owned rule, plus a
|
||||
// teardown for each) — a couple of seconds each, paid over and over for a
|
||||
// session that never changes.
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
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()) {
|
||||
const text = await res.text();
|
||||
// Two workers logging in at the same moment both insert the preference and
|
||||
// the loser gets a 500 on `uq_user_preference_name_user_id`. The write it
|
||||
// lost to set the same value, so the preference *is* pinned — treat the
|
||||
// duplicate as success rather than failing an unrelated test.
|
||||
if (text.includes('uq_user_preference_name_user_id')) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${text}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated storage state for `user`, logging in once per worker. Callers
|
||||
* hand the result to `browser.newContext({ storageState })`.
|
||||
*/
|
||||
export function storageStateFor(
|
||||
browser: Browser,
|
||||
user: User = ADMIN,
|
||||
): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const ctx = await browser.newContext(contextDefaults);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an authenticated admin `BrowserContext`. Used by suite hooks
|
||||
* (`test.beforeAll` / `test.afterAll`) and worker-scoped fixtures, where the
|
||||
* test-scoped `authedPage` fixture from `fixtures/auth.ts` is not reachable.
|
||||
* Build a fresh authenticated `BrowserContext` via UI login. Used by suite
|
||||
* hooks (`test.beforeAll` / `test.afterAll`), where the test-scoped
|
||||
* `authedPage` fixture from `fixtures/auth.ts` is not reachable.
|
||||
*
|
||||
* Reuses this worker's cached session, so only the first call in a worker pays
|
||||
* for a login. The caller owns the context and must close it.
|
||||
* Each call performs one fresh login (~1s). The per-worker storageState
|
||||
* cache in `fixtures/auth.ts` is intentionally not shared here — keeping
|
||||
* this helper standalone avoids coupling suite hooks to the fixture's
|
||||
* private cache.
|
||||
*/
|
||||
export async function newAdminContext(
|
||||
browser: Browser,
|
||||
): Promise<BrowserContext> {
|
||||
return browser.newContext({
|
||||
...contextDefaults,
|
||||
storageState: await storageStateFor(browser, ADMIN),
|
||||
});
|
||||
const email = process.env.SIGNOZ_E2E_USERNAME;
|
||||
const password = process.env.SIGNOZ_E2E_PASSWORD;
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
'SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD must be set ' +
|
||||
'(pytest bootstrap writes them to .env.local).',
|
||||
);
|
||||
}
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
await page.close();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page, Request } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Shared helpers used across feature-specific helper modules (dashboards,
|
||||
// trace-details, …). Keep this to genuinely cross-feature utilities.
|
||||
@@ -18,108 +18,6 @@ export function seederUrl(): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── Console / network noise ──────────────────────────────────────────────
|
||||
|
||||
// Requests the bootstrap stack always fails, on every page, for reasons that
|
||||
// have nothing to do with the feature under test. Keep this list tiny and give
|
||||
// every entry a reason — it is a deny-list of *environment* noise, never of real
|
||||
// application errors.
|
||||
const HARNESS_FAILING_REQUESTS = [
|
||||
// Zeus is a WireMock stub with no /api/v2/zeus/hosts mapping, so the app
|
||||
// shell's workspace-URL lookup 404s on every page load. It reaches the console
|
||||
// three ways: the resource-load error, the AxiosError, and the literal `any`
|
||||
// that `api/ErrorResponseHandler.ts`'s fallback branch logs.
|
||||
'/api/v2/zeus/hosts',
|
||||
// The app shell polls GitHub for the latest release. Unauthenticated calls
|
||||
// from CI/dev machines get rate-limited (403), which has nothing to do with
|
||||
// the page under test.
|
||||
'api.github.com',
|
||||
];
|
||||
|
||||
// The console side of {@link HARNESS_FAILING_REQUESTS}. Browsers log a
|
||||
// resource-load error without the URL, so these have to be matched on text —
|
||||
// which is why the URL list above is the precise half of the check.
|
||||
const HARNESS_CONSOLE_NOISE = [
|
||||
'Failed to load resource: the server responded with a status of 404 (Not Found)',
|
||||
'Failed to load resource: the server responded with a status of 403',
|
||||
'Request failed with status code 404',
|
||||
'client never received a response, or request never left',
|
||||
'any',
|
||||
];
|
||||
|
||||
export interface ConsoleWatch {
|
||||
/** Console `error` entries and uncaught page errors, harness noise removed. */
|
||||
errors: string[];
|
||||
/** `"<status> <method> <url>"` for every 4xx/5xx, harness noise removed. */
|
||||
failedResponses: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a page for console errors and failed requests. Call **before** the first
|
||||
* navigation; the returned object fills in as the page runs, so assert on it at
|
||||
* the end of the scenario.
|
||||
*
|
||||
* Console text alone is a weak signal (the harness's Zeus 404 produces three
|
||||
* generic-looking entries), so the failed-response list is the precise half:
|
||||
* text matching is deliberately loose while the URL check stays strict.
|
||||
*/
|
||||
export function watchConsole(
|
||||
page: Page,
|
||||
/**
|
||||
* Extra substrings to ignore. Use this — with a comment naming the defect —
|
||||
* for a *known application* bug that is out of the spec's scope, so the rest
|
||||
* of the console assertion keeps its value instead of being deleted.
|
||||
*/
|
||||
options: { ignore?: string[] } = {},
|
||||
): ConsoleWatch {
|
||||
const watch: ConsoleWatch = { errors: [], failedResponses: [] };
|
||||
const noise = [...HARNESS_CONSOLE_NOISE, ...(options.ignore ?? [])];
|
||||
const isNoise = (text: string): boolean =>
|
||||
noise.some((entry) => text.includes(entry));
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' && !isNoise(msg.text())) {
|
||||
watch.errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
if (!isNoise(String(err))) {
|
||||
watch.errors.push(String(err));
|
||||
}
|
||||
});
|
||||
page.on('response', (res) => {
|
||||
if (res.status() < 400) {
|
||||
return;
|
||||
}
|
||||
const url = res.url();
|
||||
if (HARNESS_FAILING_REQUESTS.some((entry) => url.includes(entry))) {
|
||||
return;
|
||||
}
|
||||
watch.failedResponses.push(
|
||||
`${res.status()} ${res.request().method()} ${url}`,
|
||||
);
|
||||
});
|
||||
return watch;
|
||||
}
|
||||
|
||||
// ─── Network capture ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every request the page issues from now on. Call **before** the first
|
||||
* navigation — the returned array fills in as the page runs, so filter it at the
|
||||
* end of the scenario ("endpoint called exactly once", "no legacy route used").
|
||||
*/
|
||||
export function collectRequests(page: Page): Request[] {
|
||||
const requests: Request[] = [];
|
||||
page.on('request', (request) => requests.push(request));
|
||||
return requests;
|
||||
}
|
||||
|
||||
/** A request's URL, parsed — the readable way to reach `searchParams`. */
|
||||
export function requestUrl(request: Request): URL {
|
||||
return new URL(request.url());
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Read the app JWT from the context's stored auth state. No navigation needed:
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --with-web e2e/bootstrap/setup.py::test_setup",
|
||||
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown e2e/bootstrap/setup.py::test_teardown",
|
||||
"env:clean": "rm -rf ../tmp ../.pytest_cache .env.local artifacts && echo 'Cleaned. Run docker container prune if needed.'",
|
||||
"test": "playwright test",
|
||||
"test:local": "pnpm env:start && pnpm test",
|
||||
"test:all": "playwright test",
|
||||
"test:staging": "SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
|
||||
@@ -1,36 +1,15 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Precedence, lowest to highest:
|
||||
// .env — user-provided defaults (staging creds)
|
||||
// .env.local — written by tests/e2e/bootstrap/setup.py when the pytest
|
||||
// lifecycle brings the backend up locally, so it must win over
|
||||
// any stale .env value
|
||||
// the real environment — anything the caller exported on purpose, e.g.
|
||||
// `SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test` to run
|
||||
// against a locally served frontend, or the vars pytest injects
|
||||
// when it shells out to `pnpm test`.
|
||||
//
|
||||
// This is deliberately *not* `dotenv.config({ override: true })`: that flag
|
||||
// makes the file beat process.env, so an exported SIGNOZ_E2E_BASE_URL was
|
||||
// silently discarded and every run went to whatever .env.local pointed at.
|
||||
// Parsing by hand is the only way to get ".env.local beats .env" without also
|
||||
// getting ".env.local beats the caller".
|
||||
const exported = new Set(Object.keys(process.env));
|
||||
for (const file of ['.env', '.env.local']) {
|
||||
const filePath = path.resolve(__dirname, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = dotenv.parse(fs.readFileSync(filePath));
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (!exported.has(key)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// .env holds user-provided defaults (staging creds).
|
||||
// .env.local is written by tests/e2e/bootstrap/setup.py when the pytest
|
||||
// lifecycle brings the backend up locally; override=true so local-backend
|
||||
// coordinates win over any stale .env values. Subprocess-injected env
|
||||
// (e.g. when pytest shells out to `pnpm test`) still takes priority —
|
||||
// dotenv doesn't touch vars that are already set in process.env.
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
|
||||
67
tests/e2e/tests/alerts/alerts.spec.ts
Normal file
67
tests/e2e/tests/alerts/alerts.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createThresholdAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
gotoAlertOverview,
|
||||
} from '../../helpers/alerts';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
|
||||
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('alerts — threshold persists on edit-page load', () => {
|
||||
const TARGET = 245;
|
||||
let ruleId: string;
|
||||
let channelId: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const stamp = Date.now();
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-threshold-persistence-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
ruleId = await createThresholdAlertViaApi(page, {
|
||||
name: `e2e-threshold-persistence-${stamp}`,
|
||||
target: TARGET,
|
||||
channels: [channel.name],
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
if (ruleId) {
|
||||
await deleteAlertViaApi(page, ruleId);
|
||||
}
|
||||
if (channelId) {
|
||||
await deleteChannelViaApi(page, channelId);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 edit page shows the saved threshold value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
// The condition editor should show the persisted target once loaded.
|
||||
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
|
||||
String(TARGET),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERT_OVERVIEW_PATH,
|
||||
ALERTS_LIST_PATH,
|
||||
gotoAlertDetails,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — actions', () => {
|
||||
test('AD-06 enable/disable toggle changes the rule state', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.logs({
|
||||
name: `e2e-ad-toggle-${Date.now()}`,
|
||||
schema: 'v2',
|
||||
});
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
|
||||
const toggle = page.getByTestId('alert-actions-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
res.request().method() === 'PATCH',
|
||||
),
|
||||
toggle.click(),
|
||||
]);
|
||||
|
||||
await expect(page.getByText('Alert has been disabled.')).toBeVisible();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
res.request().method() === 'PATCH',
|
||||
),
|
||||
toggle.click(),
|
||||
]);
|
||||
|
||||
await expect(page.getByText('Alert has been enabled.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AD-07 Duplicate creates a copy and navigates to overview', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ad-duplicate-${Date.now()}`;
|
||||
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
/\/api\/v\d\/rules$/.test(new URL(res.url()).pathname) &&
|
||||
res.request().method() === 'POST',
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Duplicate' }).click(),
|
||||
]);
|
||||
await ownedRules.register(createResponse);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
|
||||
await expect(page).toHaveURL(/[?&]ruleId=/);
|
||||
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
|
||||
await expect(page.getByText(`${name} - Copy`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('AD-08 Delete removes the rule and returns to the list', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ad-delete-${Date.now()}`;
|
||||
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
res.request().method() === 'DELETE',
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||
]);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
|
||||
await expect(page.getByText(name, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import { ALERTS_LIST_PATH, gotoAlertHistory } from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — page chrome', () => {
|
||||
test('AD-09 copy-link button copies the current URL to clipboard', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName !== 'chromium',
|
||||
'clipboard-read permission is Chromium-only in Playwright',
|
||||
);
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const expected = page.url();
|
||||
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Copied')).toBeVisible();
|
||||
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copied).toBe(expected);
|
||||
});
|
||||
|
||||
test('AD-10 breadcrumb navigates back to the alert list', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const breadcrumb = page.locator('.ant-breadcrumb');
|
||||
await expect(breadcrumb).toContainText('Alert Rules');
|
||||
await expect(breadcrumb).toContainText(alertHistory.ruleId);
|
||||
|
||||
await breadcrumb.getByText('Alert Rules').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
|
||||
});
|
||||
|
||||
test('AD-13 document title updates to show the rule name', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await expect
|
||||
.poll(() => page.title(), { timeout: 15_000 })
|
||||
.toContain('e2e-ah-rule-v2');
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
SEED_C_TEAM_LABEL,
|
||||
test,
|
||||
} from '../../../fixtures/alert-history';
|
||||
import { gotoAlertDetails } from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — header', () => {
|
||||
test('AD-01 v2 header shows editable name input without Rename menu item', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertDetails(page, alertHistory.ruleId);
|
||||
|
||||
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
|
||||
'data-schema-version',
|
||||
'v2alpha1',
|
||||
);
|
||||
|
||||
const nameInput = page.getByTestId('alert-name-input');
|
||||
await expect(nameInput).toBeVisible();
|
||||
await expect(nameInput).not.toHaveValue('');
|
||||
await expect(nameInput).toBeEditable();
|
||||
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Rename' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AD-02 v1 header shows static title with state, severity and labels', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertDetails(page, alertHistory.ruleIdV1);
|
||||
|
||||
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
|
||||
'data-schema-version',
|
||||
'v1',
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('alert-header-title')).toBeVisible();
|
||||
await expect(page.getByTestId('alert-header-state')).toBeVisible();
|
||||
await expect(page.getByTestId('alert-header-severity')).toContainText(
|
||||
'Warning',
|
||||
);
|
||||
await expect(page.getByTestId('alert-header-labels')).toContainText(
|
||||
SEED_C_TEAM_LABEL,
|
||||
);
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Rename' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
ALERT_OVERVIEW_PATH,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — not found', () => {
|
||||
test('AD-11 invalid ruleId shows AlertNotFound page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto(
|
||||
`${ALERT_HISTORY_PATH}?ruleId=01920000-0000-7000-8000-000000000000`,
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('AD-12 missing ruleId on overview shows AlertNotFound page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(ALERT_OVERVIEW_PATH);
|
||||
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERTS_LIST_PATH,
|
||||
gotoAlertDetails,
|
||||
gotoAlertHistory,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — rename', () => {
|
||||
test('AD-03 v1 rename via modal updates the rule name', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const original = `e2e-ad-rename-v1-${stamp}`;
|
||||
const renamed = `${original}-renamed`;
|
||||
const ruleId = await ownedRules.logs({ name: original, schema: 'v1' });
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await expect(page.getByTestId('alert-header-title')).toContainText(original);
|
||||
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Rename' }).click();
|
||||
|
||||
const modalInput = page.getByTestId('alert-name');
|
||||
await expect(modalInput).toBeVisible();
|
||||
await modalInput.fill(renamed);
|
||||
await page.getByRole('button', { name: 'Rename Alert' }).click();
|
||||
|
||||
await expect(page.getByText('Alert renamed successfully')).toBeVisible();
|
||||
await expect(page.getByTestId('alert-header-title')).toContainText(renamed);
|
||||
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
|
||||
await expect(page.getByText(renamed)).toBeVisible();
|
||||
});
|
||||
|
||||
test('AD-04 v2 inline rename saves via Overview footer button', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const original = `e2e-ad-rename-v2-${stamp}`;
|
||||
const renamed = `${original}-renamed`;
|
||||
const ruleId = await ownedRules.logs({ name: original, schema: 'v2' });
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
|
||||
const nameInput = page.getByTestId('alert-name-input');
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
await nameInput.fill(renamed);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
['PUT', 'POST', 'PATCH'].includes(res.request().method()),
|
||||
),
|
||||
page.getByRole('button', { name: 'Save Alert Rule' }).click(),
|
||||
]);
|
||||
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
|
||||
await expect(page.getByText(renamed)).toBeVisible();
|
||||
|
||||
await gotoAlertHistory(page, ruleId);
|
||||
const historyInput = page.getByTestId('alert-name-input');
|
||||
await historyInput.fill(`${renamed}-unsaved`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save Alert Rule' }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
|
||||
await expect(page.getByText(renamed)).toBeVisible();
|
||||
await expect(page.getByText(`${renamed}-unsaved`)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
ALERT_OVERVIEW_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
gotoAlertDetails,
|
||||
gotoAlertHistory,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — tabs', () => {
|
||||
test('AD-05 Overview/History tabs preserve ruleId and relativeTime', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertDetails(page, alertHistory.ruleId);
|
||||
|
||||
await page.getByTestId('alert-details-tab-history').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
|
||||
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`relativeTime=${DEFAULT_RELATIVE_TIME}`),
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId('alert-details-tab-history').getByText('Beta'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId('alert-details-tab-overview').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
|
||||
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
|
||||
});
|
||||
|
||||
test('AD-05b switching to History tab discards other history params', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
page: '2',
|
||||
order: 'desc',
|
||||
timelineFilter: 'FIRED',
|
||||
});
|
||||
|
||||
await page.getByTestId('alert-details-tab-overview').click();
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
|
||||
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
|
||||
|
||||
await page.getByTestId('alert-details-tab-history').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
|
||||
const search = new URL(page.url()).searchParams;
|
||||
expect([...search.keys()].sort()).toEqual(['relativeTime', 'ruleId']);
|
||||
expect(search.get('ruleId')).toBe(alertHistory.ruleId);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import { gotoAlertOverview } from '../../../helpers/alerts';
|
||||
|
||||
const TARGET = 245;
|
||||
|
||||
test.describe('Alert overview — threshold persistence', () => {
|
||||
test('TC-02 edit page displays the saved threshold value', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(
|
||||
`e2e-threshold-persistence-${Date.now()}`,
|
||||
{ target: TARGET },
|
||||
);
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
|
||||
String(TARGET),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,223 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
encodeTimelineCursor,
|
||||
gotoAlertHistory,
|
||||
HISTORY_ENDPOINTS,
|
||||
isHistoryRequest,
|
||||
sortTimelineDescending,
|
||||
statsCard,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
timelineFooterRange,
|
||||
timelineRows,
|
||||
} from '../../../helpers/alerts';
|
||||
import {
|
||||
collectRequests,
|
||||
requestUrl,
|
||||
watchConsole,
|
||||
} from '../../../helpers/common';
|
||||
|
||||
test.describe('Alert history — cross-cutting', () => {
|
||||
test('AX-01 full deep-link with all params is honoured in one load', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const service = alertHistory.services[8];
|
||||
await page.goto(
|
||||
`${ALERT_HISTORY_PATH}?ruleId=${alertHistory.ruleId}` +
|
||||
`&relativeTime=${DEFAULT_RELATIVE_TIME}` +
|
||||
`&timelineFilter=FIRED&page=1&order=desc` +
|
||||
`&alertHistoryExpression=${encodeURIComponent(`service.name = '${service}'`)}` +
|
||||
`&viewAllTopContributors=true`,
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-filter-fired')).toHaveClass(
|
||||
/selected/,
|
||||
);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AX-02 page reload preserves all history params', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
timelineFilter: 'FIRED',
|
||||
page: '2',
|
||||
order: 'desc',
|
||||
});
|
||||
const before = new URL(page.url()).searchParams;
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
|
||||
const after = new URL(page.url()).searchParams;
|
||||
for (const [key, value] of before) {
|
||||
expect(after.get(key), `param ${key} survived the reload`).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
test('AX-03 browser back/forward restores correct table state', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
|
||||
await page.getByTestId('timeline-filter-fired').click();
|
||||
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
|
||||
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await expect(timelineRows(page)).toHaveCount(
|
||||
alertHistory.total - TIMELINE_PAGE_SIZE,
|
||||
);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).not.toHaveURL(/[?&]page=2/);
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await expect(timelineRows(page)).toHaveCount(
|
||||
alertHistory.total - TIMELINE_PAGE_SIZE,
|
||||
);
|
||||
});
|
||||
|
||||
test('AX-04 no unhandled console errors across full history session', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const watch = watchConsole(page);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await page.getByTestId('timeline-filter-fired').click();
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await sortTimelineDescending(page);
|
||||
await expect(page).toHaveURL(/[?&]order=desc/);
|
||||
await page.getByTestId('top-contributors-view-all').click();
|
||||
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
|
||||
|
||||
expect(watch.errors).toEqual([]);
|
||||
expect(watch.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
test('AX-05 no request storm on mount (exactly one call per endpoint)', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const requests = collectRequests(page);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
await expect(
|
||||
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
|
||||
).toBeVisible();
|
||||
|
||||
for (const endpoint of HISTORY_ENDPOINTS) {
|
||||
const calls = requests.filter((req) => isHistoryRequest(req, endpoint));
|
||||
expect(calls, `${endpoint} called exactly once on mount`).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('AX-06 v1 and v2 schema rules both render history correctly', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
for (const [variant, ruleId, total] of [
|
||||
['v2', alertHistory.ruleId, alertHistory.total],
|
||||
['v1', alertHistory.ruleIdV1, alertHistory.totalV1],
|
||||
] as const) {
|
||||
await gotoAlertHistory(page, ruleId);
|
||||
await expect(
|
||||
page.getByTestId('alert-details-root'),
|
||||
`${variant} rule renders the details shell`,
|
||||
).toHaveAttribute(
|
||||
'data-schema-version',
|
||||
variant === 'v2' ? 'v2alpha1' : 'v1',
|
||||
);
|
||||
await expect(timelineFooterRange(page)).toContainText(`of ${total}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('AX-07 no legacy v1 history API calls during full session', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const requests = collectRequests(page);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await page.getByTestId('timeline-filter-fired').click();
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await sortTimelineDescending(page);
|
||||
await expect(page).toHaveURL(/[?&]order=desc/);
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '6h' });
|
||||
|
||||
const legacy = requests.filter((req) =>
|
||||
/\/api\/v1\/rules\/[^/]+\/history\//.test(req.url()),
|
||||
);
|
||||
expect(
|
||||
legacy.map((req) => req.url()),
|
||||
'no legacy POST /api/v1/rules/*/history/* calls',
|
||||
).toEqual([]);
|
||||
|
||||
for (const endpoint of HISTORY_ENDPOINTS) {
|
||||
expect(
|
||||
requests.filter((req) => isHistoryRequest(req, endpoint)).length,
|
||||
`v2 ${endpoint} endpoint was used`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('AX-08 history API endpoints carry expected params', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const requests = collectRequests(page);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
timelineFilter: 'FIRED',
|
||||
page: '2',
|
||||
});
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
|
||||
const timeline = requests
|
||||
.filter((req) => isHistoryRequest(req, 'timeline'))
|
||||
.pop();
|
||||
expect(timeline).toBeDefined();
|
||||
const timelineParams = requestUrl(timeline!).searchParams;
|
||||
for (const key of ['start', 'end', 'limit', 'order', 'cursor', 'state']) {
|
||||
expect(timelineParams.get(key), `timeline carries ${key}`).toBeTruthy();
|
||||
}
|
||||
expect(timelineParams.get('limit')).toBe(String(TIMELINE_PAGE_SIZE));
|
||||
expect(timelineParams.get('cursor')).toBe(encodeTimelineCursor(2));
|
||||
|
||||
for (const endpoint of [
|
||||
'stats',
|
||||
'overall_status',
|
||||
'top_contributors',
|
||||
] as const) {
|
||||
const request = requests
|
||||
.filter((req) => isHistoryRequest(req, endpoint))
|
||||
.pop();
|
||||
expect(request, `${endpoint} was requested`).toBeDefined();
|
||||
const params = requestUrl(request!).searchParams;
|
||||
expect(params.get('start'), `${endpoint} carries start`).toBeTruthy();
|
||||
expect(params.get('end'), `${endpoint} carries end`).toBeTruthy();
|
||||
}
|
||||
|
||||
const keys = requests
|
||||
.filter((req) => /\/history\/filter_keys/.test(req.url()))
|
||||
.pop();
|
||||
expect(keys, 'filter_keys was requested').toBeDefined();
|
||||
const keyParams = requestUrl(keys!).searchParams;
|
||||
expect(keyParams.get('startUnixMilli')).toBeTruthy();
|
||||
expect(keyParams.get('endUnixMilli')).toBeTruthy();
|
||||
expect(keyParams.get('start')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
createLogsAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
expectFirstPage,
|
||||
gotoAlertHistory,
|
||||
isHistoryRequest,
|
||||
runFilterExpression,
|
||||
setRuleDisabledViaApi,
|
||||
statsCard,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
timelineFooterRange,
|
||||
timelineRows,
|
||||
} from '../../../helpers/alerts';
|
||||
import { collectRequests } from '../../../helpers/common';
|
||||
import { typeExpression } from '../../../helpers/query-builder';
|
||||
|
||||
test.describe('Alert history — error and empty states', () => {
|
||||
test('AE-01 invalid filter expression shows syntax error and recovers on fix', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const responsePromise = page.waitForResponse((res) =>
|
||||
isHistoryRequest(res.request(), 'timeline'),
|
||||
);
|
||||
await runFilterExpression(page, 'service.name =');
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const error = page.getByTestId('timeline-error');
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).toContainText(/syntax error/i);
|
||||
|
||||
await runFilterExpression(
|
||||
page,
|
||||
`service.name = '${alertHistory.services[7]}'`,
|
||||
);
|
||||
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AE-02 empty filter_keys response still mounts editor (no suggestions)', async ({
|
||||
authedPage: page,
|
||||
emptyHistory,
|
||||
}) => {
|
||||
const keysPromise = page.waitForResponse((res) =>
|
||||
/\/history\/filter_keys/.test(res.url()),
|
||||
);
|
||||
await gotoAlertHistory(page, emptyHistory.ruleId);
|
||||
const keysResponse = await keysPromise;
|
||||
|
||||
const body = (await keysResponse.json()) as {
|
||||
data: { keys?: Record<string, unknown> } | null;
|
||||
};
|
||||
expect(Object.keys(body.data?.keys ?? {})).toHaveLength(0);
|
||||
|
||||
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
|
||||
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
|
||||
await typeExpression(page, 'anything.at.all');
|
||||
await expect(
|
||||
page.locator('.query-where-clause-editor .cm-content'),
|
||||
).toContainText('anything.at.all');
|
||||
});
|
||||
|
||||
test('AE-02b bogus ruleId never reaches history APIs (shows AlertNotFound)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const requests = collectRequests(page);
|
||||
|
||||
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
|
||||
expect(requests.filter((req) => /\/history\//.test(req.url()))).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('AE-03 rule with no history renders empty state (not error)', async ({
|
||||
authedPage: page,
|
||||
emptyHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, emptyHistory.ruleId);
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(0);
|
||||
await expect(
|
||||
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
|
||||
).toHaveText('None Triggered.');
|
||||
await expect(
|
||||
statsCard(page, 'Avg. Resolution Time').getByTestId('stats-card-value'),
|
||||
).toHaveText('No Resolutions.');
|
||||
await expect(page.getByTestId('top-contributors-row')).toHaveCount(0);
|
||||
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AE-04 time range with no data renders empty state', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const end = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const start = end - 30 * 60 * 1000;
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
startTime: String(start),
|
||||
endTime: String(end),
|
||||
});
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(0);
|
||||
await expect(
|
||||
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
|
||||
).toHaveText('None Triggered.');
|
||||
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AE-05 time-range change resets pagination to first page', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
|
||||
await page.locator('.filters input').first().click();
|
||||
await page.getByText('Last 6 hours', { exact: true }).click();
|
||||
|
||||
await expectFirstPage(page);
|
||||
});
|
||||
|
||||
test('AE-06 absurd time range (90d) still renders', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '90d' });
|
||||
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
await expect(timelineRows(page).first()).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-graph-title')).toContainText(
|
||||
`${alertHistory.total} triggers in`,
|
||||
);
|
||||
});
|
||||
|
||||
test('AE-07 disabled rule history is still readable', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.total}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('AE-08 deleted rule shows AlertNotFound on revisit', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const doomedId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-doomed-${Date.now()}`,
|
||||
marker: alertHistory.marker,
|
||||
channels: [alertHistory.channelName],
|
||||
});
|
||||
await setRuleDisabledViaApi(page, doomedId, true);
|
||||
|
||||
await gotoAlertHistory(page, doomedId);
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
|
||||
await deleteAlertViaApi(page, doomedId);
|
||||
|
||||
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=${doomedId}`);
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
expectFirstPage,
|
||||
gotoAlertHistory,
|
||||
isHistoryRequest,
|
||||
runFilterExpression,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
timelineFooterRange,
|
||||
timelineRows,
|
||||
} from '../../../helpers/alerts';
|
||||
import { requestUrl } from '../../../helpers/common';
|
||||
import { typeExpression } from '../../../helpers/query-builder';
|
||||
|
||||
test.describe('Alert history — expression filter', () => {
|
||||
test('AF-06 key suggestions load on page load', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const keysPromise = page.waitForResponse((res) =>
|
||||
/\/history\/filter_keys/.test(res.url()),
|
||||
);
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const keysResponse = await keysPromise;
|
||||
|
||||
const body = (await keysResponse.json()) as {
|
||||
data: { keys: Record<string, { name: string }[]> } | null;
|
||||
};
|
||||
expect(Object.keys(body.data?.keys ?? {}).sort()).toEqual([
|
||||
'service.name',
|
||||
'severity',
|
||||
'threshold.name',
|
||||
]);
|
||||
const params = new URL(keysResponse.url()).searchParams;
|
||||
expect(params.get('startUnixMilli')).toBeTruthy();
|
||||
expect(params.get('endUnixMilli')).toBeTruthy();
|
||||
|
||||
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AF-07 value suggestions fetch from filter_values endpoint', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const valuesPromise = page.waitForResponse((res) =>
|
||||
/\/history\/filter_values/.test(res.url()),
|
||||
);
|
||||
await typeExpression(page, "service.name = '");
|
||||
const valuesResponse = await valuesPromise;
|
||||
|
||||
const params = new URL(valuesResponse.url()).searchParams;
|
||||
expect(params.get('name')).toBe('service.name');
|
||||
|
||||
const body = (await valuesResponse.json()) as {
|
||||
data: { values?: { stringValues?: string[] }; complete?: boolean } | null;
|
||||
};
|
||||
expect(body.data?.values?.stringValues).toEqual(
|
||||
[...alertHistory.services].sort(),
|
||||
);
|
||||
expect(body.data?.complete).toBe(true);
|
||||
});
|
||||
|
||||
test('AF-08 value suggestions filter client-side as user types', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await typeExpression(page, "service.name = 'svc-1");
|
||||
|
||||
const options = page.locator('.cm-tooltip-autocomplete li');
|
||||
await expect(options.first()).toBeVisible();
|
||||
const texts = await options.allInnerTexts();
|
||||
expect(texts.length).toBeGreaterThan(0);
|
||||
expect(texts.length).toBeLessThan(alertHistory.services.length);
|
||||
for (const text of texts) {
|
||||
expect(text).toContain('svc-1');
|
||||
}
|
||||
});
|
||||
|
||||
test('AF-09 running equality expression filters the table', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const service = alertHistory.services[3];
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await runFilterExpression(page, `service.name = '${service}'`);
|
||||
const request = await requestPromise;
|
||||
|
||||
expect(requestUrl(request).searchParams.get('filterExpression')).toBe(
|
||||
`service.name = '${service}'`,
|
||||
);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
|
||||
});
|
||||
|
||||
test('AF-10 running expression resets pagination to first page', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
|
||||
|
||||
await runFilterExpression(
|
||||
page,
|
||||
`service.name = '${alertHistory.services[0]}'`,
|
||||
);
|
||||
|
||||
await expect(page).not.toHaveURL(/[?&]page=2/);
|
||||
await expectFirstPage(page);
|
||||
});
|
||||
|
||||
test('AF-11 Run button re-fetches unchanged expression', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const expression = `service.name = '${alertHistory.services[1]}'`;
|
||||
await runFilterExpression(page, expression);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await page.getByRole('button', { name: /run query/i }).click();
|
||||
await requestPromise;
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AF-12 in-flight query can be cancelled', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await page.route(
|
||||
(url) => /\/history\/timeline/.test(url.pathname),
|
||||
async (route) => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 3_000);
|
||||
});
|
||||
await route.continue();
|
||||
},
|
||||
);
|
||||
|
||||
await typeExpression(page, `service.name = '${alertHistory.services[2]}'`);
|
||||
await page.getByRole('button', { name: /run query/i }).click();
|
||||
|
||||
const cancel = page.getByRole('button', { name: 'Cancel' });
|
||||
await expect(cancel).toBeVisible();
|
||||
await cancel.click();
|
||||
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /run query/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AF-13 threshold.name and severity keys filter correctly', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await runFilterExpression(page, `threshold.name = 'critical'`);
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.total}`,
|
||||
);
|
||||
|
||||
await runFilterExpression(page, `severity = 'critical'`);
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.total}`,
|
||||
);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleIdV1);
|
||||
await runFilterExpression(page, `threshold.name = 'warning'`);
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.totalV1}`,
|
||||
);
|
||||
|
||||
await runFilterExpression(page, `threshold.name = 'critical'`);
|
||||
await expect(timelineRows(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AF-14 unknown key returns 200 with zero rows (not 500)', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const responsePromise = page.waitForResponse((res) =>
|
||||
isHistoryRequest(res.request(), 'timeline'),
|
||||
);
|
||||
await runFilterExpression(page, `nonexistent.key = 'x'`);
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
await expect(timelineRows(page)).toHaveCount(0);
|
||||
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AF-15 expression is lost on Overview→History round-trip (known bug)', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const expression = `service.name = '${alertHistory.services[4]}'`;
|
||||
await runFilterExpression(page, expression);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
|
||||
|
||||
await page.getByTestId('alert-details-tab-overview').click();
|
||||
await page.getByTestId('alert-details-tab-history').click();
|
||||
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.has('alertHistoryExpression')).toBe(
|
||||
false,
|
||||
);
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
});
|
||||
|
||||
test('AF-16 expression and state filter compose in request', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
timelineFilter: 'FIRED',
|
||||
});
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
const service = alertHistory.services[5];
|
||||
await runFilterExpression(page, `service.name = '${service}'`);
|
||||
const request = await requestPromise;
|
||||
|
||||
const params = requestUrl(request).searchParams;
|
||||
expect(params.get('state')).toBe('firing');
|
||||
expect(params.get('filterExpression')).toBe(`service.name = '${service}'`);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AF-17 clearing expression restores full unfiltered list', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await runFilterExpression(
|
||||
page,
|
||||
`service.name = '${alertHistory.services[6]}'`,
|
||||
);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await runFilterExpression(page, '');
|
||||
const request = await requestPromise;
|
||||
|
||||
expect(requestUrl(request).searchParams.get('filterExpression')).toBeNull();
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.total}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
expectFirstPage,
|
||||
gotoAlertHistory,
|
||||
isHistoryRequest,
|
||||
timelineRows,
|
||||
} from '../../../helpers/alerts';
|
||||
import { requestUrl } from '../../../helpers/common';
|
||||
|
||||
test.describe('Alert history — state filter', () => {
|
||||
test('AF-01 All filter sends no state param in request', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
timelineFilter: 'FIRED',
|
||||
});
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await page.getByTestId('timeline-filter-all').click();
|
||||
const request = await requestPromise;
|
||||
|
||||
await expect(page).toHaveURL(/[?&]timelineFilter=ALL/);
|
||||
expect(requestUrl(request).searchParams.get('state')).toBeNull();
|
||||
});
|
||||
|
||||
test('AF-02 Fired filter sends state=firing in request', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await page.getByTestId('timeline-filter-fired').click();
|
||||
const request = await requestPromise;
|
||||
|
||||
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
|
||||
expect(requestUrl(request).searchParams.get('state')).toBe('firing');
|
||||
await expect(timelineRows(page).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('AF-03 Resolved filter shows empty for rule with no resolutions', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await page.getByTestId('timeline-filter-resolved').click();
|
||||
const request = await requestPromise;
|
||||
|
||||
await expect(page).toHaveURL(/[?&]timelineFilter=RESOLVED/);
|
||||
expect(requestUrl(request).searchParams.get('state')).toBe('inactive');
|
||||
await expect(timelineRows(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AF-03b Resolved filter shows rows for rule with resolutions', async ({
|
||||
authedPage: page,
|
||||
resolvedHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, resolvedHistory.ruleId, {
|
||||
timelineFilter: 'RESOLVED',
|
||||
});
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(resolvedHistory.resolvedCount);
|
||||
for (const text of await timelineRows(page)
|
||||
.getByTestId('timeline-row-state')
|
||||
.allInnerTexts()) {
|
||||
expect(text).toBe('Resolved');
|
||||
}
|
||||
|
||||
await page.getByTestId('timeline-filter-fired').click();
|
||||
await expect(timelineRows(page)).toHaveCount(resolvedHistory.firingCount);
|
||||
});
|
||||
|
||||
test('AF-04 deep-link ?timelineFilter=FIRED starts on Fired tab', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
timelineFilter: 'FIRED',
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('timeline-filter-fired')).toHaveClass(
|
||||
/selected/,
|
||||
);
|
||||
await expect(timelineRows(page).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('AF-05 changing state filter resets pagination to first page', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
|
||||
|
||||
await page.getByTestId('timeline-filter-fired').click();
|
||||
|
||||
await expect(page).not.toHaveURL(/[?&]page=2/);
|
||||
await expectFirstPage(page);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
gotoAlertHistory,
|
||||
statsCard,
|
||||
timelineRows,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert history — statistics', () => {
|
||||
test('AS-01 Total Triggered card shows the firing count', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const card = statsCard(page, 'Total Triggered');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toHaveAttribute('data-empty', 'false');
|
||||
await expect(card.getByTestId('stats-card-value')).toHaveText(
|
||||
String(alertHistory.total),
|
||||
);
|
||||
});
|
||||
|
||||
test('AS-02 Avg. Resolution Time card shows "No Resolutions." when none exist', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const card = statsCard(page, 'Avg. Resolution Time');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toHaveAttribute('data-empty', 'true');
|
||||
const value = card.getByTestId('stats-card-value');
|
||||
await expect(value).toHaveText('No Resolutions.');
|
||||
await expect(value).not.toHaveText(/NaN/);
|
||||
await expect(card.getByTestId('stats-card-sparkline')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AS-03 empty stats card never renders a sparkline', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const empty = statsCard(page, 'Avg. Resolution Time');
|
||||
await expect(empty).toHaveAttribute('data-empty', 'true');
|
||||
await expect(empty.getByTestId('stats-card-sparkline')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
|
||||
test('AS-03b sparkline present with a multi-point series', async () => {
|
||||
test.skip(
|
||||
true,
|
||||
'Not deterministic on SEED-A: whether `currentTriggersSeries` lands in ' +
|
||||
'one stats bucket or two depends on where the ~2-minute seed falls ' +
|
||||
'relative to the bucket boundary, so the `timeSeries.length > 1` gate ' +
|
||||
'flips between runs (observed both ways). Needs a fixture that ' +
|
||||
'guarantees ≥2 points — see coverage doc §3.5.',
|
||||
);
|
||||
});
|
||||
|
||||
test('AS-04 change-vs-past indicator shows "no previous data" when unavailable', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const card = statsCard(page, 'Total Triggered');
|
||||
await expect(card.getByTestId('stats-card-change')).toHaveText(
|
||||
'no previous data',
|
||||
);
|
||||
});
|
||||
|
||||
test('AS-09 stats update when time range changes', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const end = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const start = end - 30 * 60 * 1000;
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
startTime: String(start),
|
||||
endTime: String(end),
|
||||
});
|
||||
|
||||
await expect(
|
||||
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
|
||||
).toHaveText('None Triggered.');
|
||||
await expect(
|
||||
statsCard(page, 'Avg. Resolution Time').getByTestId('stats-card-value'),
|
||||
).toHaveText('No Resolutions.');
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await expect(
|
||||
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
|
||||
).toHaveText(String(alertHistory.total));
|
||||
});
|
||||
|
||||
test('AS-11 Avg. Resolution Time shows formatted duration when resolutions exist', async ({
|
||||
authedPage: page,
|
||||
resolvedHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, resolvedHistory.ruleId);
|
||||
|
||||
const card = statsCard(page, 'Avg. Resolution Time');
|
||||
await expect(card).toHaveAttribute('data-empty', 'false');
|
||||
await expect(card.getByTestId('stats-card-value')).not.toHaveText(
|
||||
'No Resolutions.',
|
||||
);
|
||||
await expect(card.getByTestId('stats-card-value')).not.toHaveText('');
|
||||
await expect(card.getByTestId('stats-card-sparkline')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AS-12 Total Triggered counts only firing rows (not resolved)', async ({
|
||||
authedPage: page,
|
||||
resolvedHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, resolvedHistory.ruleId);
|
||||
|
||||
await expect(
|
||||
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
|
||||
).toHaveText(String(resolvedHistory.firingCount));
|
||||
await expect(timelineRows(page)).toHaveCount(
|
||||
resolvedHistory.firingCount + resolvedHistory.resolvedCount,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
gotoAlertHistory,
|
||||
isHistoryRequest,
|
||||
} from '../../../helpers/alerts';
|
||||
import { watchConsole } from '../../../helpers/common';
|
||||
|
||||
test.describe('Alert history — timeline graph', () => {
|
||||
test('AT-03 renders canvas with two segments (inactive→firing)', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const watch = watchConsole(page);
|
||||
|
||||
const statusPromise = page.waitForResponse((res) =>
|
||||
isHistoryRequest(res.request(), 'overall_status'),
|
||||
);
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const status = await statusPromise;
|
||||
|
||||
const body = (await status.json()) as {
|
||||
data: { state: string }[] | null;
|
||||
};
|
||||
const states = (body.data ?? []).map((segment) => segment.state);
|
||||
expect(states).toEqual(['inactive', 'firing']);
|
||||
|
||||
await expect(page.getByTestId('timeline-graph')).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId('timeline-graph').locator('canvas'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('timeline-graph-title')).toHaveText(
|
||||
`${alertHistory.total} triggers in ${DEFAULT_RELATIVE_TIME}`,
|
||||
);
|
||||
|
||||
expect(watch.errors).toEqual([]);
|
||||
expect(watch.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
test('AT-03b renders canvas with three segments (inactive→firing→inactive)', async ({
|
||||
authedPage: page,
|
||||
resolvedHistory,
|
||||
}) => {
|
||||
const watch = watchConsole(page);
|
||||
|
||||
const statusPromise = page.waitForResponse((res) =>
|
||||
isHistoryRequest(res.request(), 'overall_status'),
|
||||
);
|
||||
await gotoAlertHistory(page, resolvedHistory.ruleId);
|
||||
const status = await statusPromise;
|
||||
|
||||
const body = (await status.json()) as { data: { state: string }[] | null };
|
||||
expect((body.data ?? []).map((s) => s.state)).toEqual([
|
||||
'inactive',
|
||||
'firing',
|
||||
'inactive',
|
||||
]);
|
||||
await expect(
|
||||
page.getByTestId('timeline-graph').locator('canvas'),
|
||||
).toBeVisible();
|
||||
expect(watch.errors).toEqual([]);
|
||||
expect(watch.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
test('AT-19 handles nodata state without console errors', async ({
|
||||
authedPage: page,
|
||||
noDataHistory,
|
||||
}) => {
|
||||
const watch = watchConsole(page);
|
||||
|
||||
const statusPromise = page.waitForResponse((res) =>
|
||||
isHistoryRequest(res.request(), 'overall_status'),
|
||||
);
|
||||
await gotoAlertHistory(page, noDataHistory.ruleId);
|
||||
const status = await statusPromise;
|
||||
|
||||
const body = (await status.json()) as { data: { state: string }[] | null };
|
||||
const KNOWN_STATES = [
|
||||
'firing',
|
||||
'inactive',
|
||||
'pending',
|
||||
'nodata',
|
||||
'recovering',
|
||||
'disabled',
|
||||
];
|
||||
const states = (body.data ?? []).map((segment) => segment.state);
|
||||
expect(states.length).toBeGreaterThan(0);
|
||||
for (const state of states) {
|
||||
expect(KNOWN_STATES).toContain(state);
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByTestId('timeline-graph').locator('canvas'),
|
||||
).toBeVisible();
|
||||
expect(watch.errors).toEqual([]);
|
||||
expect(watch.failedResponses).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,170 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
encodeTimelineCursor,
|
||||
expectFirstPage,
|
||||
gotoAlertHistory,
|
||||
isHistoryRequest,
|
||||
sortTimelineDescending,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
timelineFooterRange,
|
||||
timelineRowLabels,
|
||||
timelineRows,
|
||||
} from '../../../helpers/alerts';
|
||||
import { requestUrl } from '../../../helpers/common';
|
||||
|
||||
test.describe('Alert history — timeline pagination', () => {
|
||||
test('AT-06 next page sends cursor and shows different rows', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const firstPageLabels = await timelineRowLabels(page);
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
const request = await requestPromise;
|
||||
|
||||
expect(requestUrl(request).searchParams.get('cursor')).toBe(
|
||||
encodeTimelineCursor(2),
|
||||
);
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await expect(timelineRows(page)).toHaveCount(
|
||||
alertHistory.total - TIMELINE_PAGE_SIZE,
|
||||
);
|
||||
await expect(timelineFooterRange(page)).toHaveText(
|
||||
`${TIMELINE_PAGE_SIZE + 1} — ${alertHistory.total} of ${alertHistory.total}`,
|
||||
);
|
||||
await expect(page.getByTestId('timeline-prev-page')).toBeEnabled();
|
||||
|
||||
expect(await timelineRowLabels(page)).not.toEqual(firstPageLabels);
|
||||
});
|
||||
|
||||
test('AT-07 prev page drops the cursor from request', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await page.getByTestId('timeline-prev-page').click();
|
||||
const request = await requestPromise;
|
||||
|
||||
expect(requestUrl(request).searchParams.get('cursor')).toBeNull();
|
||||
await expect(timelineFooterRange(page)).toHaveText(
|
||||
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('AT-08 pagination buttons disable at first and last page', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await expect(page.getByTestId('timeline-prev-page')).toBeDisabled();
|
||||
await expect(page.getByTestId('timeline-next-page')).toBeEnabled();
|
||||
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
|
||||
await expect(page.getByTestId('timeline-next-page')).toBeDisabled();
|
||||
await expect(page.getByTestId('timeline-prev-page')).toBeEnabled();
|
||||
});
|
||||
|
||||
test('AT-09 browser back after paging returns to previous page', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
|
||||
await page.goBack();
|
||||
|
||||
await expect(page).not.toHaveURL(/[?&]page=2/);
|
||||
await expect(timelineFooterRange(page)).toHaveText(
|
||||
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('AT-10 deep-link ?page=2 loads second page directly', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const requestPromise = page.waitForRequest(
|
||||
(req) =>
|
||||
isHistoryRequest(req, 'timeline') &&
|
||||
requestUrl(req).searchParams.get('cursor') === encodeTimelineCursor(2),
|
||||
);
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
|
||||
await requestPromise;
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(
|
||||
alertHistory.total - TIMELINE_PAGE_SIZE,
|
||||
);
|
||||
});
|
||||
|
||||
test('AT-11 default sort order is ascending', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const request = await requestPromise;
|
||||
|
||||
expect(new URL(page.url()).searchParams.get('order')).toBeNull();
|
||||
expect(requestUrl(request).searchParams.get('order')).toBe('asc');
|
||||
});
|
||||
|
||||
test('AT-12 sorting toggles order and resets to first page', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
|
||||
|
||||
await sortTimelineDescending(page);
|
||||
|
||||
await expect(page).toHaveURL(/[?&]order=desc/);
|
||||
await expectFirstPage(page);
|
||||
});
|
||||
|
||||
test('AT-13 single page disables both pagination buttons', async ({
|
||||
authedPage: page,
|
||||
metricsHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, metricsHistory.ruleId);
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(metricsHistory.total);
|
||||
await expect(page.getByTestId('timeline-prev-page')).toBeDisabled();
|
||||
await expect(page.getByTestId('timeline-next-page')).toBeDisabled();
|
||||
await expect(timelineFooterRange(page)).toHaveText(
|
||||
`1 — ${metricsHistory.total} of ${metricsHistory.total}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('AT-21 all pages together cover the complete row set', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const pageOne = await timelineRowLabels(page);
|
||||
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
await expect(timelineRows(page)).toHaveCount(
|
||||
alertHistory.total - TIMELINE_PAGE_SIZE,
|
||||
);
|
||||
await expect(timelineFooterRange(page)).toHaveText(
|
||||
`${TIMELINE_PAGE_SIZE + 1} — ${alertHistory.total} of ${alertHistory.total}`,
|
||||
);
|
||||
const pageTwo = await timelineRowLabels(page);
|
||||
|
||||
const union = new Set([...pageOne, ...pageTwo]);
|
||||
expect(pageOne.length + pageTwo.length).toBe(alertHistory.total);
|
||||
expect(union.size).toBe(alertHistory.total);
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
firstTimelineRowCreatedAt,
|
||||
gotoAlertHistory,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
timelineFooterRange,
|
||||
timelineRows,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert history — timeline table', () => {
|
||||
test('AT-01 timeline section renders all chrome elements', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await expect(page.locator('.timeline__title')).toHaveText('Timeline');
|
||||
await expect(page.getByTestId('timeline-tab-overall-status')).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-filter-all')).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-filter-fired')).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-filter-resolved')).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-graph')).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AT-02 Top 5 Contributors tab is disabled with Coming Soon indicator', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const tab = page.getByTestId('timeline-tab-top-contributors');
|
||||
await expect(tab).toBeDisabled();
|
||||
await expect(tab).toContainText('Coming Soon');
|
||||
});
|
||||
|
||||
test('AT-04 table rows display state, labels and formatted timestamp', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
|
||||
const first = timelineRows(page).first();
|
||||
await expect(first.getByTestId('timeline-row-state')).toHaveText('Firing');
|
||||
await expect(first.getByTestId('timeline-row-labels')).toContainText(
|
||||
'service.name',
|
||||
);
|
||||
await expect(first.getByTestId('timeline-row-created-at')).toHaveText(
|
||||
/^[A-Z][a-z]{2} \d{1,2}, \d{4} ⎯ \d{2}:\d{2}:\d{2}$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('AT-05 footer shows correct row range', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await expect(timelineFooterRange(page)).toHaveText(
|
||||
`1 — ${TIMELINE_PAGE_SIZE} of ${alertHistory.total}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('AT-14 row click does not navigate away', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const before = page.url();
|
||||
|
||||
await timelineRows(page).first().click();
|
||||
|
||||
expect(page.url()).toBe(before);
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AT-15 row actions link navigates to logs explorer', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await timelineRows(page).first().getByTestId('timeline-row-actions').click();
|
||||
|
||||
const viewLogs = page.getByTestId('alert-popover-view-logs');
|
||||
await expect(viewLogs).toBeVisible();
|
||||
await viewLogs.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/logs\/logs-explorer/);
|
||||
const search = new URL(page.url()).searchParams;
|
||||
expect(search.get('startTime')).toBeTruthy();
|
||||
expect(search.get('endTime')).toBeTruthy();
|
||||
expect(page.url()).toContain('compositeQuery');
|
||||
});
|
||||
|
||||
test('AT-16 metrics rule rows show disabled action (no related links)', async ({
|
||||
authedPage: page,
|
||||
metricsHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, metricsHistory.ruleId);
|
||||
|
||||
const action = timelineRows(page).first().getByTestId('timeline-row-actions');
|
||||
await expect(action).toBeDisabled();
|
||||
|
||||
await expect(page.getByTestId('alert-popover-view-logs')).toHaveCount(0);
|
||||
await expect(page.getByTestId('alert-popover-view-traces')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AT-17 CREATED AT column respects app timezone setting', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const utcCell = await firstTimelineRowCreatedAt(page);
|
||||
|
||||
await page.addInitScript(() =>
|
||||
localStorage.setItem('PREFERRED_TIMEZONE', 'Asia/Kolkata'),
|
||||
);
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
expect(await firstTimelineRowCreatedAt(page)).not.toBe(utcCell);
|
||||
});
|
||||
|
||||
test('AT-18 state cell renders Firing, Resolved, and No Data correctly', async ({
|
||||
authedPage: page,
|
||||
resolvedHistory,
|
||||
noDataHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, resolvedHistory.ruleId);
|
||||
const stateCells = timelineRows(page).getByTestId('timeline-row-state');
|
||||
const labels = [...new Set(await stateCells.allInnerTexts())];
|
||||
expect(labels).toContain('Firing');
|
||||
expect(labels).toContain('Resolved');
|
||||
|
||||
await gotoAlertHistory(page, noDataHistory.ruleId);
|
||||
await expect(
|
||||
timelineRows(page).getByTestId('timeline-row-state').first(),
|
||||
).toHaveText('No Data');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
|
||||
test('AT-18b pending/recovering states render blank (coverage gap)', async () => {
|
||||
test.skip(
|
||||
true,
|
||||
'Needs SEED-D (coverage doc §3.5): `pending` and `recovering` are ' +
|
||||
'transient states no cheap fixture produces. AlertState.tsx has no ' +
|
||||
'`case` for either, so both hit `default` and the STATE cell renders ' +
|
||||
'empty — write this as a bug-catch once the seeder endpoint exists.',
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
|
||||
test('AT-18c disabled state renders as "Muted" (coverage gap)', async () => {
|
||||
test.skip(
|
||||
true,
|
||||
'Needs SEED-D (coverage doc §3.5): a `disabled` history row is ' +
|
||||
'policy-driven and the ruler never writes one for a rule we disable ' +
|
||||
'(verified: disabling appends no row).',
|
||||
);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
|
||||
test('AT-20 time-range boundaries inclusive/exclusive (coverage gap)', async () => {
|
||||
test.skip(
|
||||
true,
|
||||
'Needs SEED-D (coverage doc §3.5): asserting a row exactly at `start` ' +
|
||||
'and one at `start-1ms` requires controlling the row timestamps, and ' +
|
||||
'evaluation times are whatever the ruler chose.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import { gotoAlertHistory } from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert history — top contributors', () => {
|
||||
test('AS-05 card displays max 3 rows with count ratios', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const card = page.getByTestId('top-contributors-card');
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText('top contributors');
|
||||
|
||||
const rows = card.getByTestId('top-contributors-row');
|
||||
await expect(rows).toHaveCount(3);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await expect(
|
||||
rows.nth(i).getByTestId('top-contributors-row-count'),
|
||||
).toHaveText(`1/${alertHistory.total}`);
|
||||
await expect(rows.nth(i)).toContainText('service.name');
|
||||
}
|
||||
});
|
||||
|
||||
// AS-05 reads the `count/total` text, which a *different* column renders than
|
||||
// the bar does. Dropping the `/ total * 100` scaling from the bar leaves that
|
||||
// text untouched, so the width needs its own assertion. Radix surfaces the
|
||||
// clamped percent as `aria-valuenow`.
|
||||
test('AS-13 contributor bar width is the count as a percentage of the total', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const rows = page
|
||||
.getByTestId('top-contributors-card')
|
||||
.getByTestId('top-contributors-row');
|
||||
await expect(rows).toHaveCount(3);
|
||||
|
||||
// Every SEED-A contributor fired exactly once out of `total`.
|
||||
const percent = String((1 / alertHistory.total) * 100);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await expect(rows.nth(i).getByRole('progressbar')).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
percent,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('AS-06 "View all" button only appears when more than 3 contributors', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
metricsHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await expect(page.getByTestId('top-contributors-view-all')).toBeVisible();
|
||||
|
||||
await gotoAlertHistory(page, metricsHistory.ruleId);
|
||||
await expect(page.getByTestId('top-contributors-card')).toBeVisible();
|
||||
await expect(page.getByTestId('top-contributors-view-all')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AS-07 View-all drawer shows paginated list of all contributors', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await page.getByTestId('top-contributors-view-all').click();
|
||||
|
||||
const drawer = page.getByTestId('top-contributors-drawer');
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(page.getByText('Viewing All Contributors')).toBeVisible();
|
||||
await expect(drawer.getByTestId('top-contributors-row')).toHaveCount(10);
|
||||
await expect(drawer.locator('.total')).toHaveText(
|
||||
` of ${alertHistory.total}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('AS-07b drawer opens from deep link with ?viewAllTopContributors=true', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
viewAllTopContributors: 'true',
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AS-08 View-all click adds ?viewAllTopContributors=true to URL', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await page.getByTestId('top-contributors-view-all').click();
|
||||
|
||||
await expect(page).toHaveURL(/[?&]viewAllTopContributors=true/);
|
||||
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AS-10 contributor rows show related-logs link for logs-based rules', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await page
|
||||
.getByTestId('top-contributors-card')
|
||||
.getByTestId('top-contributors-row')
|
||||
.first()
|
||||
.getByTestId('top-contributors-row-count')
|
||||
.click();
|
||||
|
||||
await expect(page.getByTestId('alert-popover-view-logs')).toBeVisible();
|
||||
await expect(page.getByTestId('alert-popover-view-traces')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
ALERT_LIST_PAGE_SIZE,
|
||||
alertRuleRows,
|
||||
gotoAlertList,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert rules list — columns', () => {
|
||||
test('LR-01 renders all default columns (Status, Alert Name, Severity, Labels, Actions)', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
|
||||
for (const header of ['Status', 'Alert Name', 'Severity', 'Labels']) {
|
||||
await expect(page.getByRole('columnheader', { name: header })).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: 'Actions' }),
|
||||
).toBeVisible();
|
||||
for (const hidden of [
|
||||
'Created At',
|
||||
'Created By',
|
||||
'Updated At',
|
||||
'Updated By',
|
||||
]) {
|
||||
await expect(page.getByRole('columnheader', { name: hidden })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
|
||||
await expect(page.getByTestId('alert-columns-button')).toBeVisible();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
|
||||
test('LR-02 shows empty state when no rules exist', async () => {
|
||||
test.skip(
|
||||
true,
|
||||
'AlertsEmptyState needs a zero-rule workspace; unreachable without ' +
|
||||
'tearing down SEED-B mid-suite. Covered by the component test.',
|
||||
);
|
||||
});
|
||||
|
||||
test('LR-10 column selector hides and shows a column', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: 'Severity' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId('alert-columns-button').click();
|
||||
await page.getByText('Toggle Columns').waitFor();
|
||||
const severityToggle = page.locator('label', { hasText: 'Severity' });
|
||||
await severityToggle.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: 'Severity' }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: 'Severity' }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId('alert-columns-button').click();
|
||||
await page.locator('label', { hasText: 'Severity' }).click();
|
||||
await expect(
|
||||
page.getByRole('columnheader', { name: 'Severity' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import { alertRuleRows, gotoAlertList } from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert rules list — navigation', () => {
|
||||
test('LR-11 row click opens the overview page', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
|
||||
await alertRuleRows(page).first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/alerts\/overview\?/);
|
||||
await expect(page).toHaveURL(/[?&]ruleId=/);
|
||||
await expect(page).toHaveURL(/[?&]compositeQuery=/);
|
||||
await expect(page).toHaveURL(/[?&]panelTypes=/);
|
||||
});
|
||||
|
||||
test('LR-12 ctrl/cmd-click opens the overview in a new tab', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
|
||||
// Bounded on purpose. This scenario is intermittently red (~1 in 5 at
|
||||
// --workers=1, worse under parallel load): the ctrl+click lands, one or
|
||||
// more popups open, and the `page` event never arrives. Unbounded, the
|
||||
// wait burns the whole test timeout — 120s in a mutation run — which made
|
||||
// it the single largest item on the critical path. The flake is *not*
|
||||
// fixed by this; it just costs seconds instead of minutes when it trips.
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent('page', { timeout: 15_000 }),
|
||||
alertRuleRows(page)
|
||||
.first()
|
||||
.click({ modifiers: ['ControlOrMeta'] }),
|
||||
]);
|
||||
|
||||
await newPage.waitForLoadState();
|
||||
expect(newPage.url()).toContain('/alerts/overview');
|
||||
expect(newPage.url()).toContain('ruleId=');
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
test('LR-13 actions menu Edit and Edit in New Tab navigate correctly', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
|
||||
await alertRuleRows(page).first().getByTestId('alert-actions').click();
|
||||
await page.getByRole('menuitem', { name: 'Edit in New Tab' }).waitFor();
|
||||
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
page.getByRole('menuitem', { name: 'Edit in New Tab' }).click(),
|
||||
]);
|
||||
await newPage.waitForLoadState();
|
||||
expect(newPage.url()).toContain('/alerts/overview');
|
||||
await newPage.close();
|
||||
|
||||
await alertRuleRows(page).first().getByTestId('alert-actions').click();
|
||||
await page.getByRole('menuitem', { name: 'Edit', exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/alerts\/overview\?/);
|
||||
await expect(page).toHaveURL(/[?&]ruleId=/);
|
||||
});
|
||||
|
||||
test('LR-17 New Alert button navigates to alert creation', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
|
||||
await page.getByTestId('list-alerts-new-alert-button').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/alerts\/new/);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect -- documented coverage gap
|
||||
test('LR-18 shows ErrorEmptyState when list fails to load', async () => {
|
||||
test.skip(
|
||||
true,
|
||||
'Not covered: the suite never stubs network, and there is no server-side ' +
|
||||
'way to make GET /api/v1/rules fail on demand. Left explicitly ' +
|
||||
'untested rather than mocked.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
ALERT_LIST_PAGE_SIZE,
|
||||
alertRuleRows,
|
||||
gotoAlertList,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert rules list — pagination and sorting', () => {
|
||||
test('LR-07 navigates between pages', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
|
||||
await expect(page.getByTestId('pagination-total-count')).toContainText(
|
||||
`of ${alertList.count}`,
|
||||
);
|
||||
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
|
||||
|
||||
await page.getByLabel('Go to next page').click();
|
||||
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await expect(alertRuleRows(page)).toHaveCount(
|
||||
alertList.count - ALERT_LIST_PAGE_SIZE,
|
||||
);
|
||||
await expect(page.getByTestId('pagination-total-count')).toContainText(
|
||||
`${ALERT_LIST_PAGE_SIZE + 1} - ${alertList.count}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('LR-08 changes page size', async ({ authedPage: page, alertList }) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
|
||||
|
||||
await page.getByTestId('pagination-page-size').click();
|
||||
await page.getByRole('option', { name: '20', exact: true }).click();
|
||||
|
||||
await expect(page).toHaveURL(/[?&]limit=20/);
|
||||
await expect(alertRuleRows(page)).toHaveCount(alertList.count);
|
||||
});
|
||||
|
||||
test('LR-09 sorts by column header click', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
|
||||
const firstCell = alertRuleRows(page).first();
|
||||
const ascFirst = await firstCell.textContent();
|
||||
|
||||
await page.getByRole('button', { name: 'Alert Name' }).click();
|
||||
await expect(page).toHaveURL(/[?&]orderBy=/);
|
||||
|
||||
await page.getByRole('button', { name: 'Alert Name' }).click();
|
||||
await expect(page).toHaveURL(/[?&]orderBy=/);
|
||||
await expect(alertRuleRows(page).first()).not.toHaveText(ascFirst ?? '');
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import { alertRuleRows, gotoAlertList } from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert rules list — row actions', () => {
|
||||
test('LR-14 Disable then Enable toggles the rule state', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-alert-row-toggle-${Date.now()}`;
|
||||
const ruleId = await ownedRules.threshold(name);
|
||||
|
||||
await gotoAlertList(page, { search: name });
|
||||
const row = alertRuleRows(page).first();
|
||||
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText('OK');
|
||||
|
||||
await row.getByTestId('alert-actions').click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
res.request().method() === 'PATCH',
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Disable' }).click(),
|
||||
]);
|
||||
|
||||
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText(
|
||||
'Disabled',
|
||||
);
|
||||
|
||||
await row.getByTestId('alert-actions').click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
res.request().method() === 'PATCH',
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Enable' }).click(),
|
||||
]);
|
||||
|
||||
await expect(row.getByTestId(`alert-row-${ruleId}-state`)).toHaveText('OK');
|
||||
});
|
||||
|
||||
test('LR-15 Clone creates a copy and shows success toast', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-alert-row-clone-${Date.now()}`;
|
||||
await ownedRules.threshold(name);
|
||||
|
||||
await gotoAlertList(page, { search: name });
|
||||
await alertRuleRows(page).first().getByTestId('alert-actions').click();
|
||||
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
/\/api\/v\d\/rules$/.test(new URL(res.url()).pathname) &&
|
||||
res.request().method() === 'POST',
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Clone' }).click(),
|
||||
]);
|
||||
|
||||
await ownedRules.register(createResponse);
|
||||
|
||||
await expect(page.getByText('Alert cloned successfully')).toBeVisible();
|
||||
|
||||
await gotoAlertList(page, { search: name });
|
||||
await expect(page.getByText(`${name} - Copy`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('LR-16 Delete removes the rule and shows success toast', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-alert-row-delete-${Date.now()}`;
|
||||
const ruleId = await ownedRules.threshold(name);
|
||||
|
||||
await gotoAlertList(page, { search: name });
|
||||
await alertRuleRows(page).first().getByTestId('alert-actions').click();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
res.request().method() === 'DELETE',
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||
]);
|
||||
|
||||
await expect(page.getByText('Alert deleted successfully')).toBeVisible();
|
||||
await expect(page.getByText(name, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
ALERT_LIST_PAGE_SIZE,
|
||||
alertRuleRows,
|
||||
expectFirstPage,
|
||||
gotoAlertList,
|
||||
SEED_B_SEVERITIES,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert rules list — search', () => {
|
||||
test('LR-03 filters by name', async ({ authedPage: page, alertList }) => {
|
||||
await gotoAlertList(page);
|
||||
|
||||
await page
|
||||
.getByTestId('list-alerts-search-input')
|
||||
.fill(`${alertList.namePrefix}-03`);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`search=${alertList.namePrefix}-03`));
|
||||
await expect(alertRuleRows(page)).toHaveCount(1);
|
||||
await expect(page.getByText(`${alertList.namePrefix}-03`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('LR-04 filters by severity and by label', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page);
|
||||
const search = page.getByTestId('list-alerts-search-input');
|
||||
|
||||
const perSeverity = Math.floor(alertList.count / SEED_B_SEVERITIES.length);
|
||||
|
||||
await search.fill('warning');
|
||||
await expect(page).toHaveURL(/search=warning/);
|
||||
await expect(alertRuleRows(page)).not.toHaveCount(0);
|
||||
for (const row of await alertRuleRows(page).all()) {
|
||||
await expect(row).toContainText('warning');
|
||||
}
|
||||
expect(await alertRuleRows(page).count()).toBeGreaterThanOrEqual(perSeverity);
|
||||
|
||||
await search.fill(alertList.paymentsLabel);
|
||||
await expect(page).toHaveURL(new RegExp(`search=${alertList.paymentsLabel}`));
|
||||
await expect(alertRuleRows(page)).toHaveCount(alertList.count / 2);
|
||||
});
|
||||
|
||||
test('LR-05 shows no-results state with clear button', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { search: alertList.namePrefix });
|
||||
|
||||
await page
|
||||
.getByTestId('list-alerts-search-input')
|
||||
.fill('no-such-alert-anywhere');
|
||||
|
||||
await expect(page.getByText('No matching alert rules')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Clear Search' }).click();
|
||||
|
||||
await expect(page).not.toHaveURL(/search=/);
|
||||
await expect(alertRuleRows(page)).toHaveCount(ALERT_LIST_PAGE_SIZE);
|
||||
});
|
||||
|
||||
test('LR-06 resets pagination when searching', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
await gotoAlertList(page, { page: '2' });
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
|
||||
await page.getByTestId('list-alerts-search-input').fill(alertList.namePrefix);
|
||||
|
||||
await expectFirstPage(page);
|
||||
});
|
||||
|
||||
test('LR-19 state and severity filters intersect, they do not union', async ({
|
||||
authedPage: page,
|
||||
alertList,
|
||||
}) => {
|
||||
const perSeverity = alertList.count / SEED_B_SEVERITIES.length;
|
||||
const scoped = { search: alertList.namePrefix };
|
||||
|
||||
// One filter kind: a third of the batch.
|
||||
await gotoAlertList(page, {
|
||||
...scoped,
|
||||
alertRulesFilters: JSON.stringify(['severity:warning']),
|
||||
});
|
||||
await expect(alertRuleRows(page)).toHaveCount(perSeverity);
|
||||
for (const row of await alertRuleRows(page).all()) {
|
||||
await expect(row).toContainText('warning');
|
||||
}
|
||||
|
||||
// Both kinds, both satisfied — seeded rules never fire, so they sit in
|
||||
// `inactive`. The intersection is unchanged.
|
||||
await gotoAlertList(page, {
|
||||
...scoped,
|
||||
alertRulesFilters: JSON.stringify(['severity:warning', 'state:inactive']),
|
||||
});
|
||||
await expect(alertRuleRows(page)).toHaveCount(perSeverity);
|
||||
|
||||
// Both kinds, only one satisfied. Under AND this is empty; under OR the
|
||||
// severity match alone would still surface all `perSeverity` rules.
|
||||
await gotoAlertList(
|
||||
page,
|
||||
{
|
||||
...scoped,
|
||||
alertRulesFilters: JSON.stringify(['severity:warning', 'state:firing']),
|
||||
},
|
||||
{ expectRows: false },
|
||||
);
|
||||
await expect(alertRuleRows(page)).toHaveCount(0);
|
||||
await expect(page.getByText('No matching alert rules')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
import { expect, test } from '../../fixtures/alert-rules';
|
||||
import { ALERTS_LIST_PATH } from '../../helpers/alerts';
|
||||
import { watchConsole } from '../../helpers/common';
|
||||
|
||||
// AL-* — the Alerts page shell: the four top-level tabs, how they map to
|
||||
// `?tab=`, and a navigation smoke check per tab. Tab *internals* (channel CRUD,
|
||||
// planned downtime, routing policies) are deliberately out of scope — each
|
||||
// deserves its own spec file.
|
||||
|
||||
const TAB_NAMES = {
|
||||
triggered: /triggered alerts/i,
|
||||
rules: /alert rules/i,
|
||||
channels: /notification channels/i,
|
||||
configuration: /configuration/i,
|
||||
};
|
||||
|
||||
test.describe('Alerts page shell', () => {
|
||||
test('AL-01 all four top-level tabs render', async ({ authedPage: page }) => {
|
||||
await page.goto(ALERTS_LIST_PATH);
|
||||
|
||||
for (const name of Object.values(TAB_NAMES)) {
|
||||
await expect(page.getByRole('tab', { name })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('AL-02 default tab is Alert Rules', async ({ authedPage: page }) => {
|
||||
await page.goto(ALERTS_LIST_PATH);
|
||||
|
||||
// No `tab` param at all — `getActiveKey()` falls back to AlertRules.
|
||||
await expect(page).toHaveURL(ALERTS_LIST_PATH);
|
||||
await expect(
|
||||
page.getByRole('tab', { name: TAB_NAMES.rules }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
// This spec seeds no rules, so the pane may render either the table (with
|
||||
// its search input) or `AlertsEmptyState` depending on what neighbouring
|
||||
// specs have in flight. Assert the pane itself, not one of its variants —
|
||||
// `ListAlertRules` hides the search input entirely in the empty state.
|
||||
await expect(
|
||||
page
|
||||
.getByRole('heading', { name: 'Alert Rules' })
|
||||
.or(page.getByTestId('list-alerts-search-input')),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('AL-03 tab switch writes ?tab= and clears subTab', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(ALERTS_LIST_PATH);
|
||||
|
||||
await page.getByRole('tab', { name: TAB_NAMES.triggered }).click();
|
||||
await expect(page).toHaveURL(/[?&]tab=TriggeredAlerts/);
|
||||
|
||||
await page.getByRole('tab', { name: TAB_NAMES.channels }).click();
|
||||
await expect(page).toHaveURL(/[?&]tab=Channels/);
|
||||
|
||||
// Entering Configuration adds the default subTab...
|
||||
await page.getByRole('tab', { name: TAB_NAMES.configuration }).click();
|
||||
await expect(page).toHaveURL(/[?&]tab=Configuration/);
|
||||
await expect(page).toHaveURL(/[?&]subTab=planned-downtime/);
|
||||
|
||||
// ...and leaving it drops subTab entirely (onChange rebuilds the search
|
||||
// from scratch rather than mutating the existing params).
|
||||
await page.getByRole('tab', { name: TAB_NAMES.rules }).click();
|
||||
await expect(page).toHaveURL(/[?&]tab=AlertRules/);
|
||||
await expect(page).not.toHaveURL(/subTab=/);
|
||||
});
|
||||
|
||||
test('AL-04 Configuration deep-link', async ({ authedPage: page }) => {
|
||||
// Deep-linking without subTab: the inner Tabs falls back to
|
||||
// planned-downtime for its activeKey without writing it to the URL, so
|
||||
// assert the rendered tab, not the param.
|
||||
await page.goto(`${ALERTS_LIST_PATH}?tab=Configuration`);
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /planned downtime/i }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await page.goto(
|
||||
`${ALERTS_LIST_PATH}?tab=Configuration&subTab=routing-policies`,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole('tab', { name: /routing policies/i }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('AL-05 Triggered Alerts tab smoke', async ({ authedPage: page }) => {
|
||||
// Known application defect, out of scope here: an icon on this tab renders
|
||||
// with a NaN dimension ("<svg> attribute viewBox: Expected number, \"0 0 32
|
||||
// NaN\""). Ignore that one string so the rest of the console guard still
|
||||
// has teeth.
|
||||
const watch = watchConsole(page, {
|
||||
ignore: ['attribute viewBox: Expected number'],
|
||||
});
|
||||
|
||||
await page.goto(`${ALERTS_LIST_PATH}?tab=TriggeredAlerts`);
|
||||
|
||||
// A fresh stack has no firing instances, so either the table or the empty
|
||||
// state is correct — what matters is that the tab mounts and its controls
|
||||
// render.
|
||||
await expect(page.getByTestId('triggered-alerts-search-input')).toBeVisible();
|
||||
expect(watch.errors).toEqual([]);
|
||||
expect(watch.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
test('AL-06 Notification Channels tab smoke', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
// The worker's channel gives this tab a row to render.
|
||||
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
|
||||
|
||||
await expect(page.getByText(alertChannel.name)).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: /new alert channel/i }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('AL-07 tab state survives reload', async ({ authedPage: page }) => {
|
||||
await page.goto(`${ALERTS_LIST_PATH}?tab=Channels`);
|
||||
await expect(
|
||||
page.getByRole('tab', { name: TAB_NAMES.channels }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(page).toHaveURL(/[?&]tab=Channels/);
|
||||
await expect(
|
||||
page.getByRole('tab', { name: TAB_NAMES.channels }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
524
tests/integration/tests/savedview/01_saved_view.py
Normal file
524
tests/integration/tests/savedview/01_saved_view.py
Normal file
@@ -0,0 +1,524 @@
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
BASE_URL = "/api/v2/saved_views"
|
||||
|
||||
|
||||
def _query(*, disabled: bool = False, legend: str = "") -> dict:
|
||||
return {
|
||||
"type": "builder_query",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "logs",
|
||||
"aggregations": [{"expression": "count()"}],
|
||||
"disabled": disabled,
|
||||
"legend": legend,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _body(
|
||||
*,
|
||||
name: str = "my-view",
|
||||
source_page: str = "logs",
|
||||
panel_type: str = "table",
|
||||
max_lines: int = 0,
|
||||
font_size: str = "",
|
||||
fmt: str = "",
|
||||
color: str = "",
|
||||
selected_fields: list | None = None,
|
||||
disabled: bool = False,
|
||||
legend: str = "",
|
||||
) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"sourcePage": source_page,
|
||||
"schemaVersion": "v2",
|
||||
"spec": {
|
||||
"panelType": panel_type,
|
||||
"queries": [_query(disabled=disabled, legend=legend)],
|
||||
"selectedFields": [] if selected_fields is None else selected_fields,
|
||||
"display": {"maxLines": max_lines, "fontSize": font_size, "format": fmt, "color": color},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ─── failure cases (create no saved views) ───────────────────────────────────
|
||||
|
||||
|
||||
def test_create_rejects_wrong_schema_version(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _body()
|
||||
body["schemaVersion"] = "v9"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "saved_view_invalid_input"
|
||||
assert response.json()["error"]["message"] == 'schemaVersion must be "v2", got "v9"'
|
||||
|
||||
|
||||
def test_create_rejects_invalid_panel_type(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _body()
|
||||
body["spec"]["panelType"] = "bogus"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "saved_view_invalid_input"
|
||||
|
||||
|
||||
def test_create_rejects_empty_queries(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _body()
|
||||
body["spec"]["queries"] = []
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
# CompositeQuery.Validate() (querybuildertypesv5) raises this with the generic
|
||||
# invalid_input code, not saved_view_invalid_input -- unlike schemaVersion/
|
||||
# panelType/sourcePage, which are validated directly by savedviewtypes.
|
||||
assert response.json()["error"]["code"] == "invalid_input"
|
||||
assert "at least one query is required" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_create_rejects_invalid_source_page(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
body = _body()
|
||||
body["sourcePage"] = "bogus"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "saved_view_invalid_input"
|
||||
|
||||
|
||||
def test_create_rejects_unknown_field(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# Unlike dashboard v2's PostableDashboardV2 (which has a custom UnmarshalJSON
|
||||
# that rewraps this as its own dashboard_invalid_input code), PostableSavedView
|
||||
# relies solely on binding.WithDisallowUnknownFields, so this surfaces as the
|
||||
# generic invalid_input code rather than saved_view_invalid_input.
|
||||
body = _body()
|
||||
body["unknownfield"] = "boom"
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response.json()["error"]["code"] == "invalid_input"
|
||||
assert "unknown field" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
# ─── not-found cases ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_get_rejects_malformed_id(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/not-a-uuid"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def test_get_missing_view_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.json()["error"]["code"] == "saved_view_not_found"
|
||||
|
||||
|
||||
def test_update_missing_view_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
|
||||
json=_body(),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.json()["error"]["code"] == "saved_view_not_found"
|
||||
|
||||
|
||||
def test_delete_missing_view_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{uuid.uuid4()}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert response.json()["error"]["code"] == "saved_view_not_found"
|
||||
|
||||
|
||||
# ─── lifecycle ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_saved_view_lifecycle(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# ── create ────────────────────────────────────────────────────────────────
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=_body(name="lc-logs-overview", source_page="logs"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
view_id = response.json()["data"]
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=_body(name="lc-traces-overview", source_page="traces"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
try:
|
||||
# ── get echoes back the created shape ────────────────────────────────
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
got = response.json()["data"]
|
||||
assert got["id"] == view_id
|
||||
assert got["name"] == "lc-logs-overview"
|
||||
assert got["sourcePage"] == "logs"
|
||||
assert got["spec"]["panelType"] == "table"
|
||||
|
||||
# ── list filters by sourcePage and name ──────────────────────────────
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"sourcePage": "logs"},
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert {v["name"] for v in response.json()["data"]} == {"lc-logs-overview"}
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
params={"sourcePage": "logs", "name": "overview"},
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert {v["name"] for v in response.json()["data"]} == {"lc-logs-overview"}
|
||||
|
||||
# ── update mutates name, sourcePage and spec ─────────────────────────
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
json=_body(name="lc-logs-renamed", source_page="metrics", panel_type="graph"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
updated = response.json()["data"]
|
||||
assert updated["name"] == "lc-logs-renamed"
|
||||
assert updated["sourcePage"] == "metrics"
|
||||
assert updated["spec"]["panelType"] == "graph"
|
||||
finally:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# ── delete removes it from get and list ──────────────────────────────────
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
# ─── round-trip serialization: zero/empty values must not get corrupted ──────
|
||||
# A value that's genuinely zero (maxLines: 0), empty (""), or an explicit empty
|
||||
# list must survive being written and read back exactly as sent — never dropped,
|
||||
# defaulted, or turned into null. The riskier case is not create -> GET (a fresh
|
||||
# row), it's UPDATE -> GET: overwriting a *previously non-zero* value down to its
|
||||
# zero value must actually take effect on the persisted row, not silently retain
|
||||
# the old value or lose the field. See test_dashboard_v2_roundtrip_preserves_zero_values
|
||||
# for the analogous dashboard v2 case.
|
||||
|
||||
|
||||
def test_create_roundtrip_preserves_zero_values(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=_body(
|
||||
name="create-zero-values",
|
||||
max_lines=0,
|
||||
font_size="",
|
||||
fmt="",
|
||||
color="",
|
||||
selected_fields=[],
|
||||
disabled=False,
|
||||
legend="",
|
||||
),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
view_id = response.json()["data"]
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
spec = response.json()["data"]["spec"]
|
||||
query = spec["queries"][0]["spec"]
|
||||
|
||||
cases = [
|
||||
("maxLines 0", spec["display"]["maxLines"], 0),
|
||||
("fontSize empty", spec["display"]["fontSize"], ""),
|
||||
("format empty", spec["display"]["format"], ""),
|
||||
("color empty", spec["display"]["color"], ""),
|
||||
("selectedFields explicit empty list", spec["selectedFields"], []),
|
||||
("query disabled false", query["disabled"], False),
|
||||
("query legend empty", query["legend"], ""),
|
||||
]
|
||||
for description, actual, expected in cases:
|
||||
assert actual == expected, description
|
||||
finally:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
|
||||
def test_selected_fields_omitted_on_create_reads_back_as_empty_list_not_null(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
body = _body(name="omitted-selected-fields")
|
||||
del body["spec"]["selectedFields"]
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=body,
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
view_id = response.json()["data"]
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["spec"]["selectedFields"] == []
|
||||
finally:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
|
||||
def test_update_does_not_corrupt_zero_values(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
"""The failure mode this guards against: an update that writes maxLines=0 (or
|
||||
any other zero/empty value) either silently keeps the previous non-zero value
|
||||
(a partial-update bug) or drops/nulls the field on read-back (a serialization
|
||||
bug). Both are round-trip corruption; only an exact zero on GET proves neither
|
||||
happened."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# ── create with deliberately non-zero values everywhere ──────────────────
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(BASE_URL),
|
||||
json=_body(
|
||||
name="update-zero-values",
|
||||
max_lines=25,
|
||||
font_size="large",
|
||||
fmt="table",
|
||||
color="blue",
|
||||
selected_fields=[{"name": "service.name"}],
|
||||
disabled=True,
|
||||
legend="Custom Legend",
|
||||
),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
view_id = response.json()["data"]
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
spec = response.json()["data"]["spec"]
|
||||
assert spec["display"]["maxLines"] == 25
|
||||
# signal/fieldContext/fieldDataType always serialize on TelemetryFieldKey
|
||||
# (no omitempty -- see pkg/types/telemetrytypes/field.go), so an entry sent
|
||||
# with only "name" reads back with those three as explicit "".
|
||||
assert spec["selectedFields"] == [{"name": "service.name", "signal": "", "fieldContext": "", "fieldDataType": ""}]
|
||||
assert spec["queries"][0]["spec"]["disabled"] is True
|
||||
|
||||
# ── update overwrites every one of those fields down to its zero value ──
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
json=_body(
|
||||
name="update-zero-values",
|
||||
max_lines=0,
|
||||
font_size="",
|
||||
fmt="",
|
||||
color="",
|
||||
selected_fields=[],
|
||||
disabled=False,
|
||||
legend="",
|
||||
),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
|
||||
# ── the zero values took effect -- not retained, not dropped, not null ──
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
spec = response.json()["data"]["spec"]
|
||||
query = spec["queries"][0]["spec"]
|
||||
|
||||
cases = [
|
||||
("maxLines reset to 0", spec["display"]["maxLines"], 0),
|
||||
("fontSize reset to empty", spec["display"]["fontSize"], ""),
|
||||
("format reset to empty", spec["display"]["format"], ""),
|
||||
("color reset to empty", spec["display"]["color"], ""),
|
||||
("selectedFields reset to empty list", spec["selectedFields"], []),
|
||||
("query disabled reset to false", query["disabled"], False),
|
||||
("query legend reset to empty", query["legend"], ""),
|
||||
]
|
||||
for description, actual, expected in cases:
|
||||
assert actual == expected, description
|
||||
finally:
|
||||
requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{view_id}"),
|
||||
headers=headers,
|
||||
timeout=5,
|
||||
)
|
||||
Reference in New Issue
Block a user