mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-26 14:30:40 +01:00
Compare commits
11 Commits
v0.139.0
...
issue_4501
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58a52529c3 | ||
|
|
02c5555a48 | ||
|
|
0bdc7bf6a1 | ||
|
|
a8c04cb563 | ||
|
|
bb57adcdee | ||
|
|
71dc06bc7e | ||
|
|
435471a18d | ||
|
|
816905f4cf | ||
|
|
691f724480 | ||
|
|
e04f26f5b7 | ||
|
|
4a72aab47c |
@@ -20,6 +20,16 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Extended fixtures:** For features needing complex setup (seeded data, API calls, cleanup), import from domain-specific fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the full pattern.
|
||||
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
|
||||
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
|
||||
```ts
|
||||
// Alert list tests - need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// Alert history tests - need evaluated history rows
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
|
||||
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
|
||||
|
||||
@@ -49,6 +49,7 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
|
||||
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
|
||||
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
|
||||
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
|
||||
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
|
||||
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
|
||||
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
|
||||
|
||||
@@ -8010,6 +8010,7 @@ components:
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
- ai_observability
|
||||
type: string
|
||||
SavedviewtypesUpdatableSavedView:
|
||||
properties:
|
||||
@@ -22949,6 +22950,73 @@ paths:
|
||||
summary: Rotate session
|
||||
tags:
|
||||
- sessions
|
||||
/api/v2/system/dashboards/{name}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
|
||||
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
|
||||
are read-only and upgraded through releases. The dashboard's own `name` field
|
||||
carries a reserved prefix that the path segment must not include.
|
||||
operationId: GetSystemDashboard
|
||||
parameters:
|
||||
- in: path
|
||||
name: name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
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:
|
||||
- dashboard:read
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
summary: Get system dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/user_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -112,6 +112,41 @@ These two folders look similar but mean different things:
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
### Extended fixtures
|
||||
|
||||
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
|
||||
|
||||
**Fixture scopes:**
|
||||
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
|
||||
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
|
||||
|
||||
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
|
||||
|
||||
```
|
||||
fixtures/alerts/
|
||||
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
|
||||
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
|
||||
```
|
||||
|
||||
Specs import from the fixture they need:
|
||||
|
||||
```ts
|
||||
// List tests — just need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// History tests — need history rows from ruler evaluation
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
|
||||
**When creating new fixtures:**
|
||||
|
||||
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
|
||||
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
|
||||
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
|
||||
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
|
||||
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
|
||||
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
@@ -232,11 +267,14 @@ cd tests/e2e
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single sub-area
|
||||
npx playwright test tests/alerts/history/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
npx playwright test tests/alerts/page.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
npx playwright test --project=chromium -g "AL-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
@@ -270,7 +308,14 @@ yarn test:staging
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) → `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
|
||||
|
||||
```bash
|
||||
# runs against a locally served frontend, not whatever .env.local points at
|
||||
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
|
||||
```
|
||||
|
||||
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
|
||||
|
||||
### Playwright options
|
||||
|
||||
|
||||
@@ -276,6 +276,10 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.GetByNameV2(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
|
||||
}
|
||||
@@ -284,6 +288,10 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
func (module *module) UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.UpdateUnsafeV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
// anything else
|
||||
console.error('any');
|
||||
console.error('ErrorResponseHandler: unclassified error');
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
|
||||
@@ -46,6 +46,8 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
GetSystemDashboard200,
|
||||
GetSystemDashboardPathParameters,
|
||||
ListDashboardViews200,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
@@ -2111,6 +2113,108 @@ export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const getSystemDashboard = (
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSystemDashboard200>({
|
||||
url: `/api/v2/system/dashboards/${name}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryKey = ({
|
||||
name,
|
||||
}: GetSystemDashboardPathParameters) => {
|
||||
return [`/api/v2/system/dashboards/${name}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
> = ({ signal }) => getSystemDashboard({ name }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!name,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSystemDashboardQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
>;
|
||||
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
|
||||
export function useGetSystemDashboard<
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const invalidateGetSystemDashboard = async (
|
||||
queryClient: QueryClient,
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
|
||||
* @summary List dashboards for the current user (v2)
|
||||
|
||||
@@ -9021,6 +9021,7 @@ export enum SavedviewtypesSourceDTO {
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
ai_observability = 'ai_observability',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display?: SavedviewtypesDisplayDTO;
|
||||
@@ -12249,6 +12250,17 @@ export type RotateSession200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetSystemDashboardPathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type GetSystemDashboard200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUserRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
|
||||
@@ -8,12 +8,14 @@ export interface AlertBreadcrumbProps {
|
||||
items: BreadcrumbItemConfig[];
|
||||
className?: string;
|
||||
showDivider?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function AlertBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
showDivider = true,
|
||||
testId,
|
||||
}: AlertBreadcrumbProps): JSX.Element {
|
||||
const breadcrumbItems = items.map((item) => ({
|
||||
title: <BreadcrumbItem {...item} />,
|
||||
@@ -24,6 +26,7 @@ function AlertBreadcrumb({
|
||||
<Breadcrumb
|
||||
className={`${styles.breadcrumb} ${className || ''}`}
|
||||
items={breadcrumbItems}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{showDivider && <Divider className={styles.divider} />}
|
||||
</>
|
||||
|
||||
@@ -29,6 +29,7 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -40,6 +41,7 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
>
|
||||
<div className="icon">
|
||||
<DraftingCompass
|
||||
|
||||
@@ -26,7 +26,10 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -38,7 +41,10 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -50,7 +56,10 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -103,7 +112,12 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div
|
||||
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
|
||||
data-testid="stats-card"
|
||||
data-stats-title={title}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -123,7 +137,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,7 +81,11 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,11 +48,16 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card">
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="top-contributors-card__header">
|
||||
<div className="title">top contributors</div>
|
||||
{topContributorsData.length > 3 && (
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<Button
|
||||
type="text"
|
||||
className="view-all"
|
||||
onClick={toggleViewAllDrawer}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,7 +68,10 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div className="total-contribution">
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -78,7 +81,10 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,7 +31,10 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -32,8 +32,8 @@ function GraphWrapper({
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
|
||||
@@ -118,7 +118,10 @@ function TimelineTableContent(): JSX.Element {
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
@@ -128,12 +131,15 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-table">
|
||||
<div className="timeline-table" data-testid="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div className="timeline-table__filter-search">
|
||||
<div
|
||||
className="timeline-table__filter-search"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
@@ -155,6 +161,7 @@ function TimelineTableContent(): JSX.Element {
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -172,14 +179,17 @@ function TimelineTableContent(): JSX.Element {
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error">
|
||||
<div className="timeline-table__error" data-testid="timeline-error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div className="timeline-table__pagination-info">
|
||||
<div
|
||||
className="timeline-table__pagination-info"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
|
||||
@@ -21,18 +21,14 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
<AlertState state={value} showLabel testId="timeline-row-state" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'LABELS',
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
<AlertLabels labels={labels} testId="timeline-row-labels" />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -40,7 +36,10 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule__created-at">
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -53,7 +52,7 @@ export const timelineTableColumns = ({
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled>
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -65,7 +64,7 @@ export const timelineTableColumns = ({
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
|
||||
@@ -23,6 +23,7 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -33,6 +34,7 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -57,14 +59,17 @@ function TimelineFilters(): JSX.Element {
|
||||
{
|
||||
value: TimelineFilter.ALL,
|
||||
label: 'All',
|
||||
testId: 'timeline-filter-all',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.FIRED,
|
||||
label: 'Fired',
|
||||
testId: 'timeline-filter-fired',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.RESOLVED,
|
||||
label: 'Resolved',
|
||||
testId: 'timeline-filter-resolved',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
testId="send-notification-if-data-is-missing-input"
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
@@ -66,6 +67,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
testId="enforce-minimum-datapoints-input"
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,7 @@ function EvaluationWindowPopover({
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
data-testid={`${sectionId}-option-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -186,6 +186,7 @@ function Footer(): JSX.Element {
|
||||
color="primary"
|
||||
onClick={handleSaveAlert}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="save-alert-rule-button"
|
||||
>
|
||||
{isCreatingAlertRule || isUpdatingAlertRule ? (
|
||||
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
|
||||
@@ -218,6 +219,7 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleTestNotification}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="test-notification-button"
|
||||
>
|
||||
{isTestingAlertRule ? (
|
||||
<Loader data-testid="test-notification-loader-icon" size={14} />
|
||||
@@ -249,6 +251,7 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
disabled={disableButtons}
|
||||
testId="discard-alert-rule-button"
|
||||
>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
|
||||
@@ -119,6 +119,7 @@ function BasicInfo({
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
@@ -147,6 +148,7 @@ function BasicInfo({
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
@@ -161,6 +163,7 @@ function BasicInfo({
|
||||
name={['annotations', 'description']}
|
||||
>
|
||||
<TextareaMedium
|
||||
data-testid="alert-description-input"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
|
||||
@@ -105,7 +105,7 @@ function QuerySection({
|
||||
{
|
||||
label: (
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns">
|
||||
<Button className="nav-btns" data-testid="query-builder-tab">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
@@ -122,7 +122,11 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -162,7 +166,11 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -180,7 +188,11 @@ function QuerySection({
|
||||
: 'PromQL'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="promql-tab"
|
||||
>
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
|
||||
@@ -80,6 +80,7 @@ function RuleOptions({
|
||||
defaultValue={defaultCompareOp}
|
||||
value={alertDef.condition?.op}
|
||||
style={{ minWidth: '120px' }}
|
||||
data-testid="alert-threshold-op-select"
|
||||
onChange={(value: string | unknown): void => {
|
||||
const newOp = (value as string) || '';
|
||||
|
||||
@@ -116,6 +117,7 @@ function RuleOptions({
|
||||
defaultValue={defaultMatchType}
|
||||
style={{ minWidth: '130px' }}
|
||||
value={alertDef.condition?.matchType}
|
||||
data-testid="alert-threshold-match-type-select-v1"
|
||||
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
|
||||
>
|
||||
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
|
||||
@@ -177,6 +179,7 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -194,6 +197,7 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -395,6 +399,7 @@ function RuleOptions({
|
||||
value={alertDef?.condition?.target}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
data-testid="alert-threshold-target-input"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -844,8 +844,6 @@ function FormAlertRules({
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className={`form-alert-rules-container ${
|
||||
@@ -968,6 +966,7 @@ function FormAlertRules({
|
||||
!isChannelConfigurationValid ||
|
||||
queryStatus === 'error'
|
||||
}
|
||||
data-testid="alert-save-button"
|
||||
>
|
||||
{isNewRule ? t('button_createrule') : t('button_savechanges')}
|
||||
</ActionButton>
|
||||
@@ -981,6 +980,7 @@ function FormAlertRules({
|
||||
}
|
||||
type="default"
|
||||
onClick={onTestRuleHandler}
|
||||
data-testid="alert-test-button"
|
||||
>
|
||||
{' '}
|
||||
{t('button_testrule')}
|
||||
@@ -989,6 +989,7 @@ function FormAlertRules({
|
||||
disabled={loading || false}
|
||||
type="default"
|
||||
onClick={onCancelHandler}
|
||||
data-testid="alert-cancel-button"
|
||||
>
|
||||
{isNewRule && t('button_cancelchanges')}
|
||||
{ruleId && !isEmpty(ruleId) && t('button_discard')}
|
||||
@@ -998,6 +999,7 @@ function FormAlertRules({
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
testId="alert-save-confirm-dialog"
|
||||
open={isConfirmSaveOpen}
|
||||
onOpenChange={setIsConfirmSaveOpen}
|
||||
title={t('confirm_save_title')}
|
||||
|
||||
@@ -174,6 +174,7 @@ function LabelSelect({
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
|
||||
@@ -94,6 +94,8 @@ function AlertDetails(): JSX.Element {
|
||||
>
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
data-testid="alert-details-root"
|
||||
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
|
||||
@@ -117,7 +117,11 @@ function AlertActionButtons({
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
<Switch
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
@@ -129,6 +133,7 @@ function AlertActionButtons({
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
|
||||
@@ -47,21 +47,26 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
<div className="alert-title">
|
||||
<AlertState
|
||||
state={alertRuleState ?? state ?? ''}
|
||||
testId="alert-header-state"
|
||||
/>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && <AlertSeverity severity={labels.severity} />}
|
||||
{labels?.severity && (
|
||||
<AlertSeverity severity={labels.severity} testId="alert-header-severity" />
|
||||
)}
|
||||
|
||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||
{/* <AlertStatus
|
||||
status="firing"
|
||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||
/> */}
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
<AlertLabels labels={labelsWithoutSeverity} testId="alert-header-labels" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,14 +6,16 @@ import './AlertLabels.styles.scss';
|
||||
export type AlertLabelsProps = {
|
||||
labels: Record<string, any>;
|
||||
initialCount?: number;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
function AlertLabels({
|
||||
labels,
|
||||
initialCount = 2,
|
||||
testId,
|
||||
}: AlertLabelsProps): JSX.Element {
|
||||
return (
|
||||
<div className="alert-labels">
|
||||
<div className="alert-labels" data-testid={testId}>
|
||||
<SeeMore initialCount={initialCount} moreLabel="More">
|
||||
{Object.entries(labels).map(([key, value]) => (
|
||||
<KeyValueLabel key={`label-${key}`} badgeKey={key} badgeValue={value} />
|
||||
@@ -25,6 +27,7 @@ function AlertLabels({
|
||||
|
||||
AlertLabels.defaultProps = {
|
||||
initialCount: 2,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default AlertLabels;
|
||||
|
||||
@@ -32,8 +32,10 @@ const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
|
||||
|
||||
export default function AlertSeverity({
|
||||
severity,
|
||||
testId,
|
||||
}: {
|
||||
severity: string;
|
||||
testId?: string;
|
||||
}): JSX.Element {
|
||||
const severityDetails = useMemo(() => {
|
||||
if (severityConfig[severity]) {
|
||||
@@ -52,9 +54,16 @@ export default function AlertSeverity({
|
||||
};
|
||||
}, [severity]);
|
||||
return (
|
||||
<div className={`alert-severity ${severityDetails.className}`}>
|
||||
<div
|
||||
className={`alert-severity ${severityDetails.className}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="alert-severity__icon">{severityDetails.icon}</div>
|
||||
<div className="alert-severity__text">{severityDetails.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AlertSeverity.defaultProps = {
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -8,11 +8,13 @@ import './AlertState.styles.scss';
|
||||
type AlertStateProps = {
|
||||
state: RuletypesAlertStateDTO | string;
|
||||
showLabel?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export default function AlertState({
|
||||
state,
|
||||
showLabel,
|
||||
testId,
|
||||
}: AlertStateProps): JSX.Element {
|
||||
let icon;
|
||||
let label;
|
||||
@@ -64,7 +66,7 @@ export default function AlertState({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="alert-state">
|
||||
<div className="alert-state" data-testid={testId}>
|
||||
{icon} {showLabel && <div className="alert-state__label">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -72,4 +74,5 @@ export default function AlertState({
|
||||
|
||||
AlertState.defaultProps = {
|
||||
showLabel: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
|
||||
@@ -13,6 +13,8 @@ interface Tab {
|
||||
disabled?: boolean;
|
||||
icon?: string | JSX.Element;
|
||||
isBeta?: boolean;
|
||||
/** Optional `data-testid` for the tab button. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
interface TimelineTabsProps {
|
||||
@@ -63,6 +65,7 @@ function Tabs2({
|
||||
disabled={tab.disabled}
|
||||
icon={tab.icon}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
data-testid={tab.testId}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -80,6 +81,8 @@ type provider struct {
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
systemDashboardModule systemdashboard.Module
|
||||
systemDashboardHandler systemdashboard.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -118,6 +121,8 @@ func NewFactory(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
systemDashboardModule systemdashboard.Module,
|
||||
systemDashboardHandler systemdashboard.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -159,6 +164,8 @@ func NewFactory(
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
systemDashboardModule,
|
||||
systemDashboardHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -202,6 +209,8 @@ func newProvider(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
systemDashboardModule systemdashboard.Module,
|
||||
systemDashboardHandler systemdashboard.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -244,6 +253,8 @@ func newProvider(
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
systemDashboardModule: systemDashboardModule,
|
||||
systemDashboardHandler: systemDashboardHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -296,6 +307,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addSystemDashboardRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addMetricsExplorerRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
63
pkg/apiserver/signozapiserver/systemdashboard.go
Normal file
63
pkg/apiserver/signozapiserver/systemdashboard.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addSystemDashboardRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/system/dashboards/{name}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.systemDashboardHandler.Get, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetSystemDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get system dashboard",
|
||||
Description: "Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: provider.systemDashboardID(),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// systemDashboardID resolves the {name} path param to the dashboard's id. Authz
|
||||
// tuples and audit records are written against ids, so the name has to be
|
||||
// resolved before either runs.
|
||||
func (provider *provider) systemDashboardID() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
ctx := ec.Request.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
id, err := provider.systemDashboardModule.ResolveID(ctx, valuer.MustNewUUID(claims.OrgID), mux.Vars(ec.Request)["name"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return id.StringValue(), nil
|
||||
})
|
||||
}
|
||||
@@ -63,6 +63,8 @@ type Module interface {
|
||||
|
||||
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored in the v1 schema.
|
||||
MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
@@ -72,6 +74,9 @@ type Module interface {
|
||||
|
||||
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// UpdateUnsafeV2 updates a dashboard bypassing the guards. Intended for internal system callers.
|
||||
UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
|
||||
|
||||
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
@@ -64,6 +64,23 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByName(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableDashboard, error) {
|
||||
storableDashboard := new(dashboardtypes.StorableDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storableDashboard).
|
||||
Where("name = ?", name).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with name %s doesn't exist", name)
|
||||
}
|
||||
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
|
||||
// spec calls for. Aliases:
|
||||
//
|
||||
|
||||
@@ -19,9 +19,12 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
dashboard, err := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -120,6 +123,20 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
storable, err := module.store.GetByName(ctx, orgID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, storable.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
|
||||
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
@@ -179,13 +196,32 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
|
||||
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.Update)
|
||||
}
|
||||
|
||||
func (module *module) UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.UpdateUnsafe)
|
||||
}
|
||||
|
||||
// apply is existing.Update or existing.UpdateUnsafe, so the gated path keeps its
|
||||
// in-transaction checks and only UpdateUnsafeV2 skips them.
|
||||
func (module *module) updateV2(ctx context.Context, orgID valuer.UUID, existing *dashboardtypes.DashboardV2, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2, apply func(dashboardtypes.UpdatableDashboardV2, string, []*tagtypes.Tag) error) (*dashboardtypes.DashboardV2, error) {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, existing.ID, updatable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = existing.Update(updatable, updatedBy, resolvedTags)
|
||||
err = apply(updatable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,18 +6,20 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type setter struct {
|
||||
store types.OrganizationStore
|
||||
alertmanager alertmanager.Alertmanager
|
||||
quickfilter quickfilter.Module
|
||||
store types.OrganizationStore
|
||||
alertmanager alertmanager.Alertmanager
|
||||
quickfilter quickfilter.Module
|
||||
systemDashboard systemdashboard.Module
|
||||
}
|
||||
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, systemDashboard systemdashboard.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, systemDashboard: systemDashboard}
|
||||
}
|
||||
|
||||
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
|
||||
@@ -37,6 +39,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.systemDashboard.Reconcile(ctx, organization.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
|
||||
)
|
||||
|
||||
const definitionsRoot = "fs/definitions"
|
||||
|
||||
//go:embed fs/definitions/*.json
|
||||
var definitionFiles embed.FS
|
||||
|
||||
// NewRegistry parses every embedded definition. Definitions are build-time assets
|
||||
// validated by a test, so a failure here means the binary shipped broken JSON.
|
||||
func NewRegistry() (systemdashboardtypes.Registry, error) {
|
||||
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
|
||||
if err != nil {
|
||||
return systemdashboardtypes.Registry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read system dashboard definitions")
|
||||
}
|
||||
|
||||
definitions := make([]systemdashboardtypes.Definition, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
file := path.Join(definitionsRoot, entry.Name())
|
||||
raw, err := definitionFiles.ReadFile(file)
|
||||
if err != nil {
|
||||
return systemdashboardtypes.Registry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
|
||||
}
|
||||
|
||||
definition, err := systemdashboardtypes.NewDefinition(raw)
|
||||
if err != nil {
|
||||
return systemdashboardtypes.Registry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
|
||||
return systemdashboardtypes.NewRegistry(definitions)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A schema migration cannot ship without updating the definitions: parsing them
|
||||
// runs the same validation a create goes through, at the current schemaVersion.
|
||||
func TestEmbeddedDefinitionsParseAtCurrentSchemaVersion(t *testing.T) {
|
||||
registry, err := NewRegistry()
|
||||
require.NoError(t, err)
|
||||
|
||||
// The frontend addresses the overview dashboard by this name.
|
||||
_, ok := registry.Get(dashboardtypes.SystemDashboardNamePrefix + "ai-o11y-overview")
|
||||
assert.True(t, ok)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"schemaVersion": "v6",
|
||||
"name": "signoz---ai-o11y-overview",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "AI Observability Overview",
|
||||
"description": "Overview of LLM traffic. Panels ship in an upcoming release."
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {},
|
||||
"layouts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
48
pkg/modules/systemdashboard/implsystemdashboard/handler.go
Normal file
48
pkg/modules/systemdashboard/implsystemdashboard/handler.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module systemdashboard.Module
|
||||
}
|
||||
|
||||
func NewHandler(module systemdashboard.Module) systemdashboard.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw 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(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
name := mux.Vars(r)["name"]
|
||||
if name == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is missing in the path"))
|
||||
return
|
||||
}
|
||||
|
||||
systemDashboard, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), name)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, systemDashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
151
pkg/modules/systemdashboard/implsystemdashboard/module.go
Normal file
151
pkg/modules/systemdashboard/implsystemdashboard/module.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
store systemdashboardtypes.Store
|
||||
registry systemdashboardtypes.Registry
|
||||
dashboardModule dashboard.Module
|
||||
}
|
||||
|
||||
func NewModule(
|
||||
providerSettings factory.ProviderSettings,
|
||||
store systemdashboardtypes.Store,
|
||||
registry systemdashboardtypes.Registry,
|
||||
dashboardModule dashboard.Module,
|
||||
) systemdashboard.Module {
|
||||
return &module{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"),
|
||||
store: store,
|
||||
registry: registry,
|
||||
dashboardModule: dashboardModule,
|
||||
}
|
||||
}
|
||||
|
||||
func (module *module) Reconcile(ctx context.Context, orgID valuer.UUID) error {
|
||||
for _, definition := range module.registry.List() {
|
||||
if err := module.reconcile(ctx, orgID, definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) reconcile(ctx context.Context, orgID valuer.UUID, definition systemdashboardtypes.Definition) error {
|
||||
existing, err := module.dashboardModule.GetByNameV2(ctx, orgID, definition.Name())
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
return module.provision(ctx, orgID, definition)
|
||||
}
|
||||
|
||||
// Anything but the provisioner in updated_by means a foreign write. Leave the
|
||||
// row alone — never overwriting is the safe direction.
|
||||
if existing.UpdatedBy != systemdashboardtypes.ProvisionerIdentity {
|
||||
module.settings.Logger().WarnContext(ctx, "skipping system dashboard reconcile: last write was not by the provisioner", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()), slog.String("updated_by", existing.UpdatedBy))
|
||||
return nil
|
||||
}
|
||||
|
||||
state, err := module.store.Get(ctx, orgID, definition.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only ever move forward: a downgrade must not rewrite the newer content.
|
||||
if state.Version >= definition.Version {
|
||||
return nil
|
||||
}
|
||||
|
||||
return module.upgrade(ctx, orgID, existing.ID, definition)
|
||||
}
|
||||
|
||||
// provision creates the dashboard and its state row in one transaction, so a
|
||||
// system dashboard can never exist without the version it was provisioned at.
|
||||
// A concurrent provisioner (another replica, or the org-creation hook racing the
|
||||
// startup sweep) loses on the state row's unique (org_id, name) index and rolls back.
|
||||
func (module *module) provision(ctx context.Context, orgID valuer.UUID, definition systemdashboardtypes.Definition) error {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
created, err := module.dashboardModule.CreateV2(
|
||||
ctx,
|
||||
orgID,
|
||||
systemdashboardtypes.ProvisionerIdentity,
|
||||
valuer.UUID{},
|
||||
dashboardtypes.SourceSystem,
|
||||
definition.Dashboard,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.Create(ctx, systemdashboardtypes.NewStorableSystemDashboard(orgID, created.ID, definition.Name(), definition.Version))
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
module.settings.Logger().DebugContext(ctx, "system dashboard already provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
module.settings.Logger().InfoContext(ctx, "provisioned system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) upgrade(ctx context.Context, orgID valuer.UUID, id valuer.UUID, definition systemdashboardtypes.Definition) error {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if _, err := module.dashboardModule.UpdateUnsafeV2(ctx, orgID, id, systemdashboardtypes.ProvisionerIdentity, definition.ToUpdatable()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.UpdateVersion(ctx, orgID, definition.Name(), definition.Version)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
module.settings.Logger().InfoContext(ctx, "upgraded system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.get(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (module *module) ResolveID(ctx context.Context, orgID valuer.UUID, name string) (valuer.UUID, error) {
|
||||
existing, err := module.get(ctx, orgID, name)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return existing.ID, nil
|
||||
}
|
||||
|
||||
func (module *module) get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
if strings.HasPrefix(name, dashboardtypes.SystemDashboardNamePrefix) {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "name must not carry the %q prefix", dashboardtypes.SystemDashboardNamePrefix)
|
||||
}
|
||||
|
||||
existing, err := module.dashboardModule.GetByNameV2(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := existing.ErrIfNotSystem(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
209
pkg/modules/systemdashboard/implsystemdashboard/module_test.go
Normal file
209
pkg/modules/systemdashboard/implsystemdashboard/module_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testDashboardName = "test-overview"
|
||||
|
||||
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, model := range []any{
|
||||
(*dashboardtypes.StorableDashboard)(nil),
|
||||
(*tagtypes.Tag)(nil),
|
||||
(*tagtypes.TagRelation)(nil),
|
||||
(*systemdashboardtypes.StorableSystemDashboard)(nil),
|
||||
} {
|
||||
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_system_dashboard_org_name ON system_dashboard (org_id, name)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...systemdashboardtypes.Definition) (*module, dashboard.Module) {
|
||||
t.Helper()
|
||||
|
||||
providerSettings := factorytest.NewSettings()
|
||||
dashboardModule := impldashboard.NewModule(
|
||||
impldashboard.NewStore(sqlStore),
|
||||
providerSettings,
|
||||
analyticstest.New(),
|
||||
nil,
|
||||
queryparser.New(providerSettings),
|
||||
impltag.NewModule(impltag.NewStore(sqlStore)),
|
||||
)
|
||||
|
||||
registry, err := systemdashboardtypes.NewRegistry(definitions)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewModule(providerSettings, NewStore(sqlStore), registry, dashboardModule).(*module), dashboardModule
|
||||
}
|
||||
|
||||
func newTestDefinition(t *testing.T, version int, displayName string) systemdashboardtypes.Definition {
|
||||
t.Helper()
|
||||
|
||||
raw := `{
|
||||
"version": ` + strconv.Itoa(version) + `,
|
||||
"definition": {
|
||||
"schemaVersion": "` + dashboardtypes.SchemaVersion + `",
|
||||
"name": "` + dashboardtypes.SystemDashboardNamePrefix + testDashboardName + `",
|
||||
"tags": [],
|
||||
"spec": {"display": {"name": "` + displayName + `"}, "variables": [], "panels": {}, "layouts": []}
|
||||
}
|
||||
}`
|
||||
|
||||
definition, err := systemdashboardtypes.NewDefinition([]byte(raw))
|
||||
require.NoError(t, err)
|
||||
|
||||
return definition
|
||||
}
|
||||
|
||||
func TestReconcileProvisionsThenUpgradesUntilTheRowIsModified(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
systemDashboardModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
|
||||
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
|
||||
|
||||
provisioned, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dashboardtypes.SourceSystem, provisioned.Source)
|
||||
assert.Equal(t, systemdashboardtypes.ProvisionerIdentity, provisioned.CreatedBy)
|
||||
assert.Equal(t, "v1", provisioned.Spec.Display.Name)
|
||||
assert.Equal(t, 1, stateVersion(t, systemDashboardModule, ctx, orgID))
|
||||
|
||||
// Reconciling the same version again is a no-op.
|
||||
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
|
||||
unchanged, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, provisioned.UpdatedAt, unchanged.UpdatedAt)
|
||||
|
||||
// An unmodified copy is upgraded in place, keeping its id.
|
||||
upgradingModule, dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
|
||||
require.NoError(t, upgradingModule.Reconcile(ctx, orgID))
|
||||
|
||||
upgraded, err := upgradingModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, provisioned.ID, upgraded.ID)
|
||||
assert.Equal(t, "v2", upgraded.Spec.Display.Name)
|
||||
assert.Equal(t, 2, stateVersion(t, upgradingModule, ctx, orgID))
|
||||
|
||||
// Once anything but the provisioner writes the row, later releases leave it alone.
|
||||
updatable := newTestDefinition(t, 2, "edited out of band").ToUpdatable()
|
||||
_, err = dashboardModule.UpdateUnsafeV2(ctx, orgID, upgraded.ID, "user@signoz.io", updatable)
|
||||
require.NoError(t, err)
|
||||
|
||||
shippingModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
|
||||
require.NoError(t, shippingModule.Reconcile(ctx, orgID))
|
||||
|
||||
untouched, err := shippingModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user@signoz.io", untouched.UpdatedBy)
|
||||
assert.Equal(t, "edited out of band", untouched.Spec.Display.Name)
|
||||
assert.Equal(t, 2, stateVersion(t, shippingModule, ctx, orgID))
|
||||
}
|
||||
|
||||
func stateVersion(t *testing.T, module *module, ctx context.Context, orgID valuer.UUID) int {
|
||||
t.Helper()
|
||||
|
||||
state, err := module.store.Get(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
|
||||
require.NoError(t, err)
|
||||
|
||||
return state.Version
|
||||
}
|
||||
|
||||
func TestSystemDashboardsAreImmutableToUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
systemDashboardModule, dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
|
||||
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
|
||||
|
||||
provisioned, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = dashboardModule.UpdateV2(ctx, orgID, provisioned.ID, "user@signoz.io", newTestDefinition(t, 1, "edited").ToUpdatable())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be modified")
|
||||
}
|
||||
|
||||
func TestReconcileDoesNotDowngrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
newerModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
|
||||
require.NoError(t, newerModule.Reconcile(ctx, orgID))
|
||||
|
||||
olderModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
|
||||
require.NoError(t, olderModule.Reconcile(ctx, orgID))
|
||||
|
||||
got, err := newerModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v3", got.Spec.Display.Name)
|
||||
assert.Equal(t, 3, stateVersion(t, newerModule, ctx, orgID))
|
||||
}
|
||||
|
||||
func TestGetRejectsANonSystemDashboard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
systemDashboardModule, dashboardModule := newTestModule(t, sqlStore)
|
||||
|
||||
var postable dashboardtypes.PostableDashboardV2
|
||||
require.NoError(t, postable.UnmarshalJSON([]byte(`{
|
||||
"schemaVersion": "`+dashboardtypes.SchemaVersion+`",
|
||||
"name": "a-user-dashboard",
|
||||
"tags": [],
|
||||
"spec": {"display": {"name": "user"}, "variables": [], "panels": {}, "layouts": []}
|
||||
}`)))
|
||||
_, err := dashboardModule.CreateV2(ctx, orgID, "user@signoz.io", valuer.GenerateUUID(), dashboardtypes.SourceUser, postable)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The server-side prefix makes user names structurally unreachable here.
|
||||
_, err = systemDashboardModule.Get(ctx, orgID, "a-user-dashboard")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = systemDashboardModule.Get(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "must not carry")
|
||||
}
|
||||
81
pkg/modules/systemdashboard/implsystemdashboard/service.go
Normal file
81
pkg/modules/systemdashboard/implsystemdashboard/service.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
)
|
||||
|
||||
const reconcileRetryInterval = 30 * time.Second
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
module systemdashboard.Module
|
||||
orgGetter organization.Getter
|
||||
stopC chan struct{}
|
||||
healthyC chan struct{}
|
||||
}
|
||||
|
||||
// NewService reconciles every org's system dashboards once at startup. Orgs
|
||||
// created later are reconciled by the organization setter instead.
|
||||
func NewService(providerSettings factory.ProviderSettings, module systemdashboard.Module, orgGetter organization.Getter) factory.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"),
|
||||
module: module,
|
||||
orgGetter: orgGetter,
|
||||
stopC: make(chan struct{}),
|
||||
healthyC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(reconcileRetryInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
err := service.reconcile(ctx)
|
||||
if err == nil {
|
||||
close(service.healthyC)
|
||||
<-service.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
service.settings.Logger().WarnContext(ctx, "system dashboard reconciliation failed, retrying", errors.Attr(err))
|
||||
|
||||
select {
|
||||
case <-service.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Healthy() <-chan struct{} {
|
||||
return service.healthyC
|
||||
}
|
||||
|
||||
func (service *service) Stop(_ context.Context) error {
|
||||
close(service.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *service) reconcile(ctx context.Context) error {
|
||||
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := service.module.Reconcile(ctx, org.ID); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile system dashboards for org %s", org.ID.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
service.settings.Logger().InfoContext(ctx, "system dashboard reconciliation completed", slog.Int("orgs", len(orgs)))
|
||||
return nil
|
||||
}
|
||||
80
pkg/modules/systemdashboard/implsystemdashboard/store.go
Normal file
80
pkg/modules/systemdashboard/implsystemdashboard/store.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) systemdashboardtypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, storable *systemdashboardtypes.StorableSystemDashboard) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(storable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, systemdashboardtypes.ErrCodeSystemDashboardAlreadyProvisioned, "system dashboard %s is already provisioned", storable.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID valuer.UUID, name string) (*systemdashboardtypes.StorableSystemDashboard, error) {
|
||||
storable := new(systemdashboardtypes.StorableSystemDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, systemdashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error {
|
||||
result, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(new(systemdashboardtypes.StorableSystemDashboard)).
|
||||
Set("version = ?", version).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, systemdashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return store.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
28
pkg/modules/systemdashboard/systemdashboard.go
Normal file
28
pkg/modules/systemdashboard/systemdashboard.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package systemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// Reconcile provisions the org's missing system dashboards and upgrades the
|
||||
// unmodified ones to the shipped version. It never touches a dashboard whose
|
||||
// row carries a foreign write and it never deletes.
|
||||
Reconcile(ctx context.Context, orgID valuer.UUID) error
|
||||
|
||||
// Get addresses the dashboard by its bare definition name; the reserved
|
||||
// prefix is a storage concern the API never exposes.
|
||||
Get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// ResolveID maps a system dashboard's name to its id, so routes addressed by
|
||||
// name can be authz-checked and audited against the id tuples carry.
|
||||
ResolveID(ctx context.Context, orgID valuer.UUID, name string) (valuer.UUID, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
@@ -470,7 +470,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
continue
|
||||
}
|
||||
// Type is resolved now; validate aggregation compatibility against it.
|
||||
if err := spec.Aggregations[i].ValidateForType(); err != nil {
|
||||
if err := spec.Aggregations[i].ValidateForTypeAndTemporality(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if reducedMetricsSet[spec.Aggregations[i].MetricName] {
|
||||
|
||||
@@ -46,6 +46,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
@@ -88,6 +90,7 @@ type Handlers struct {
|
||||
RulerHandler ruler.Handler
|
||||
LLMPricingRuleHandler llmpricingrule.Handler
|
||||
StatsHandler statsreporter.Handler
|
||||
SystemDashboard systemdashboard.Handler
|
||||
}
|
||||
|
||||
func NewHandlers(
|
||||
@@ -137,5 +140,6 @@ func NewHandlers(
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),
|
||||
StatsHandler: statsreporter.NewHandler(statsAggregator),
|
||||
SystemDashboard: implsystemdashboard.NewHandler(modules.SystemDashboard),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, nil)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
@@ -48,6 +48,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
|
||||
@@ -67,35 +68,36 @@ import (
|
||||
)
|
||||
|
||||
type Modules struct {
|
||||
OrgGetter organization.Getter
|
||||
OrgSetter organization.Setter
|
||||
Preference preference.Module
|
||||
UserSetter user.Setter
|
||||
UserGetter user.Getter
|
||||
RetentionGetter retention.Getter
|
||||
SavedView savedview.Module
|
||||
Apdex apdex.Module
|
||||
Dashboard dashboard.Module
|
||||
QuickFilter quickfilter.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
RawDataExport rawdataexport.Module
|
||||
AuthDomain authdomain.Module
|
||||
Session session.Module
|
||||
Services services.Module
|
||||
SpanPercentile spanpercentile.Module
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
MetricReductionRule metricreductionrule.Module
|
||||
InfraMonitoring inframonitoring.Module
|
||||
OrgGetter organization.Getter
|
||||
OrgSetter organization.Setter
|
||||
Preference preference.Module
|
||||
UserSetter user.Setter
|
||||
UserGetter user.Getter
|
||||
RetentionGetter retention.Getter
|
||||
SavedView savedview.Module
|
||||
Apdex apdex.Module
|
||||
Dashboard dashboard.Module
|
||||
QuickFilter quickfilter.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
RawDataExport rawdataexport.Module
|
||||
AuthDomain authdomain.Module
|
||||
Session session.Module
|
||||
Services services.Module
|
||||
SpanPercentile spanpercentile.Module
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
MetricReductionRule metricreductionrule.Module
|
||||
InfraMonitoring inframonitoring.Module
|
||||
Promote promote.Module
|
||||
ServiceAccount serviceaccount.Module
|
||||
ServiceAccountGetter serviceaccount.Getter
|
||||
CloudIntegration cloudintegration.Module
|
||||
LogsPipeline logspipeline.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
SpanMapper spanmapper.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
Tag tag.Module
|
||||
LogsPipeline logspipeline.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
SpanMapper spanmapper.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
Tag tag.Module
|
||||
SystemDashboard systemdashboard.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -124,9 +126,10 @@ func NewModules(
|
||||
fl flagger.Flagger,
|
||||
tagModule tag.Module,
|
||||
metricReductionRule metricreductionrule.Module,
|
||||
systemDashboard systemdashboard.Module,
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, systemDashboard)
|
||||
// Cleanup callbacks from other modules, invoked when a user is deleted.
|
||||
onDeleteUser := []user.OnDeleteUser{
|
||||
dashboard.DeletePreferencesForUser,
|
||||
@@ -136,34 +139,35 @@ func NewModules(
|
||||
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
|
||||
|
||||
return Modules{
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
UserGetter: userGetter,
|
||||
RetentionGetter: retentionGetter,
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
UserGetter: userGetter,
|
||||
RetentionGetter: retentionGetter,
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
ServiceAccount: serviceAccount,
|
||||
ServiceAccountGetter: serviceAccountGetter,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
SystemDashboard: systemDashboard,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -66,7 +67,12 @@ func TestNewModules(t *testing.T) {
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
systemDashboardRegistry, err := implsystemdashboard.NewRegistry()
|
||||
require.NoError(t, err)
|
||||
|
||||
systemDashboard := implsystemdashboard.NewModule(providerSettings, implsystemdashboard.NewStore(sqlstore), systemDashboardRegistry, dashboardModule)
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), systemDashboard)
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -93,6 +94,8 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
struct{ systemdashboard.Module }{},
|
||||
struct{ systemdashboard.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -244,6 +244,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewDeleteOrphanUserRolesFactory(),
|
||||
sqlmigration.NewMigrateLambdaDashboardsFactory(),
|
||||
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -347,6 +348,8 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
modules.SystemDashboard,
|
||||
handlers.SystemDashboard,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
@@ -540,8 +541,16 @@ func New(
|
||||
|
||||
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
|
||||
|
||||
// Initialize the system dashboard module. The registry is parsed here so a
|
||||
// malformed embedded definition fails startup instead of a request.
|
||||
systemDashboardRegistry, err := implsystemdashboard.NewRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
systemDashboardModule := implsystemdashboard.NewModule(providerSettings, implsystemdashboard.NewStore(sqlstore), systemDashboardRegistry, dashboard)
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule, systemDashboardModule)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
|
||||
@@ -610,6 +619,7 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
|
||||
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
|
||||
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
|
||||
factory.NewNamedService(factory.MustNewName("systemdashboard"), implsystemdashboard.NewService(providerSettings, systemDashboardModule, orgGetter)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
93
pkg/sqlmigration/118_add_system_dashboard.go
Normal file
93
pkg/sqlmigration/118_add_system_dashboard.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addSystemDashboard struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddSystemDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_system_dashboard"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addSystemDashboard{sqlstore: sqlstore, sqlschema: sqlschema}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addSystemDashboard) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addSystemDashboard) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "system_dashboard",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "version", DataType: sqlschema.DataTypeBigInt, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
|
||||
ReferencedTableName: sqlschema.TableName("dashboard"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// (org_id, name) is what makes provisioning safe across replicas: the state
|
||||
// row is written in the same transaction as the dashboard, so a losing racer
|
||||
// rolls back its dashboard too.
|
||||
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "system_dashboard",
|
||||
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
|
||||
},
|
||||
)...)
|
||||
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "system_dashboard",
|
||||
ColumnNames: []sqlschema.ColumnName{"dashboard_id"},
|
||||
},
|
||||
)...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addSystemDashboard) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -428,20 +428,24 @@ func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
sb.SelectMore(fmt.Sprintf("`%s`", GroupByColumnAlias(i, g.Name)))
|
||||
}
|
||||
|
||||
aggCol, err := metricstelemetryschema.AggregationColumnForSamplesTable(
|
||||
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
|
||||
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
|
||||
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
|
||||
}
|
||||
|
||||
var aggCol string
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() &&
|
||||
query.Aggregations[0].Type == metrictypes.ExpHistogramType {
|
||||
// merging sketches already spans every series in the step, so neither a
|
||||
// samples-table value column nor the rate divisor applies
|
||||
aggCol = fmt.Sprintf("quantilesDDMerge(0.01, %f)(sketch)[1]", query.Aggregations[0].SpaceAggregation.Percentile())
|
||||
} else {
|
||||
col, err := metricstelemetryschema.AggregationColumnForSamplesTable(
|
||||
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
aggCol = col
|
||||
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
|
||||
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
|
||||
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
|
||||
}
|
||||
}
|
||||
|
||||
sb.SelectMore(fmt.Sprintf("%s AS value", aggCol))
|
||||
|
||||
@@ -126,6 +126,64 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_exp_histogram_percentile_delta",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.ExpHistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
// the sketch merge spans the whole step, so `rate` must not add a /step divisor
|
||||
name: "test_exp_histogram_percentile_delta_rate_time_aggregation",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.ExpHistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_histogram_percentile1",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
|
||||
@@ -25,6 +25,10 @@ const (
|
||||
dashboardNameSuffixLen = 8
|
||||
)
|
||||
|
||||
// SystemDashboardNamePrefix is reserved for dashboards SigNoz ships and owns. Generated
|
||||
// names never contain consecutive hyphens, so only a typed name can carry it — create rejects that.
|
||||
const SystemDashboardNamePrefix = "signoz---"
|
||||
|
||||
const (
|
||||
dashboardIconPathPrefix = "/assets/Icons/"
|
||||
dashboardLogoPathPrefix = "/assets/Logos/"
|
||||
@@ -75,8 +79,8 @@ type DashboardV2 struct {
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotMutable() error {
|
||||
if d.Source == SourceIntegration {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
|
||||
if d.Source != SourceUser {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be modified", d.Source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -95,6 +99,11 @@ func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, r
|
||||
if err := d.ErrIfNotUpdatable(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.UpdateUnsafe(updatable, updatedBy, resolvedTags)
|
||||
}
|
||||
|
||||
// UpdateUnsafe applies the update without the source/lock gate. Intended for internal system callers.
|
||||
func (d *DashboardV2) UpdateUnsafe(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
|
||||
if updatable.Name != d.Name {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name)
|
||||
}
|
||||
@@ -129,6 +138,13 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotSystem() error {
|
||||
if d.Source != SourceSystem {
|
||||
return errors.Newf(errors.TypeNotFound, ErrCodeDashboardNotFound, "dashboard %q is not a system dashboard", d.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotClonable() error {
|
||||
if !d.Source.isClonable() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be cloned", d.Source)
|
||||
@@ -205,13 +221,21 @@ type PostableDashboardV2 struct {
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) *DashboardV2 {
|
||||
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) (*DashboardV2, error) {
|
||||
now := time.Now()
|
||||
|
||||
name := postable.Name
|
||||
if postable.GenerateName {
|
||||
name = generateDashboardName(postable.Spec.Display.Name)
|
||||
}
|
||||
// Checked on the final name, here rather than in validateName, because only
|
||||
// the constructor knows the source.
|
||||
if source != SourceSystem && strings.HasPrefix(name, SystemDashboardNamePrefix) {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name %q is invalid: the %q prefix is reserved for system dashboards", name, SystemDashboardNamePrefix)
|
||||
}
|
||||
if source == SourceSystem && !strings.HasPrefix(name, SystemDashboardNamePrefix) {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name %q is invalid: system dashboard names must start with the %q prefix", name, SystemDashboardNamePrefix)
|
||||
}
|
||||
|
||||
return &DashboardV2{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
@@ -224,7 +248,7 @@ func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy
|
||||
Name: name,
|
||||
Tags: tagtypes.NewTagsFromPostableTags(orgID, coretypes.KindDashboard, postable.Tags),
|
||||
Spec: postable.Spec,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -89,21 +89,25 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
cases := []struct {
|
||||
scenario string
|
||||
source Source
|
||||
name string
|
||||
expectedLocked bool
|
||||
}{
|
||||
{
|
||||
scenario: "user source is not locked",
|
||||
source: SourceUser,
|
||||
name: "my-dashboard",
|
||||
expectedLocked: false,
|
||||
},
|
||||
{
|
||||
scenario: "system source is not locked",
|
||||
source: SourceSystem,
|
||||
name: SystemDashboardNamePrefix + "my-dashboard",
|
||||
expectedLocked: false,
|
||||
},
|
||||
{
|
||||
scenario: "integration source is locked",
|
||||
source: SourceIntegration,
|
||||
name: "my-dashboard",
|
||||
expectedLocked: true,
|
||||
},
|
||||
}
|
||||
@@ -115,7 +119,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
SchemaVersion: SchemaVersion,
|
||||
Image: "img",
|
||||
},
|
||||
Name: "my-dashboard",
|
||||
Name: tc.name,
|
||||
Tags: []tagtypes.PostableTag{
|
||||
{Key: "team", Value: "platform"},
|
||||
{Key: "env", Value: "prod"},
|
||||
@@ -124,7 +128,8 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
dashboard := postable.NewDashboardV2(orgID, "alice", tc.source)
|
||||
dashboard, err := postable.NewDashboardV2(orgID, "alice", tc.source)
|
||||
require.NoError(t, err)
|
||||
after := time.Now()
|
||||
|
||||
require.NotNil(t, dashboard)
|
||||
@@ -160,8 +165,10 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
Spec: DashboardSpec{},
|
||||
}
|
||||
|
||||
first := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
second := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
first, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
require.NoError(t, err)
|
||||
second, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, first.ID, second.ID, "expected distinct UUIDs across invocations")
|
||||
})
|
||||
|
||||
@@ -174,7 +181,8 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
dashboard := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
dashboard, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(dashboard.Name, "my-dashboard-"), "expected slug prefix, got %q", dashboard.Name)
|
||||
assert.Len(t, dashboard.Name, len("my-dashboard-")+dashboardNameSuffixLen)
|
||||
})
|
||||
|
||||
@@ -109,7 +109,8 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
var p PostableDashboardV2
|
||||
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
|
||||
testOrgID := valuer.GenerateUUID()
|
||||
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
|
||||
base, err := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
|
||||
require.NoError(t, err)
|
||||
base.Tags = []*tagtypes.Tag{
|
||||
{Key: "team", Value: "alpha"},
|
||||
{Key: "env", Value: "prod"},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -1928,3 +1929,37 @@ func TestEnsureSingleExpressionAggregation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the constant: a prefixed name must stay a valid DNS-1123 label.
|
||||
func TestSystemDashboardNamePrefix(t *testing.T) {
|
||||
require.NoError(t, validateDashboardName(SystemDashboardNamePrefix+"ai-o11y-overview"))
|
||||
}
|
||||
|
||||
func TestNewDashboardV2RejectsReservedName(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
name string
|
||||
source Source
|
||||
errContains string
|
||||
}{
|
||||
{description: "reserved name for a system dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceSystem},
|
||||
{description: "reserved name for a user dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceUser, errContains: "reserved for system dashboards"},
|
||||
{description: "reserved name for an integration dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceIntegration, errContains: "reserved for system dashboards"},
|
||||
{description: "unprefixed name for a system dashboard", name: "overview", source: SourceSystem, errContains: "must start with"},
|
||||
{description: "ordinary name for a user dashboard", name: "overview", source: SourceUser},
|
||||
{description: "fewer hyphens than the prefix for a user dashboard", name: "signoz--overview", source: SourceUser},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
postable := PostableDashboardV2{Name: testCase.name}
|
||||
_, err := postable.NewDashboardV2(valuer.GenerateUUID(), "user@signoz.io", testCase.source)
|
||||
if testCase.errContains != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), testCase.errContains)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ type Store interface {
|
||||
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, error)
|
||||
|
||||
// GetByName resolves a dashboard by its per-org unique name.
|
||||
GetByName(ctx context.Context, orgID valuer.UUID, name string) (*StorableDashboard, error)
|
||||
|
||||
GetPublic(context.Context, string) (*StorablePublicDashboard, error)
|
||||
|
||||
GetDashboardByOrgsAndPublicID(context.Context, []string, string) (*StorableDashboard, error)
|
||||
|
||||
@@ -374,7 +374,7 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m MetricAggregation) ValidateForType() error {
|
||||
func (m MetricAggregation) ValidateForTypeAndTemporality() error {
|
||||
if m.SpaceAggregation.IsPercentile() && !m.Type.IsPercentileSpaceAggregationAllowed() {
|
||||
return errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
@@ -384,6 +384,17 @@ func (m MetricAggregation) ValidateForType() error {
|
||||
m.Type.StringValue(),
|
||||
)
|
||||
}
|
||||
// reading a step's distribution out of a cumulative sketch would mean
|
||||
// subtracting the previous point's sketch, which ClickHouse cannot do
|
||||
if m.Type == metrictypes.ExpHistogramType && m.Temporality != metrictypes.Delta {
|
||||
return errors.Newf(
|
||||
errors.TypeUnsupported,
|
||||
errors.CodeUnsupported,
|
||||
"metric `%s` is an exponential histogram recorded with `%s` temporality, which cannot be queried; only `delta` exponential histograms are supported",
|
||||
m.MetricName,
|
||||
m.Temporality.StringValue(),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1517,10 +1517,11 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricAggregationValidateForType(t *testing.T) {
|
||||
func TestMetricAggregationValidateForTypeAndTemporality(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
metricType metrictypes.Type
|
||||
temporality metrictypes.Temporality
|
||||
spaceAggregation metrictypes.SpaceAggregation
|
||||
comparisonParam *metrictypes.ComparisonSpaceAggregationParam
|
||||
wantErr bool
|
||||
@@ -1532,11 +1533,33 @@ func TestMetricAggregationValidateForType(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "percentile on exponential histogram is allowed",
|
||||
name: "percentile on delta exponential histogram is allowed",
|
||||
metricType: metrictypes.ExpHistogramType,
|
||||
temporality: metrictypes.Delta,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "cumulative exponential histogram is not allowed",
|
||||
metricType: metrictypes.ExpHistogramType,
|
||||
temporality: metrictypes.Cumulative,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "exponential histogram with unresolved temporality is not allowed",
|
||||
metricType: metrictypes.ExpHistogramType,
|
||||
temporality: metrictypes.Unknown,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "cumulative histogram is unaffected by the exponential histogram rule",
|
||||
metricType: metrictypes.HistogramType,
|
||||
temporality: metrictypes.Cumulative,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "percentile on summary is not allowed",
|
||||
metricType: metrictypes.SummaryType,
|
||||
@@ -1562,10 +1585,11 @@ func TestMetricAggregationValidateForType(t *testing.T) {
|
||||
agg := MetricAggregation{
|
||||
MetricName: "test_metric",
|
||||
Type: tc.metricType,
|
||||
Temporality: tc.temporality,
|
||||
SpaceAggregation: tc.spaceAggregation,
|
||||
ComparisonSpaceAggregationParam: tc.comparisonParam,
|
||||
}
|
||||
err := agg.ValidateForType()
|
||||
err := agg.ValidateForTypeAndTemporality()
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ var (
|
||||
const savedViewNameSuffixLen = 8
|
||||
|
||||
var (
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
SourceMetrics = Source{valuer.NewString("metrics")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
SourceMetrics = Source{valuer.NewString("metrics")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceAIObservability = Source{valuer.NewString("ai_observability")}
|
||||
)
|
||||
|
||||
type SavedView struct {
|
||||
@@ -117,12 +118,13 @@ func (Source) Enum() []any {
|
||||
SourceLogs,
|
||||
SourceMetrics,
|
||||
SourceMeter,
|
||||
SourceAIObservability,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Source) Validate() error {
|
||||
switch s {
|
||||
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter:
|
||||
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter, SourceAIObservability:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid source: %s", s.StringValue())
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestSourceValidate(t *testing.T) {
|
||||
{name: "logs", source: SourceLogs},
|
||||
{name: "metrics", source: SourceMetrics},
|
||||
{name: "meter", source: SourceMeter},
|
||||
{name: "ai_observability", source: SourceAIObservability},
|
||||
{name: "unknown is rejected", source: Source{valuer.NewString("bogus")}, expectError: true},
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,21 @@ func TestSavedViewSpecValidate(t *testing.T) {
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "builder_ai_query is valid",
|
||||
spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeList,
|
||||
RequestType: qbtypes.RequestTypeRaw,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
},
|
||||
}},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "graph panel query with no aggregation is still rejected",
|
||||
spec: SavedViewSpec{
|
||||
|
||||
95
pkg/types/systemdashboardtypes/definition.go
Normal file
95
pkg/types/systemdashboardtypes/definition.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package systemdashboardtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
)
|
||||
|
||||
// Definition is one shipped system dashboard. Version is bumped on every content
|
||||
// change and drives upgrade detection; the name is the stable key and never changes.
|
||||
type Definition struct {
|
||||
Version int `json:"version"`
|
||||
Dashboard dashboardtypes.PostableDashboardV2 `json:"definition"`
|
||||
}
|
||||
|
||||
func (definition Definition) Name() string {
|
||||
return definition.Dashboard.Name
|
||||
}
|
||||
|
||||
func NewDefinition(raw []byte) (Definition, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var definition Definition
|
||||
if err := decoder.Decode(&definition); err != nil {
|
||||
return Definition{}, errors.WrapInvalidInputf(err, ErrCodeSystemDashboardDefinitionInvalid, "%s", err.Error())
|
||||
}
|
||||
if err := definition.validate(); err != nil {
|
||||
return Definition{}, err
|
||||
}
|
||||
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func (definition Definition) validate() error {
|
||||
if definition.Version < 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "version must be at least 1, got %d", definition.Version)
|
||||
}
|
||||
if !strings.HasPrefix(definition.Name(), dashboardtypes.SystemDashboardNamePrefix) {
|
||||
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "name %q must start with %q", definition.Name(), dashboardtypes.SystemDashboardNamePrefix)
|
||||
}
|
||||
if definition.Dashboard.GenerateName {
|
||||
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "%s: generateName is not allowed, the name is the stable key", definition.Name())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToUpdatable is how an upgrade re-applies a definition onto an existing row:
|
||||
// everything but the dashboard's identity comes from the shipped definition.
|
||||
func (definition Definition) ToUpdatable() dashboardtypes.UpdatableDashboardV2 {
|
||||
return dashboardtypes.UpdatableDashboardV2{
|
||||
DashboardV2MetadataBase: definition.Dashboard.DashboardV2MetadataBase,
|
||||
Name: definition.Dashboard.Name,
|
||||
Tags: definition.Dashboard.Tags,
|
||||
Spec: definition.Dashboard.Spec,
|
||||
}
|
||||
}
|
||||
|
||||
// Registry holds every definition embedded in the binary, keyed by name.
|
||||
type Registry struct {
|
||||
definitions map[string]Definition
|
||||
}
|
||||
|
||||
func NewRegistry(definitions []Definition) (Registry, error) {
|
||||
byName := make(map[string]Definition, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
if _, duplicate := byName[definition.Name()]; duplicate {
|
||||
return Registry{}, errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "duplicate system dashboard name %q", definition.Name())
|
||||
}
|
||||
byName[definition.Name()] = definition
|
||||
}
|
||||
|
||||
return Registry{definitions: byName}, nil
|
||||
}
|
||||
|
||||
func (registry Registry) Get(name string) (Definition, bool) {
|
||||
definition, ok := registry.definitions[name]
|
||||
return definition, ok
|
||||
}
|
||||
|
||||
// List returns the definitions sorted by name so provisioning order is stable.
|
||||
func (registry Registry) List() []Definition {
|
||||
definitions := make([]Definition, 0, len(registry.definitions))
|
||||
for _, definition := range registry.definitions {
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
slices.SortFunc(definitions, func(a, b Definition) int { return strings.Compare(a.Name(), b.Name()) })
|
||||
|
||||
return definitions
|
||||
}
|
||||
58
pkg/types/systemdashboardtypes/system_dashboard.go
Normal file
58
pkg/types/systemdashboardtypes/system_dashboard.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package systemdashboardtypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeSystemDashboardNotFound = errors.MustNewCode("system_dashboard_not_found")
|
||||
ErrCodeSystemDashboardDefinitionInvalid = errors.MustNewCode("system_dashboard_definition_invalid")
|
||||
ErrCodeSystemDashboardAlreadyProvisioned = errors.MustNewCode("system_dashboard_already_provisioned")
|
||||
)
|
||||
|
||||
// ProvisionerIdentity is stamped into created_by/updated_by by the reconciler. It
|
||||
// is deliberately not a valid email, so it can never collide with a real account:
|
||||
// any other value in updated_by means a foreign write.
|
||||
const ProvisionerIdentity = "signoz"
|
||||
|
||||
type Store interface {
|
||||
Create(ctx context.Context, storable *StorableSystemDashboard) error
|
||||
|
||||
Get(ctx context.Context, orgID valuer.UUID, name string) (*StorableSystemDashboard, error)
|
||||
|
||||
UpdateVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error
|
||||
|
||||
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
|
||||
}
|
||||
|
||||
// StorableSystemDashboard records the shipped version each org's copy of a system
|
||||
// dashboard was last provisioned at. That version is the only thing the dashboard
|
||||
// row cannot answer, since the binary only embeds the latest definition.
|
||||
type StorableSystemDashboard struct {
|
||||
bun.BaseModel `bun:"table:system_dashboard"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
|
||||
DashboardID valuer.UUID `bun:"dashboard_id,type:text,notnull"`
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Version int `bun:"version,notnull"`
|
||||
}
|
||||
|
||||
func NewStorableSystemDashboard(orgID valuer.UUID, dashboardID valuer.UUID, name string, version int) *StorableSystemDashboard {
|
||||
now := time.Now()
|
||||
return &StorableSystemDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
OrgID: orgID,
|
||||
DashboardID: dashboardID,
|
||||
Name: name,
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
459
tests/e2e/fixtures/alerts/alert-history.ts
Normal file
459
tests/e2e/fixtures/alerts/alert-history.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import type { Browser } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createMetricAlertViaApi,
|
||||
createNoDataAlertViaApi,
|
||||
createTracesAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
setRuleDisabledViaApi,
|
||||
} from '../../helpers/alerts/api';
|
||||
import {
|
||||
readTimelineTotal,
|
||||
waitForTimelineEntries,
|
||||
waitForTimelineStates,
|
||||
} from '../../helpers/alerts/history';
|
||||
import {
|
||||
seedAlertHistoryLogs,
|
||||
seedAlertHistoryMetrics,
|
||||
seedAlertHistoryTraces,
|
||||
} from '../../helpers/alerts/seeding';
|
||||
import { expect, test as base, withAdminPage } from './alert-rules';
|
||||
import {
|
||||
FIXTURE_ALERT_HISTORY_TIMEOUT,
|
||||
FIXTURE_EMPTY_HISTORY_TIMEOUT,
|
||||
FIXTURE_METRICS_HISTORY_TIMEOUT,
|
||||
FIXTURE_NODATA_HISTORY_TIMEOUT,
|
||||
FIXTURE_RESOLVED_HISTORY_TIMEOUT,
|
||||
FIXTURE_TRACES_HISTORY_TIMEOUT,
|
||||
WAIT_METRICS_TIMELINE_TIMEOUT,
|
||||
WAIT_NODATA_TIMELINE_TIMEOUT,
|
||||
} from './timeouts';
|
||||
|
||||
// Worker-scoped alert-history fixtures. Extends `alert-rules`, so a spec that
|
||||
// imports `test` from here also gets `alertChannel` / `alertList` / `ownedRules`
|
||||
// — the details specs need a history seed *and* their own throwaway rules.
|
||||
//
|
||||
// Every history row has to come from the ruler actually evaluating a rule (there
|
||||
// is no seeder endpoint for `rule_state_history_v0`), so each fixture pays a
|
||||
// real ruler wait: ~20-35s for logs, ~10s for metrics, ~105s for firing→resolved.
|
||||
// Worker scope means one wait per worker instead of one per test, and Playwright
|
||||
// creates each fixture lazily — a spec that never asks for `resolvedHistory`
|
||||
// never pays its 105s.
|
||||
|
||||
/** Service count for `alertHistory`. 25 yields multi-page timeline + pagination tests. */
|
||||
const LOGS_HISTORY_SERVICES = 25;
|
||||
|
||||
/** Service count for `resolvedHistory`. 3 services + 1m window = resolves in ~105s. */
|
||||
const RESOLVED_HISTORY_SERVICES = 3;
|
||||
|
||||
/** Hosts for `metricsHistory`. 2 rows fit one page, no related-logs links. */
|
||||
const METRICS_HISTORY_HOSTS = ['host-0', 'host-1'];
|
||||
|
||||
/** Service count for `tracesHistory`. 3 keeps wait short while proving traces link. */
|
||||
const TRACES_HISTORY_SERVICES = 3;
|
||||
|
||||
/** Team label for the v1 rule in `alertHistory`, so its header labels row is non-empty. */
|
||||
export const V1_RULE_TEAM_LABEL = 'e2e-platform';
|
||||
|
||||
export interface AlertHistorySeed {
|
||||
/** v2 (`schemaVersion: v2alpha1`) rule — the default history subject. */
|
||||
ruleId: string;
|
||||
/** Legacy v1 rule over the same logs. Its `threshold.name` is `warning`. */
|
||||
ruleIdV1: string;
|
||||
channelName: string;
|
||||
/** The `body CONTAINS` marker both rules match. */
|
||||
marker: string;
|
||||
/** The seeded `service.name` values, in creation order. */
|
||||
services: string[];
|
||||
/** Baseline `total` for {@link ruleId}, read after the rule was frozen. */
|
||||
total: number;
|
||||
/** Baseline `total` for {@link ruleIdV1}. */
|
||||
totalV1: number;
|
||||
}
|
||||
|
||||
export interface MetricsHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
metricName: string;
|
||||
hosts: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TracesHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
/** The span `name` the rule matches (`name = '<marker>'`). */
|
||||
marker: string;
|
||||
services: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ResolvedHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
marker: string;
|
||||
services: string[];
|
||||
/** Rows in the `firing` state — equals `stats.totalCurrentTriggers`. */
|
||||
firingCount: number;
|
||||
/** Rows in the `inactive` state, i.e. what the `Resolved` filter shows. */
|
||||
resolvedCount: number;
|
||||
}
|
||||
|
||||
export interface NoDataHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
export interface EmptyHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
async function cleanup(
|
||||
browser: Browser,
|
||||
{ ruleIds, channelId }: { ruleIds: string[]; channelId?: string },
|
||||
): Promise<void> {
|
||||
await withAdminPage(browser, async (page) => {
|
||||
for (const id of ruleIds) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteAlertViaApi(page, id);
|
||||
}
|
||||
if (channelId) {
|
||||
await deleteChannelViaApi(page, channelId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture setup functions ---
|
||||
|
||||
interface HistoryFixtureResult<T> {
|
||||
seed: T;
|
||||
ruleIds: string[];
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
async function createAlertHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<AlertHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert history ${stamp}`;
|
||||
|
||||
const result = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: LOGS_HISTORY_SERVICES,
|
||||
servicePrefix: `e2e-ah-svc`,
|
||||
});
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v2-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v2',
|
||||
});
|
||||
const ruleIdV1 = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v1-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v1',
|
||||
extraLabels: { team: V1_RULE_TEAM_LABEL },
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: LOGS_HISTORY_SERVICES });
|
||||
await waitForTimelineEntries(page, ruleIdV1, { min: LOGS_HISTORY_SERVICES });
|
||||
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
await setRuleDisabledViaApi(page, ruleIdV1, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
ruleIdV1,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
totalV1: await readTimelineTotal(page, ruleIdV1),
|
||||
},
|
||||
ruleIds: [ruleId, ruleIdV1],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.seed.total !== LOGS_HISTORY_SERVICES) {
|
||||
throw new Error(
|
||||
`alertHistory expected ${LOGS_HISTORY_SERVICES} timeline rows, got ${result.seed.total}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createMetricsHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<MetricsHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const metricName = `e2e_ah_probe_metric_${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-metrics-ch-${stamp}`,
|
||||
);
|
||||
|
||||
await seedAlertHistoryMetrics(page, {
|
||||
metricName,
|
||||
hosts: METRICS_HISTORY_HOSTS,
|
||||
});
|
||||
|
||||
const ruleId = await createMetricAlertViaApi(page, {
|
||||
name: `e2e-ah-metrics-rule-${stamp}`,
|
||||
metricName,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: METRICS_HISTORY_HOSTS.length,
|
||||
timeoutMs: WAIT_METRICS_TIMELINE_TIMEOUT,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
metricName,
|
||||
hosts: METRICS_HISTORY_HOSTS,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createTracesHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<TracesHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e-aht-span-${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-traces-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const services = await seedAlertHistoryTraces(page, {
|
||||
marker,
|
||||
services: TRACES_HISTORY_SERVICES,
|
||||
servicePrefix: 'e2e-aht-svc',
|
||||
});
|
||||
|
||||
const ruleId = await createTracesAlertViaApi(page, {
|
||||
name: `e2e-ah-traces-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: TRACES_HISTORY_SERVICES });
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createResolvedHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<ResolvedHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert resolved ${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-resolved-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: RESOLVED_HISTORY_SERVICES,
|
||||
ageSeconds: 40,
|
||||
minAgeSeconds: 28,
|
||||
servicePrefix: 'e2e-ahr-svc',
|
||||
});
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-resolved-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
evalWindow: '1m0s',
|
||||
});
|
||||
|
||||
const timeline = await waitForTimelineStates(page, ruleId, {
|
||||
states: {
|
||||
firing: RESOLVED_HISTORY_SERVICES,
|
||||
inactive: RESOLVED_HISTORY_SERVICES,
|
||||
},
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
|
||||
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createNoDataHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<NoDataHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-nodata-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const ruleId = await createNoDataAlertViaApi(page, {
|
||||
name: `e2e-ah-nodata-rule-${stamp}`,
|
||||
marker: `e2e alert nodata ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: 1,
|
||||
state: 'nodata',
|
||||
timeoutMs: WAIT_NODATA_TIMELINE_TIMEOUT,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: { ruleId, channelName: channel.name },
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createEmptyHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<EmptyHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-empty-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-empty-rule-${stamp}`,
|
||||
marker: `e2e alert never seeded ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: { ruleId, channelName: channel.name },
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture definitions ---
|
||||
|
||||
export const test = base.extend<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
{},
|
||||
{
|
||||
alertHistory: AlertHistorySeed;
|
||||
metricsHistory: MetricsHistorySeed;
|
||||
tracesHistory: TracesHistorySeed;
|
||||
resolvedHistory: ResolvedHistorySeed;
|
||||
noDataHistory: NoDataHistorySeed;
|
||||
emptyHistory: EmptyHistorySeed;
|
||||
}
|
||||
>({
|
||||
alertHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createAlertHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_ALERT_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
metricsHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createMetricsHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_METRICS_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
tracesHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createTracesHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_TRACES_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
resolvedHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } =
|
||||
await createResolvedHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_RESOLVED_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
noDataHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createNoDataHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_NODATA_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
emptyHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createEmptyHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_EMPTY_HISTORY_TIMEOUT },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
245
tests/e2e/fixtures/alerts/alert-rules.ts
Normal file
245
tests/e2e/fixtures/alerts/alert-rules.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { Browser, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createThresholdAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
} from '../../helpers/alerts/api';
|
||||
import { seedAlertRules } from '../../helpers/alerts/seeding';
|
||||
import type {
|
||||
AlertSchema,
|
||||
LogsAlertSeed,
|
||||
ThresholdAlertSeed,
|
||||
} from '../../helpers/alerts/types';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { expect, test as base } from '../auth';
|
||||
import { FIXTURE_ALERT_LIST_TIMEOUT } from './timeouts';
|
||||
|
||||
// Alert *rule* fixtures — the API-only half of the alerts suite. Nothing here
|
||||
// waits on the ruler: a rule is created and that's it. History rows need real
|
||||
// evaluations, so those fixtures live in `alert-history.ts`, which
|
||||
// extends this module — a spec importing from there gets both sets.
|
||||
//
|
||||
// Scopes, and why:
|
||||
// `alertChannel` — worker. Every rule payload has to reference a channel by
|
||||
// name, and one channel serves the whole worker.
|
||||
// `alertList` — worker. Read-only rule list the `tests/alerts/list` specs
|
||||
// page, search and sort through. Names and label values are stamped per
|
||||
// worker so parallel batches never count each other's rules.
|
||||
// `ownedRules` — test. Scenarios that rename/toggle/clone/delete a rule seed
|
||||
// their own and have it removed when they finish; mutating a shared seed
|
||||
// would break every scenario scheduled after it.
|
||||
|
||||
export interface AlertChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AlertListSeed {
|
||||
channelName: string;
|
||||
/** Rules are named `<namePrefix>-NN` — unique to this worker's batch. */
|
||||
namePrefix: string;
|
||||
/** Rules seeded ⇒ the `of N` total once the list is scoped to the prefix. */
|
||||
count: number;
|
||||
/** `team` label on the odd-indexed half of the batch, i.e. `count / 2` rules. */
|
||||
paymentsLabel: string;
|
||||
ruleIds: string[];
|
||||
}
|
||||
|
||||
export interface OwnedRules {
|
||||
/** Seed a metric threshold rule this test owns. */
|
||||
threshold(
|
||||
name: string,
|
||||
overrides?: Partial<Omit<ThresholdAlertSeed, 'name'>>,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Seed a logs rule this test owns. No telemetry is seeded for its marker, so
|
||||
* it never fires — enough for anything about the details shell.
|
||||
*
|
||||
* `schema: 'v1'` posts the legacy payload; the condition overrides exist so
|
||||
* a v1 *prefill* assertion can be made against values the create form would
|
||||
* not have produced by itself.
|
||||
*/
|
||||
logs(
|
||||
options: {
|
||||
name: string;
|
||||
schema?: AlertSchema;
|
||||
marker?: string;
|
||||
} & Partial<
|
||||
Pick<
|
||||
LogsAlertSeed,
|
||||
'severity' | 'extraLabels' | 'evalWindow' | 'target' | 'op' | 'matchType'
|
||||
>
|
||||
>,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Track a rule the *app* created (Clone / Duplicate) so teardown removes it
|
||||
* too. Lives here because the id may legitimately be missing and a
|
||||
* conditional inside a test body is a lint error.
|
||||
*/
|
||||
register(response: { json: () => Promise<unknown> }): Promise<void>;
|
||||
}
|
||||
|
||||
/** alertList size. 12 over page size 10 = short second page for pagination tests. */
|
||||
const LIST_SEED_COUNT = 12;
|
||||
|
||||
/**
|
||||
* Run `body` on a throwaway admin page. Worker hooks can't use the test-scoped
|
||||
* `authedPage`, and every API helper needs a page whose context carries the
|
||||
* admin storage state.
|
||||
*/
|
||||
export async function withAdminPage<T>(
|
||||
browser: Browser,
|
||||
body: (page: Page) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
return await body(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRules(browser: Browser, ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
await withAdminPage(browser, async (page) => {
|
||||
for (const id of ids) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteAlertViaApi(page, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture setup/teardown functions ---
|
||||
|
||||
async function createAlertChannel(
|
||||
browser: Browser,
|
||||
workerIndex: number,
|
||||
): Promise<AlertChannel> {
|
||||
return withAdminPage(browser, (page) =>
|
||||
createEmailChannelViaApi(page, `e2e-alerts-ch-w${workerIndex}-${Date.now()}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteAlertChannel(
|
||||
browser: Browser,
|
||||
channelId: string,
|
||||
): Promise<void> {
|
||||
await withAdminPage(browser, (page) => deleteChannelViaApi(page, channelId));
|
||||
}
|
||||
|
||||
async function createAlertList(
|
||||
browser: Browser,
|
||||
channelName: string,
|
||||
workerIndex: number,
|
||||
): Promise<AlertListSeed> {
|
||||
const stamp = `w${workerIndex}-${Date.now()}`;
|
||||
const namePrefix = `e2e-alert-list-${stamp}`;
|
||||
const teamSuffix = `-${stamp}`;
|
||||
|
||||
const ruleIds = await withAdminPage(browser, (page) =>
|
||||
seedAlertRules(page, {
|
||||
count: LIST_SEED_COUNT,
|
||||
channelName,
|
||||
namePrefix,
|
||||
teamSuffix,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
channelName,
|
||||
namePrefix,
|
||||
count: LIST_SEED_COUNT,
|
||||
paymentsLabel: `payments${teamSuffix}`,
|
||||
ruleIds,
|
||||
};
|
||||
}
|
||||
|
||||
function createOwnedRulesFactory(
|
||||
browser: Browser,
|
||||
channelName: string,
|
||||
ids: Set<string>,
|
||||
): OwnedRules {
|
||||
const seed = async (
|
||||
create: (page: Page) => Promise<string>,
|
||||
): Promise<string> => {
|
||||
const id = await withAdminPage(browser, create);
|
||||
ids.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
return {
|
||||
threshold: (name, overrides = {}) =>
|
||||
seed((page) =>
|
||||
createThresholdAlertViaApi(page, {
|
||||
name,
|
||||
target: 42,
|
||||
channels: [channelName],
|
||||
labels: { severity: 'critical' },
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
|
||||
seed((page) =>
|
||||
createLogsAlertViaApi(page, {
|
||||
name,
|
||||
marker: marker ?? `e2e alert never seeded ${name}`,
|
||||
channels: [channelName],
|
||||
schema,
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
register: async (response) => {
|
||||
const body = (await response.json()) as { data?: { id?: string } };
|
||||
const id = body.data?.id;
|
||||
if (id) {
|
||||
ids.add(String(id));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Fixture definitions ---
|
||||
|
||||
export const test = base.extend<
|
||||
{ ownedRules: OwnedRules },
|
||||
{ alertChannel: AlertChannel; alertList: AlertListSeed }
|
||||
>({
|
||||
alertChannel: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const channel = await createAlertChannel(browser, workerInfo.workerIndex);
|
||||
await use(channel);
|
||||
await deleteAlertChannel(browser, channel.id);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
alertList: [
|
||||
async ({ browser, alertChannel }, use, workerInfo) => {
|
||||
const seed = await createAlertList(
|
||||
browser,
|
||||
alertChannel.name,
|
||||
workerInfo.workerIndex,
|
||||
);
|
||||
await use(seed);
|
||||
await deleteRules(browser, seed.ruleIds);
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_ALERT_LIST_TIMEOUT },
|
||||
],
|
||||
|
||||
ownedRules: async ({ browser, alertChannel }, use) => {
|
||||
const ids = new Set<string>();
|
||||
const factory = createOwnedRulesFactory(browser, alertChannel.name, ids);
|
||||
await use(factory);
|
||||
await deleteRules(browser, [...ids]);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
96
tests/e2e/fixtures/alerts/timeouts.ts
Normal file
96
tests/e2e/fixtures/alerts/timeouts.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Centralized timeout constants for alert fixtures.
|
||||
*
|
||||
* **Ruler**: SigNoz's alert evaluation engine. Runs on ~15s cycles, checks each
|
||||
* rule's query against ClickHouse, writes results to `rule_state_history_v0`.
|
||||
* There's no API to force-evaluate or seed history directly, so fixtures must
|
||||
* poll the timeline endpoint until the ruler writes rows.
|
||||
*
|
||||
* Fixture timeouts are set generously because:
|
||||
* 1. CI environments are slower than local dev machines
|
||||
* 2. Ruler evaluation depends on Kafka/ClickHouse latency
|
||||
* 3. A timeout should mean "something is broken", not "it's just slow today"
|
||||
*
|
||||
* Typical measured times (local):
|
||||
* - API call (create/delete rule/channel): ~1-2s
|
||||
* - waitForTimelineEntries (logs, 25 services): ~20-35s
|
||||
* - waitForTimelineEntries (metrics, 2 hosts): ~10s
|
||||
* - waitForTimelineStates (firing→resolved): ~105s
|
||||
* - waitForTimelineEntries (nodata state): ~60-120s
|
||||
*/
|
||||
|
||||
// ─── Fixture-specific wait overrides (ms) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Metrics history wait override. Metrics push faster than logs, but ruler still
|
||||
* needs 2 evaluation windows to confirm state. 10s typical, 120s defensive.
|
||||
*/
|
||||
export const WAIT_METRICS_TIMELINE_TIMEOUT = 120_000;
|
||||
|
||||
/**
|
||||
* Nodata state detection. Ruler must evaluate twice with empty result set.
|
||||
* Takes longer than firing detection because it's an absence check.
|
||||
*/
|
||||
export const WAIT_NODATA_TIMELINE_TIMEOUT = 180_000;
|
||||
|
||||
// ─── Fixture timeouts (ms) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* alertList: seeds 12 rules via API.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + 12×createRule(24s) = ~26s.
|
||||
* Timeout: 120s (~5x headroom for CI).
|
||||
*/
|
||||
export const FIXTURE_ALERT_LIST_TIMEOUT = 120_000;
|
||||
|
||||
/**
|
||||
* alertHistory: seeds 25 logs + 2 rules, waits for ruler evaluation.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + seedLogs(10s) + 2×createRule(4s) +
|
||||
* 2×waitForEntries(70s) + 2×disableRule(4s) = ~90s.
|
||||
* Timeout: 240s (~2.5x headroom).
|
||||
*/
|
||||
export const FIXTURE_ALERT_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
/**
|
||||
* metricsHistory: seeds 2 hosts, waits for metrics ruler cycle.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + seedMetrics(5s) + createRule(2s) +
|
||||
* waitForEntries(10s actual, 120s budget) + disableRule(2s) = ~21s.
|
||||
* Timeout: 240s (matches alertHistory for consistency).
|
||||
*/
|
||||
export const FIXTURE_METRICS_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
/**
|
||||
* tracesHistory: seeds 3 trace services, waits for ruler.
|
||||
*
|
||||
* Breakdown: similar to alertHistory but fewer services = ~45s.
|
||||
* Timeout: 240s (~5x headroom).
|
||||
*/
|
||||
export const FIXTURE_TRACES_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
/**
|
||||
* resolvedHistory: waits for firing→resolved transition.
|
||||
*
|
||||
* Breakdown: setup(30s) + waitForStates(105s) = ~135s.
|
||||
* Timeout: 300s (~2x headroom). Longest because resolved requires
|
||||
* evalWindow expiry after data stops matching.
|
||||
*/
|
||||
export const FIXTURE_RESOLVED_HISTORY_TIMEOUT = 300_000;
|
||||
|
||||
/**
|
||||
* noDataHistory: waits for nodata state to appear.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + createRule(2s) + waitForEntries(60-120s).
|
||||
* Timeout: 300s. Nodata detection is slowest because ruler must confirm
|
||||
* absence across multiple evaluation cycles.
|
||||
*/
|
||||
export const FIXTURE_NODATA_HISTORY_TIMEOUT = 300_000;
|
||||
|
||||
/**
|
||||
* emptyHistory: creates rule then immediately disables it (no ruler wait).
|
||||
*
|
||||
* Breakdown: createChannel(2s) + createRule(2s) + disableRule(2s) = ~6s.
|
||||
* Timeout: 120s (generous for slow CI, no ruler dependency).
|
||||
*/
|
||||
export const FIXTURE_EMPTY_HISTORY_TIMEOUT = 120_000;
|
||||
@@ -1,81 +1,11 @@
|
||||
import {
|
||||
test as base,
|
||||
expect,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
|
||||
|
||||
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
|
||||
export const ADMIN: User = {
|
||||
email: process.env.SIGNOZ_E2E_USERNAME!,
|
||||
password: process.env.SIGNOZ_E2E_PASSWORD!,
|
||||
};
|
||||
|
||||
// Per-worker storageState cache. One login per unique user per worker.
|
||||
// Promise-valued so concurrent requests share the same in-flight work.
|
||||
// Held in memory only — no .auth/ dir, no JSON on disk.
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await login(page, user);
|
||||
await pinSidenav(page);
|
||||
const state = await ctx.storageState();
|
||||
await ctx.close();
|
||||
return state;
|
||||
})();
|
||||
|
||||
storageByUser.set(user.email, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function login(page: Page, user: User): Promise<void> {
|
||||
if (!user.email || !user.password) {
|
||||
throw new Error(
|
||||
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
|
||||
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
|
||||
);
|
||||
}
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(user.email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
// Post-login lands somewhere different depending on whether the org is
|
||||
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
|
||||
// welcome). Wait for URL to move off /login — whichever page follows
|
||||
// is fine, each spec navigates to the feature under test anyway.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
}
|
||||
|
||||
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
|
||||
// Server-side pref, so set once per user at login.
|
||||
async function pinSidenav(page: Page): Promise<void> {
|
||||
const token = await page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: true },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
|
||||
// worker-scoped fixtures and suite hooks share one login with this fixture.
|
||||
export { ADMIN };
|
||||
export type { User };
|
||||
|
||||
export const test = base.extend<{
|
||||
/**
|
||||
@@ -95,7 +25,7 @@ export const test = base.extend<{
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
authedPage: async ({ browser, user }, use) => {
|
||||
const storageState = await storageFor(browser, user);
|
||||
const storageState = await storageStateFor(browser, user);
|
||||
const ctx = await browser.newContext({ storageState });
|
||||
const page = await ctx.newPage();
|
||||
// Opt-in CPU throttling to reproduce GitHub-Linux-runner conditions on
|
||||
|
||||
105
tests/e2e/helpers/alert-forms/constants.ts
Normal file
105
tests/e2e/helpers/alert-forms/constants.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// ─── Routes ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_NEW_PATH = '/alerts/new';
|
||||
|
||||
/**
|
||||
* The standalone edit route. Distinct from `/alerts/overview`, which renders the
|
||||
* *same* editor inside the details shell. The two are not interchangeable for v2
|
||||
* rules — see `edit/v2.spec.ts` EV2-12.
|
||||
*/
|
||||
export const ALERT_EDIT_PATH = '/alerts/edit';
|
||||
|
||||
// ─── Enums mirrored from the frontend ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* URL values of `AlertTypes` (`frontend/src/types/api/alerts/alertTypes.ts`).
|
||||
* Note `METRICS` maps to the *singular* `METRIC_BASED_ALERT` — the enum key and
|
||||
* its value disagree in the source, and the URL carries the value.
|
||||
*/
|
||||
export const AlertType = {
|
||||
METRICS: 'METRIC_BASED_ALERT',
|
||||
LOGS: 'LOGS_BASED_ALERT',
|
||||
TRACES: 'TRACES_BASED_ALERT',
|
||||
EXCEPTIONS: 'EXCEPTIONS_BASED_ALERT',
|
||||
ANOMALY: 'ANOMALY_BASED_ALERT',
|
||||
} as const;
|
||||
|
||||
export type AlertTypeValue = (typeof AlertType)[keyof typeof AlertType];
|
||||
|
||||
/** `AlertDetectionTypes` (`frontend/src/container/FormAlertRules/index.tsx:78-81`). */
|
||||
export const RuleType = {
|
||||
THRESHOLD: 'threshold_rule',
|
||||
ANOMALY: 'anomaly_rule',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertThresholdOperator` (`CreateAlertV2/context/types.ts:97-105`) and its
|
||||
* dropdown labels (`context/constants.ts:123-137`).
|
||||
*
|
||||
* Threshold-alert operators only. Anomaly alerts render a different, shorter
|
||||
* list (`ANOMALY_THRESHOLD_OPERATOR_OPTIONS`) with relabelled entries.
|
||||
*/
|
||||
export const ThresholdOperator = {
|
||||
ABOVE: { value: 'above', label: 'ABOVE' },
|
||||
BELOW: { value: 'below', label: 'BELOW' },
|
||||
EQUAL_TO: { value: 'equal', label: 'EQUAL TO' },
|
||||
NOT_EQUAL_TO: { value: 'not_equal', label: 'NOT EQUAL TO' },
|
||||
ABOVE_OR_EQUAL_TO: { value: 'above_or_equal', label: 'ABOVE OR EQUAL TO' },
|
||||
BELOW_OR_EQUAL_TO: { value: 'below_or_equal', label: 'BELOW OR EQUAL TO' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertThresholdMatchType` (`CreateAlertV2/context/types.ts:105-111`) and its
|
||||
* dropdown labels (`context/constants.ts:136-142`).
|
||||
*
|
||||
* Watch the plural: the enum *key* is `ALL_THE_TIME` but the wire value is
|
||||
* `all_the_times`, and the API rejects the singular outright — the same
|
||||
* key/value mismatch as `METRICS_BASED_ALERT` → `METRIC_BASED_ALERT`.
|
||||
*/
|
||||
export const ThresholdMatchType = {
|
||||
AT_LEAST_ONCE: { value: 'at_least_once', label: 'AT LEAST ONCE' },
|
||||
ALL_THE_TIME: { value: 'all_the_times', label: 'ALL THE TIME' },
|
||||
ON_AVERAGE: { value: 'on_average', label: 'ON AVERAGE' },
|
||||
IN_TOTAL: { value: 'in_total', label: 'IN TOTAL' },
|
||||
LAST: { value: 'last', label: 'LAST' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertListTabs` (`frontend/src/pages/AlertList/types.ts:7-9`). The values are
|
||||
* space-less — the tab *labels* read "Triggered Alerts" but the `tab` URL param
|
||||
* is `TriggeredAlerts`, and asserting the label form silently fails.
|
||||
*/
|
||||
export const AlertListTab = {
|
||||
TRIGGERED_ALERTS: 'TriggeredAlerts',
|
||||
ALERT_RULES: 'AlertRules',
|
||||
CONFIGURATION: 'Configuration',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The four cards a stock stack shows, in render order
|
||||
* (`CreateAlertRule/SelectAlertType/config.ts:10-31`). Anomaly is `unshift`ed to
|
||||
* the **front** of this list when the `ANOMALY_DETECTION` feature flag is active,
|
||||
* so both the count and the order change when it is enabled.
|
||||
*/
|
||||
export const STOCK_ALERT_TYPE_CARDS: AlertTypeValue[] = [
|
||||
AlertType.METRICS,
|
||||
AlertType.LOGS,
|
||||
AlertType.TRACES,
|
||||
AlertType.EXCEPTIONS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Rolling-window presets (`EvaluationSettings/constants.ts:9-18`) paired with the
|
||||
* button label each one produces. A value *outside* this set collapses to `custom`
|
||||
* on load (`utils.tsx:86-96`), which is what makes it a prefill assertion worth
|
||||
* having: `10m0s` proves the seed was read, `7m0s` proves the fallback fired.
|
||||
*/
|
||||
export const EVALUATION_WINDOW_PRESETS = {
|
||||
'5m0s': 'Last 5 minutes',
|
||||
'10m0s': 'Last 10 minutes',
|
||||
'15m0s': 'Last 15 minutes',
|
||||
'30m0s': 'Last 30 minutes',
|
||||
'1h0m0s': 'Last 1 hour',
|
||||
'2h0m0s': 'Last 2 hours',
|
||||
'4h0m0s': 'Last 4 hours',
|
||||
} as const;
|
||||
118
tests/e2e/helpers/alert-forms/navigation.ts
Normal file
118
tests/e2e/helpers/alert-forms/navigation.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
ALERTS_NEW_PATH,
|
||||
AlertType,
|
||||
type AlertTypeValue,
|
||||
RuleType,
|
||||
STOCK_ALERT_TYPE_CARDS,
|
||||
} from './constants';
|
||||
import { v1SaveButton } from './v1';
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the bare type-selection page. `isTypeSelectionMode` is
|
||||
* `!alertType && !ruleType && !compositeQuery`
|
||||
* (`container/CreateAlertRule/index.tsx:39-41`), so *any* of those three params
|
||||
* skips this page — including a stale `compositeQuery` left in the URL.
|
||||
*/
|
||||
export async function gotoAlertTypeSelection(page: Page): Promise<void> {
|
||||
await page.goto(ALERTS_NEW_PATH);
|
||||
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
|
||||
}
|
||||
|
||||
export function alertTypeCard(page: Page, type: AlertTypeValue): Locator {
|
||||
return page.getByTestId(`alert-type-card-${type}`);
|
||||
}
|
||||
|
||||
function alertTypeCards(page: Page): Locator {
|
||||
return page.locator('[data-testid^="alert-type-card-"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the anomaly card is on the page, i.e. whether `ANOMALY_DETECTION` is
|
||||
* active for this stack. It **is** active on the pytest-bootstrapped integration
|
||||
* stack, so every card-count assertion has to branch on it rather than hard-code
|
||||
* 4.
|
||||
*/
|
||||
export async function hasAnomalyAlertTypeCard(page: Page): Promise<boolean> {
|
||||
return (await alertTypeCard(page, AlertType.ANOMALY).count()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the type-selection page shows exactly the expected set of cards: the
|
||||
* four stock ones, plus anomaly *first* when the flag is on (`getOptionList`
|
||||
* `unshift`s it, `SelectAlertType/config.ts:33-40`).
|
||||
*
|
||||
* Written as an exact set rather than "at least four" so that adding a fifth
|
||||
* signal still fails this assertion — the flag branch is the only slack.
|
||||
*/
|
||||
export async function expectAlertTypeCardSet(page: Page): Promise<void> {
|
||||
const anomaly = await hasAnomalyAlertTypeCard(page);
|
||||
const expected = anomaly
|
||||
? [AlertType.ANOMALY, ...STOCK_ALERT_TYPE_CARDS]
|
||||
: STOCK_ALERT_TYPE_CARDS;
|
||||
|
||||
const cards = alertTypeCards(page);
|
||||
await expect(cards).toHaveCount(expected.length);
|
||||
|
||||
// Read the testids positionally so order is asserted too — anomaly being
|
||||
// unshifted rather than appended is the behaviour worth pinning.
|
||||
const rendered: (string | null)[] = [];
|
||||
for (let i = 0; i < expected.length; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
rendered.push(await cards.nth(i).getAttribute('data-testid'));
|
||||
}
|
||||
expect(rendered).toEqual(expected.map((type) => `alert-type-card-${type}`));
|
||||
}
|
||||
|
||||
export interface CreateAlertUrlOptions {
|
||||
alertType?: AlertTypeValue;
|
||||
ruleType?: string;
|
||||
/** Sets `showClassicCreateAlertsPage=true` ⇒ the v1 classic form. */
|
||||
classic?: boolean;
|
||||
/** Merged in last, so it can override anything above. */
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function createAlertUrl({
|
||||
alertType = AlertType.LOGS,
|
||||
ruleType = RuleType.THRESHOLD,
|
||||
classic = false,
|
||||
params = {},
|
||||
}: CreateAlertUrlOptions = {}): string {
|
||||
const search = new URLSearchParams({ alertType, ruleType });
|
||||
if (classic) {
|
||||
search.set('showClassicCreateAlertsPage', 'true');
|
||||
}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
search.set(key, value);
|
||||
}
|
||||
return `${ALERTS_NEW_PATH}?${search.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the **v2** builder and wait until it has settled. The wait is two-part on
|
||||
* purpose: the header proves the builder mounted, and the `compositeQuery` in the
|
||||
* URL proves `useShareBuilderUrl` has finished serialising the default query —
|
||||
* without the second half, an assertion on the URL races the builder's own
|
||||
* rewrite (the same trap `gotoAlertOverview` documents).
|
||||
*/
|
||||
export async function gotoCreateAlertV2(
|
||||
page: Page,
|
||||
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
|
||||
): Promise<void> {
|
||||
await page.goto(createAlertUrl({ ...options, classic: false }));
|
||||
await expect(page.getByTestId('alert-name-input')).toBeVisible();
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Open the **v1** classic create form and wait for its primary action. */
|
||||
export async function gotoCreateAlertV1(
|
||||
page: Page,
|
||||
options: Omit<CreateAlertUrlOptions, 'classic'> = {},
|
||||
): Promise<void> {
|
||||
await page.goto(createAlertUrl({ ...options, classic: true }));
|
||||
await expect(v1SaveButton(page)).toBeVisible();
|
||||
}
|
||||
154
tests/e2e/helpers/alert-forms/shared.ts
Normal file
154
tests/e2e/helpers/alert-forms/shared.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// ─── Antd select helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read an antd multi-select's chosen values, for asserting what a threshold row
|
||||
* ended up pointing at.
|
||||
*/
|
||||
export function selectedTags(scope: Locator): Locator {
|
||||
return scope.locator('.ant-select-selection-item-content');
|
||||
}
|
||||
|
||||
/**
|
||||
* The currently-open antd dropdown. Scoping option lookups to it matters because
|
||||
* antd keeps previously-opened dropdowns in the DOM with
|
||||
* `.ant-select-dropdown-hidden`, so an unscoped `.ant-select-item-option` can
|
||||
* resolve into a stale list.
|
||||
*
|
||||
* Adequate when only one select is ever open on the page. When several selects of
|
||||
* the *same kind* exist — the per-threshold channel selects — use
|
||||
* {@link ownDropdown} instead: `-hidden` is applied only after the close
|
||||
* transition, so "the open dropdown" is briefly ambiguous.
|
||||
*/
|
||||
export function openDropdown(page: Page): Locator {
|
||||
return page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)');
|
||||
}
|
||||
|
||||
/**
|
||||
* The dropdown belonging to one specific antd select, resolved through the
|
||||
* combobox's `aria-controls` → the listbox id it owns.
|
||||
*
|
||||
* This is the only unambiguous way to address one of several sibling selects'
|
||||
* option lists. Filtering on "the visible dropdown" is not enough: with four
|
||||
* threshold rows, row N's list is still mid-close while row N+1's opens, so the
|
||||
* option lookup lands in the wrong list and the click fails with "element is not
|
||||
* stable" and then "element is not visible".
|
||||
*/
|
||||
export async function ownDropdown(
|
||||
page: Page,
|
||||
select: Locator,
|
||||
): Promise<Locator> {
|
||||
const listId = await select
|
||||
.locator('input[role="combobox"]')
|
||||
.getAttribute('aria-controls');
|
||||
if (!listId) {
|
||||
throw new Error(
|
||||
'select has no aria-controls — not an antd combobox, or not yet opened',
|
||||
);
|
||||
}
|
||||
return page
|
||||
.locator('.ant-select-dropdown')
|
||||
.filter({ has: page.locator(`[id="${listId}"]`) });
|
||||
}
|
||||
|
||||
/**
|
||||
* An option in the open dropdown, matched on its **exact** label. Substring
|
||||
* matching is wrong here: `hasText: 'EQUAL TO'` also matches `NOT EQUAL TO`.
|
||||
*/
|
||||
export function dropdownOption(page: Page, label: string): Locator {
|
||||
return openDropdown(page)
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ has: page.getByText(label, { exact: true }) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a notification channel by exact name in one of the two channel selects —
|
||||
* v2's per-threshold one and v1's single `alert-channel-select`. Both are
|
||||
* `mode="multiple"` antd selects over the *same* global channel list, so both need
|
||||
* exactly this sequence; the shared body is why this is one function rather than
|
||||
* two near-copies.
|
||||
*
|
||||
* The list must be **searched**, not scrolled. Channels are global while the
|
||||
* `alertChannel` fixture is worker-scoped, so a shared stack accumulates one
|
||||
* channel per worker (plus anything a killed run leaked) and antd virtualises the
|
||||
* dropdown: measured on this stack, 31 channels render **10** options into the DOM,
|
||||
* and the wanted one is simply not there. Clicking by name without filtering first
|
||||
* is therefore not a slow path, it is a missing element — and it was the single
|
||||
* biggest source of flake in this suite. It fails as a plain click timeout
|
||||
* ("waiting for locator … .ant-select-item-option …"), which reads like a renamed
|
||||
* testid rather than a virtualised list.
|
||||
*/
|
||||
export async function pickChannelByName(
|
||||
page: Page,
|
||||
select: Locator,
|
||||
channelName: string,
|
||||
): Promise<void> {
|
||||
const tagsBefore = await selectedTags(select).count();
|
||||
await select.click();
|
||||
await expect(select).toHaveClass(/ant-select-open/);
|
||||
|
||||
// `fill` on the combobox input rather than `keyboard.type`: the query is a ~30
|
||||
// character channel name and every keystroke re-runs antd's filter, so typing it
|
||||
// costs ~2.5 s per pick — CV2-09 makes four of them, which was a quarter of that
|
||||
// test's 30 s budget. `fill` sets the value in one input event, which is all
|
||||
// rc-select's search needs.
|
||||
await select.locator('input[role="combobox"]').fill(channelName);
|
||||
const dropdown = await ownDropdown(page, select);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: channelName })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// A multi-select stays open after a pick and its dropdown overlays the controls
|
||||
// below, which the next interaction would otherwise hit instead.
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// Wait for *this* select to report itself closed before returning. antd removes
|
||||
// `.ant-select-dropdown-hidden` only after the close transition, so a caller that
|
||||
// immediately opens the next row's select races a still-visible stale list: the
|
||||
// option lookup then resolves inside the previous row's dropdown and the click
|
||||
// fails with "element is not stable" followed by "element is not visible".
|
||||
await expect(select).not.toHaveClass(/ant-select-open/);
|
||||
|
||||
// Fail here rather than three assertions later: a silently-missed pick shows up
|
||||
// as "Save is still disabled", which points at the validator instead of at this.
|
||||
//
|
||||
// Counted, not name-matched: v2's select sets `maxTagTextLength={10}`
|
||||
// (`ThresholdItem.tsx:140`) so its tag reads `e2e-alerts…`, and v1's passes
|
||||
// `optionLabelProp="label"` to options that carry no `label` prop, so its tag
|
||||
// renders empty. Neither can ever contain the full channel name. The name itself
|
||||
// is verified where it actually matters — in the request body (CV2-20, CV1-08).
|
||||
await expect(selectedTags(select)).toHaveCount(tagsBefore + 1);
|
||||
}
|
||||
|
||||
// ─── SEED-CH1: a stack with no notification channels ───────────────────────
|
||||
|
||||
/**
|
||||
* Route-stub `GET /api/v1/channels` to an empty list for this page only.
|
||||
*
|
||||
* This is the **one** place the alerts suite mocks the network, and it is a
|
||||
* deliberate exception to the standing no-stubbing rule. The justification: zero
|
||||
* channels is a real product state — every fresh install has it — and it is the
|
||||
* only state that reaches the `disabled` broadcast switch and
|
||||
* the empty-channel dropdown content. It cannot be produced server-side, because
|
||||
* `alertChannel` is worker-scoped and parallel workers share one stack, so
|
||||
* deleting the channel would break every other scenario running at that moment.
|
||||
*
|
||||
* Both forms read the same endpoint through `api/channels/getAll`, so one stub
|
||||
* covers v1 and v2.
|
||||
*/
|
||||
export async function stubNoChannels(page: Page): Promise<void> {
|
||||
await page.route('**/api/v1/channels', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'success', data: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
10
tests/e2e/helpers/alert-forms/v1.ts
Normal file
10
tests/e2e/helpers/alert-forms/v1.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The v1 primary action. Its *label* is mode-dependent — *Create Rule* when
|
||||
* `isNewRule`, *Save Rule* when editing (`FormAlertRules/index.tsx:970`) — so
|
||||
* scenarios that care about the mode assert the text; the locator itself does not.
|
||||
*/
|
||||
export function v1SaveButton(page: Page): Locator {
|
||||
return page.getByTestId('alert-save-button');
|
||||
}
|
||||
226
tests/e2e/helpers/alert-forms/v2.ts
Normal file
226
tests/e2e/helpers/alert-forms/v2.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { EVALUATION_WINDOW_PRESETS } from './constants';
|
||||
import { pickChannelByName } from './shared';
|
||||
|
||||
// ─── v2 builder ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Footer buttons. The disabled Save/Test buttons are wrapped in a `<span>` inside
|
||||
* an antd `Tooltip` (`CreateAlertV2/Footer/Footer.tsx:198-204`) — the wrapper is
|
||||
* why {@link v2SaveTooltip} exists instead of reading a `title` attribute, and
|
||||
* why these are testids rather than accessible names: the name lookup also
|
||||
* matched the wrapper in some states.
|
||||
*/
|
||||
export function v2SaveButton(page: Page): Locator {
|
||||
return page.getByTestId('save-alert-rule-button');
|
||||
}
|
||||
|
||||
export function v2TestButton(page: Page): Locator {
|
||||
return page.getByTestId('test-notification-button');
|
||||
}
|
||||
|
||||
export function v2DiscardButton(page: Page): Locator {
|
||||
return page.getByTestId('discard-alert-rule-button');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the v2 Discard button — via `dispatchEvent`, because a real click cannot
|
||||
* reach it.
|
||||
*
|
||||
* The footer is `position: fixed; left: 63px` (the *collapsed* nav rail width) and
|
||||
* Discard is its left-most control, so the button occupies roughly x 75-170 at the
|
||||
* bottom of the viewport. The side navigation occupies x 0-240 whenever it is
|
||||
* 240px wide, which is: always when pinned — the default — and transiently when
|
||||
* not pinned, because a mouse travelling toward the button crosses the rail and
|
||||
* triggers `:not(.pinned).is-hovered`. Either way `document.elementFromPoint` at
|
||||
* the button's centre returns the nav's `.nav-item-data`, so the nav swallows the
|
||||
* click.
|
||||
*
|
||||
* `{ force: true }` does **not** help: it skips Playwright's actionability wait
|
||||
* but still delivers a real mouse event at those coordinates, which the nav
|
||||
* receives. `dispatchEvent('click')` bypasses hit-testing entirely and React's
|
||||
* delegated handler fires normally — verified: the page navigates to `/alerts`.
|
||||
*
|
||||
* This is a workaround for a **product** bug, not for a flaky test.
|
||||
* `create/edge.spec.ts` CE-09 is the skipped scenario that asserts the fixed
|
||||
* behaviour; unskipping it and reverting this helper to `.click()` belong in the
|
||||
* same commit as the fix.
|
||||
*/
|
||||
export async function v2ClickDiscard(page: Page): Promise<void> {
|
||||
await v2DiscardButton(page).dispatchEvent('click');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the side navigation currently overlaps a point — the mechanism behind
|
||||
* {@link v2ClickDiscard}. Used by CE-09, which asserts the *absence* of that
|
||||
* overlap and is skipped until the footer is fixed.
|
||||
*/
|
||||
export async function elementAtPointClassName(
|
||||
page: Page,
|
||||
x: number,
|
||||
y: number,
|
||||
): Promise<string> {
|
||||
return page.evaluate(
|
||||
([px, py]) => {
|
||||
const el = document.elementFromPoint(px as number, py as number);
|
||||
return el ? String(el.className) : '';
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover the (disabled) Save button and return the antd tooltip's text — this is
|
||||
* the only way to read `validateCreateAlertState`'s message, since the button
|
||||
* cannot be clicked while a message exists.
|
||||
*/
|
||||
export async function v2SaveTooltip(page: Page): Promise<string> {
|
||||
// The tooltip anchors to the wrapper span, not the disabled button: a disabled
|
||||
// button emits no pointer events, so hovering it directly never opens.
|
||||
await v2SaveButton(page).locator('xpath=..').hover();
|
||||
const tooltip = page.locator('.ant-tooltip-inner').first();
|
||||
await expect(tooltip).toBeVisible();
|
||||
return (await tooltip.innerText()).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold rows. There is **no** `threshold-item-<id>` testid — the row is a bare
|
||||
* `className="threshold-item"` (`AlertCondition/ThresholdItem.tsx`), so rows are
|
||||
* addressed positionally.
|
||||
*/
|
||||
export function thresholdRows(page: Page): Locator {
|
||||
return page.locator('.threshold-item');
|
||||
}
|
||||
|
||||
export function thresholdRow(page: Page, index: number): Locator {
|
||||
return thresholdRows(page).nth(index);
|
||||
}
|
||||
|
||||
/** Assign a notification channel to the Nth v2 threshold. */
|
||||
export async function selectThresholdChannel(
|
||||
page: Page,
|
||||
index: number,
|
||||
channelName: string,
|
||||
): Promise<void> {
|
||||
await pickChannelByName(
|
||||
page,
|
||||
page.getByTestId('threshold-notification-channel-select').nth(index),
|
||||
channelName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a label through the v2 header editor. The input is a single field with two
|
||||
* phases — key, then value, each committed with Enter
|
||||
* (`CreateAlertHeader/LabelsInput.tsx:25-93`) — and a `key:value` string in the
|
||||
* first phase is accepted as a shortcut. This helper drives the two-phase path
|
||||
* because that is what a user does.
|
||||
*/
|
||||
export async function addAlertLabel(
|
||||
page: Page,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId('alert-add-label-button').click();
|
||||
const input = page.getByTestId('alert-add-label-input');
|
||||
await input.fill(key);
|
||||
await input.press('Enter');
|
||||
await input.fill(value);
|
||||
await input.press('Enter');
|
||||
|
||||
// Committing a label does *not* close the editor — `isAdding` stays true so a
|
||||
// user can type several in a row, which means `alert-add-label-button` is still
|
||||
// unmounted. Escape (with both fields empty) is what closes it, and without this
|
||||
// a second call to this helper waits forever for the add button.
|
||||
await input.press('Escape');
|
||||
await expect(page.getByTestId('alert-add-label-button')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* The toggle inside an `AdvancedOptionItem` (repeat notifications, send-if-missing,
|
||||
* enforce-minimum-datapoints). The `Switch` there carries no testid of its own, so
|
||||
* it is reached through the container's — hence the container testid being the
|
||||
* documented handle rather than the switch.
|
||||
*/
|
||||
export function advancedOptionToggle(
|
||||
page: Page,
|
||||
containerTestId: string,
|
||||
): Locator {
|
||||
return page.getByTestId(containerTestId).locator('[role="switch"]');
|
||||
}
|
||||
|
||||
// ─── Evaluation window + cadence ───────────────────────────────────────────
|
||||
|
||||
export function evaluationSettingsButton(page: Page): Locator {
|
||||
return page.getByTestId('evaluation-settings-button');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the evaluation-window popover. It is an antd `Popover`, so its content is
|
||||
* only in the DOM while open — every option lookup has to come after this.
|
||||
*/
|
||||
export async function openEvaluationSettings(page: Page): Promise<void> {
|
||||
await evaluationSettingsButton(page).click();
|
||||
await expect(page.locator('.evaluation-window-popover')).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* A popover option. The popover renders two lists from one component, keyed by
|
||||
* `data-section-id` — `window-type` (Rolling / Cumulative) and `timeframe` — and
|
||||
* the testid carries both, so `timeframe-option-10m0s` cannot collide with a
|
||||
* window-type value.
|
||||
*/
|
||||
export function evaluationWindowOption(
|
||||
page: Page,
|
||||
section: 'window-type' | 'timeframe',
|
||||
value: string,
|
||||
): Locator {
|
||||
return page.getByTestId(`${section}-option-${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a rolling timeframe and wait for the trigger button to reflect it. The wait
|
||||
* matters: the popover closes on its own animation, and a spec that immediately
|
||||
* clicks Save can otherwise post the previous window.
|
||||
*/
|
||||
export async function selectEvaluationTimeframe(
|
||||
page: Page,
|
||||
value: keyof typeof EVALUATION_WINDOW_PRESETS,
|
||||
): Promise<void> {
|
||||
await openEvaluationSettings(page);
|
||||
await evaluationWindowOption(page, 'timeframe', value).click();
|
||||
await expect(evaluationSettingsButton(page)).toContainText(
|
||||
EVALUATION_WINDOW_PRESETS[value],
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the ADVANCED OPTIONS panel inside the alert-condition section.
|
||||
*
|
||||
* antd's `Collapse` renders its panel children lazily, so `evaluation-cadence-*`
|
||||
* and the two `AdvancedOptionItem` containers do not exist in the DOM at all until
|
||||
* this runs — an assertion on them without it fails as "not found" rather than as
|
||||
* "not visible", which reads like a missing testid.
|
||||
*/
|
||||
export async function expandAdvancedOptions(page: Page): Promise<void> {
|
||||
const header = page.getByRole('button', { name: /ADVANCED OPTIONS/i });
|
||||
if ((await header.getAttribute('aria-expanded')) !== 'true') {
|
||||
await header.click();
|
||||
}
|
||||
await expect(page.getByTestId('evaluation-cadence-input-group')).toBeVisible();
|
||||
}
|
||||
|
||||
/** The cadence duration field — `evaluation.spec.frequency`'s UI half. */
|
||||
export function evaluationCadenceInput(page: Page): Locator {
|
||||
return page.getByTestId('evaluation-cadence-duration-input');
|
||||
}
|
||||
|
||||
export function evaluationCadenceUnitSelect(page: Page): Locator {
|
||||
return page.getByTestId('evaluation-cadence-unit-select');
|
||||
}
|
||||
|
||||
export function labelPill(page: Page, key: string, value: string): Locator {
|
||||
return page.getByTestId(`label-pill-${key}-${value}`);
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from './common';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_LIST_PATH = '/alerts';
|
||||
export const ALERT_OVERVIEW_PATH = '/alerts/overview';
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ThresholdAlertSeed {
|
||||
/** Alert rule name. Keep unique per test to avoid collisions. */
|
||||
name: string;
|
||||
/** The critical-threshold target value to persist and later assert. */
|
||||
target: number;
|
||||
/**
|
||||
* Notification channel names for the critical threshold. At least one is
|
||||
* required by the API — seed one with {@link createEmailChannelViaApi}.
|
||||
*/
|
||||
channels: string[];
|
||||
}
|
||||
|
||||
// ─── Payload ─────────────────────────────────────────────────────────────
|
||||
|
||||
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
|
||||
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
|
||||
// CreateAlertV2 UI posts to POST /api/v2/rules.
|
||||
function buildThresholdRulePayload({
|
||||
name,
|
||||
target,
|
||||
channels,
|
||||
}: ThresholdAlertSeed): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
annotations: {
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
summary:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
},
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow: '5m0s', frequency: '1m' },
|
||||
},
|
||||
notificationSettings: {
|
||||
groupBy: [],
|
||||
renotify: { enabled: false, interval: '30m', alertStates: [] },
|
||||
usePolicy: false,
|
||||
},
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
compositeQuery: {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_calls_total',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
filter: { expression: '' },
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: 'critical',
|
||||
target,
|
||||
targetUnit: '',
|
||||
recoveryTarget: null,
|
||||
matchType: 'at_least_once',
|
||||
op: 'above',
|
||||
channels,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed an email notification channel via API. Returns its `{ id, name }`;
|
||||
* thresholds reference channels by name, cleanup deletes by id. `to` is never
|
||||
* delivered — the channel only needs to exist to satisfy rule validation.
|
||||
*/
|
||||
export async function createEmailChannelViaApi(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v1/channels', {
|
||||
data: {
|
||||
name,
|
||||
email_configs: [
|
||||
{ send_resolved: true, to: 'e2e@signoz.test', html: '', headers: {} },
|
||||
],
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v1/channels ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return { id: String(json.data.id), name };
|
||||
}
|
||||
|
||||
/** Delete a notification channel by ID (best-effort cleanup). */
|
||||
export async function deleteChannelViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v1/channels/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a v2 threshold alert via API. Returns the new rule ID. Pair with
|
||||
* {@link deleteAlertViaApi} in an `afterAll`/`afterEach` for cleanup.
|
||||
*/
|
||||
export async function createThresholdAlertViaApi(
|
||||
page: Page,
|
||||
seed: ThresholdAlertSeed,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/rules', {
|
||||
data: buildThresholdRulePayload(seed),
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v2/rules ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/** Delete a rule by ID. Tolerates an already-deleted rule (best-effort cleanup). */
|
||||
export async function deleteAlertViaApi(page: Page, id: string): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v2/rules/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the alert overview (edit) page for `ruleId` and wait until it has fully
|
||||
* settled: the condition editor is visible and the query builder has finished
|
||||
* serializing the loaded query into the URL.
|
||||
*/
|
||||
export async function gotoAlertOverview(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}`);
|
||||
await expect(page.getByTestId('threshold-value-input')).toBeVisible();
|
||||
// The builder rewrites location.search shortly after load (adds compositeQuery).
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
// Let post-load state updates flush so callers read the settled value.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout -- no DOM signal for the async settle
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
257
tests/e2e/helpers/alerts/api.ts
Normal file
257
tests/e2e/helpers/alerts/api.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from '../common';
|
||||
|
||||
import {
|
||||
buildThresholdRulePayload,
|
||||
logsCompositeQuery,
|
||||
metricsCompositeQuery,
|
||||
tracesCompositeQuery,
|
||||
v1RulePayload,
|
||||
v2RulePayload,
|
||||
} from './payloads';
|
||||
import type {
|
||||
AlertSchema,
|
||||
LogsAlertSeed,
|
||||
MetricAlertSeed,
|
||||
ThresholdAlertSeed,
|
||||
TracesAlertSeed,
|
||||
} from './types';
|
||||
|
||||
// ─── API helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Seed an email notification channel via API. Returns its `{ id, name }`;
|
||||
* thresholds reference channels by name, cleanup deletes by id. `to` is never
|
||||
* delivered — the channel only needs to exist to satisfy rule validation.
|
||||
*/
|
||||
export async function createEmailChannelViaApi(
|
||||
page: Page,
|
||||
name: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v1/channels', {
|
||||
data: {
|
||||
name,
|
||||
email_configs: [
|
||||
{ send_resolved: true, to: 'e2e@signoz.test', html: '', headers: {} },
|
||||
],
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v1/channels ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return { id: String(json.data.id), name };
|
||||
}
|
||||
|
||||
/** Delete a notification channel by ID (best-effort cleanup). */
|
||||
export async function deleteChannelViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v1/channels/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a v2 threshold alert via API. Returns the new rule ID. Pair with
|
||||
* {@link deleteAlertViaApi} in an `afterAll`/`afterEach` for cleanup.
|
||||
*/
|
||||
export async function createThresholdAlertViaApi(
|
||||
page: Page,
|
||||
seed: ThresholdAlertSeed,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/rules', {
|
||||
data: buildThresholdRulePayload(seed),
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST /api/v2/rules ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/** Delete a rule by ID. Tolerates an already-deleted rule (best-effort cleanup). */
|
||||
export async function deleteAlertViaApi(page: Page, id: string): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`/api/v2/rules/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function postRule(
|
||||
page: Page,
|
||||
schema: AlertSchema,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const token = await authToken(page);
|
||||
const path = schema === 'v1' ? '/api/v1/rules' : '/api/v2/rules';
|
||||
const res = await page.request.post(path, {
|
||||
data: payload,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST ${path} ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return String(json.data.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logs threshold rule grouped by `service.name`. `schema: 'v1'` posts
|
||||
* the legacy payload to `/api/v1/rules`, which the UI then renders through the
|
||||
* v1 branch of `AlertHeader` / `ActionButtons` — both schemas serve the *same*
|
||||
* history APIs, so history scenarios can be parameterised over them.
|
||||
*/
|
||||
export async function createLogsAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
schema = 'v2',
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
severity = schema === 'v1' ? 'warning' : 'critical',
|
||||
extraLabels,
|
||||
alertOnAbsent,
|
||||
absentFor,
|
||||
target,
|
||||
op,
|
||||
matchType,
|
||||
}: LogsAlertSeed,
|
||||
): Promise<string> {
|
||||
const extraCondition =
|
||||
alertOnAbsent === undefined
|
||||
? undefined
|
||||
: { alertOnAbsent, absentFor: absentFor ?? 1 };
|
||||
const args = {
|
||||
name,
|
||||
alertType: 'LOGS_BASED_ALERT',
|
||||
compositeQuery: logsCompositeQuery(marker),
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
target,
|
||||
op,
|
||||
matchType,
|
||||
};
|
||||
return postRule(
|
||||
page,
|
||||
schema,
|
||||
schema === 'v1' ? v1RulePayload(args) : v2RulePayload(args),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEED-H's rule: traces-based over the seeded spans, grouped by `service.name`.
|
||||
* Its history rows carry `relatedTracesLink` and an empty `relatedLogsLink`, so
|
||||
* the popover offers "View Traces" only.
|
||||
*/
|
||||
export async function createTracesAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
}: TracesAlertSeed,
|
||||
): Promise<string> {
|
||||
return postRule(
|
||||
page,
|
||||
'v2',
|
||||
v2RulePayload({
|
||||
name,
|
||||
alertType: 'TRACES_BASED_ALERT',
|
||||
compositeQuery: tracesCompositeQuery(marker),
|
||||
channels,
|
||||
severity: 'critical',
|
||||
evalWindow,
|
||||
frequency,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** SEED-E's rule: metrics-based, so its history rows carry no related links. */
|
||||
export async function createMetricAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
metricName,
|
||||
channels,
|
||||
groupByKey = 'host',
|
||||
evalWindow = '5m0s',
|
||||
frequency = '15s',
|
||||
}: MetricAlertSeed,
|
||||
): Promise<string> {
|
||||
return postRule(
|
||||
page,
|
||||
'v2',
|
||||
v2RulePayload({
|
||||
name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
compositeQuery: metricsCompositeQuery(metricName, groupByKey),
|
||||
channels,
|
||||
severity: 'critical',
|
||||
evalWindow,
|
||||
frequency,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEED-G's rule: a logs rule whose filter matches nothing, with
|
||||
* `alertOnAbsent` set — the only cheap way to get a `nodata` history row.
|
||||
* Seed no telemetry for its marker.
|
||||
*/
|
||||
export async function createNoDataAlertViaApi(
|
||||
page: Page,
|
||||
{
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
}: { name: string; marker: string; channels: string[] },
|
||||
): Promise<string> {
|
||||
return createLogsAlertViaApi(page, {
|
||||
name,
|
||||
marker,
|
||||
channels,
|
||||
evalWindow: '5m0s',
|
||||
frequency: '15s',
|
||||
alertOnAbsent: true,
|
||||
absentFor: 1,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze a rule's history. Rows are written on *state change* only, so the
|
||||
* firing wave lands once — but once the eval window rolls past the seeded
|
||||
* records the rule resolves and writes a second row per fingerprint, doubling
|
||||
* `total` mid-suite. Disable the rule as soon as the firing wave is confirmed.
|
||||
*/
|
||||
export async function setRuleDisabledViaApi(
|
||||
page: Page,
|
||||
id: string,
|
||||
disabled: boolean,
|
||||
): Promise<void> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.patch(`/api/v2/rules/${id}`, {
|
||||
data: { disabled },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PATCH /api/v2/rules/${id} ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
45
tests/e2e/helpers/alerts/constants.ts
Normal file
45
tests/e2e/helpers/alerts/constants.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_LIST_PATH = '/alerts';
|
||||
export const ALERT_OVERVIEW_PATH = '/alerts/overview';
|
||||
export const ALERT_HISTORY_PATH = '/alerts/history';
|
||||
|
||||
/**
|
||||
* Mirrors `TIMELINE_TABLE_PAGE_SIZE` in
|
||||
* `frontend/src/container/AlertHistory/constants.ts`. This 20 is what makes the
|
||||
* page-2 cursor `base64url({"offset":20,"limit":20})`, so the two must not drift.
|
||||
*/
|
||||
export const TIMELINE_PAGE_SIZE = 20;
|
||||
|
||||
/** The `relativeTime` the history page falls back to (`DEFAULT_TIME_RANGE`). */
|
||||
export const DEFAULT_RELATIVE_TIME = '30m';
|
||||
|
||||
/**
|
||||
* Page size the list specs pin in the URL, so the number of rendered rows never
|
||||
* depends on the viewport height.
|
||||
*/
|
||||
export const ALERT_LIST_PAGE_SIZE = 10;
|
||||
|
||||
/** Severities the list seed cycles through, so search/sort tests have more than one value. */
|
||||
export const SEED_B_SEVERITIES = ['critical', 'warning', 'info'] as const;
|
||||
|
||||
// ─── Wait timeouts (ms) ──────────────────────────────────────────────────
|
||||
// These timeouts gate on the "ruler" — SigNoz's alert evaluation engine that
|
||||
// runs on ~15s cycles and writes history rows to ClickHouse. No way to force
|
||||
// evaluation or seed history directly, so we poll until rows appear.
|
||||
|
||||
/**
|
||||
* Default timeout for waitForTimelineEntries.
|
||||
*
|
||||
* Logs rules need 2+ ruler cycles (~15s each) to see 25 services fire.
|
||||
* 90s = 6 cycles worst-case. Actual time: 20-35s for logs, ~10s for metrics.
|
||||
*/
|
||||
export const WAIT_TIMELINE_ENTRIES_DEFAULT = 90_000;
|
||||
|
||||
/**
|
||||
* Default timeout for waitForTimelineStates (firing + resolved).
|
||||
*
|
||||
* Resolved state appears after evalWindow expires with no matching data.
|
||||
* 1m window + 2 ruler cycles = ~105s observed. 180s = safe margin.
|
||||
*/
|
||||
export const WAIT_TIMELINE_STATES_DEFAULT = 180_000;
|
||||
359
tests/e2e/helpers/alerts/history.ts
Normal file
359
tests/e2e/helpers/alerts/history.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Request,
|
||||
type Response,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { authToken, requestUrl } from '../common';
|
||||
import { typeExpression } from '../query-builder';
|
||||
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
WAIT_TIMELINE_ENTRIES_DEFAULT,
|
||||
WAIT_TIMELINE_STATES_DEFAULT,
|
||||
} from './constants';
|
||||
import type { TimelineItem, TimelineResponse } from './types';
|
||||
|
||||
// ─── History API probes ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the timeline straight from the API. Used to gate on the ruler having
|
||||
* produced rows *before* a spec opens the UI — polling through the browser
|
||||
* would conflate "no rows yet" with "the table failed to render".
|
||||
*/
|
||||
export async function fetchTimeline(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
params: Record<string, string | number> = {},
|
||||
): Promise<TimelineResponse> {
|
||||
const token = await authToken(page);
|
||||
const now = Date.now();
|
||||
const query = new URLSearchParams({
|
||||
start: String(now - 30 * 60 * 1000),
|
||||
end: String(now),
|
||||
limit: '100',
|
||||
order: 'asc',
|
||||
...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])),
|
||||
});
|
||||
const res = await page.request.get(
|
||||
`/api/v2/rules/${ruleId}/history/timeline?${query.toString()}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`GET /api/v2/rules/${ruleId}/history/timeline ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json()) as { data: TimelineResponse | null };
|
||||
return {
|
||||
items: json.data?.items ?? [],
|
||||
total: json.data?.total ?? 0,
|
||||
nextCursor: json.data?.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
function countStates(items: TimelineItem[]): Record<string, number> {
|
||||
return items.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.state] = (acc[item.state] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until at least `min` rows in state `state` exist. Takes ~20-35s for the
|
||||
* logs fixture (the rule fires on the first evaluation that sees the data) and
|
||||
* ~10s for the metrics one, so budget generously — a timeout here means the
|
||||
* marker aged out of the eval window, not that the assertion is wrong.
|
||||
*/
|
||||
export async function waitForTimelineEntries(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
{
|
||||
min,
|
||||
state = 'firing',
|
||||
timeoutMs = WAIT_TIMELINE_ENTRIES_DEFAULT,
|
||||
}: { min: number; state?: string; timeoutMs?: number },
|
||||
): Promise<TimelineResponse> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: TimelineResponse = { items: [], total: 0 };
|
||||
while (Date.now() < deadline) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
last = await fetchTimeline(page, ruleId);
|
||||
if (last.items.filter((item) => item.state === state).length >= min) {
|
||||
return last;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2_000);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`timeline for rule ${ruleId} never reached ${min} '${state}' rows within ${timeoutMs}ms ` +
|
||||
`(last: total=${last.total}, states=${JSON.stringify(countStates(last.items))})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until every requested state has at least the requested row count.
|
||||
* SEED-F's firing→resolved wave and SEED-G's `nodata` row both gate on this.
|
||||
*/
|
||||
export async function waitForTimelineStates(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
{
|
||||
states,
|
||||
timeoutMs = WAIT_TIMELINE_STATES_DEFAULT,
|
||||
}: { states: Record<string, number>; timeoutMs?: number },
|
||||
): Promise<TimelineResponse> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: TimelineResponse = { items: [], total: 0 };
|
||||
while (Date.now() < deadline) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
last = await fetchTimeline(page, ruleId);
|
||||
const seen = countStates(last.items);
|
||||
if (
|
||||
Object.entries(states).every(([state, min]) => (seen[state] ?? 0) >= min)
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 3_000);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`timeline for rule ${ruleId} never reached ${JSON.stringify(states)} within ${timeoutMs}ms ` +
|
||||
`(last states: ${JSON.stringify(countStates(last.items))})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The filtered row count the timeline reports. Ignores `limit`. */
|
||||
export async function readTimelineTotal(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<number> {
|
||||
return (await fetchTimeline(page, ruleId, { limit: 1 })).total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of `encodeCursor` in
|
||||
* `container/AlertHistory/Timeline/Table/useTimelineTableCursor.ts`, so specs
|
||||
* can assert the *exact* cursor the UI sends. Verified byte-identical to the
|
||||
* server's `nextCursor`.
|
||||
*/
|
||||
export function encodeTimelineCursor(
|
||||
page_: number,
|
||||
limit = TIMELINE_PAGE_SIZE,
|
||||
): string | undefined {
|
||||
if (page_ <= 1) {
|
||||
return undefined;
|
||||
}
|
||||
const offset = (page_ - 1) * limit;
|
||||
return Buffer.from(JSON.stringify({ offset, limit }))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Labels on a timeline row, flattened to a plain object. */
|
||||
export function timelineLabelsToObject(
|
||||
item: TimelineItem,
|
||||
): Record<string, string> {
|
||||
return (item.labels ?? []).reduce<Record<string, string>>((acc, label) => {
|
||||
const name = label.key?.name;
|
||||
if (name) {
|
||||
acc[name] = String(label.value ?? '');
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the history tab for `ruleId` and wait until the timeline table has
|
||||
* mounted. `params` is merged into the query string, so scenarios can deep-link
|
||||
* `page`, `order`, `timelineFilter`, … in one call.
|
||||
*/
|
||||
export async function gotoAlertHistory(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<void> {
|
||||
// An absolute window and `relativeTime` are mutually exclusive in practice:
|
||||
// with both present the time picker normalises back to the relative range and
|
||||
// **drops** `startTime`/`endTime` from the URL, so the absolute window never
|
||||
// takes effect. Only send the default relative range when no absolute one was
|
||||
// asked for.
|
||||
const hasAbsoluteRange = !!params.startTime && !!params.endTime;
|
||||
const query = new URLSearchParams({
|
||||
ruleId,
|
||||
...(hasAbsoluteRange ? {} : { relativeTime: DEFAULT_RELATIVE_TIME }),
|
||||
...params,
|
||||
});
|
||||
await page.goto(`${ALERT_HISTORY_PATH}?${query.toString()}`);
|
||||
|
||||
// Race the table against the app's error boundary. The history page has been
|
||||
// observed crashing into it intermittently on load; without this the failure
|
||||
// reads as a 15s "timeline-table not found", which says nothing about why.
|
||||
const table = page.getByTestId('timeline-table');
|
||||
const crashed = page.getByText('Something went wrong :/');
|
||||
await expect(table.or(crashed)).toBeVisible();
|
||||
if (await crashed.isVisible()) {
|
||||
throw new Error(
|
||||
`alert history crashed into the app error boundary at ${page.url()} — ` +
|
||||
'a component threw during render; check the captured console output',
|
||||
);
|
||||
}
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
// The `timeline-table` node is rendered by the first paint, *before* the
|
||||
// timeline request settles — antd only overlays a spinner on it. Returning
|
||||
// here would leave that request in flight, and the next
|
||||
// `waitForHistoryResponse` in the spec would resolve with the page's own
|
||||
// load instead of the response its interaction produced. Wait the spinner
|
||||
// out so every caller starts from a quiet page.
|
||||
await expect(page.locator('.timeline-table .ant-spin-spinning')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Locators ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assert the table is back on page 1. Both the list and the timeline use nuqs
|
||||
* with `parseAsInteger.withDefault(1)`, which **removes** the `page` param when
|
||||
* it is reset rather than writing `page=1` — so "absent" and "1" are the same
|
||||
* state and a naive `?page=1` regex never matches.
|
||||
*/
|
||||
export async function expectFirstPage(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get('page') ?? '1')
|
||||
.toBe('1');
|
||||
}
|
||||
|
||||
export function timelineRows(page: Page): Locator {
|
||||
return page.getByTestId('timeline-row');
|
||||
}
|
||||
|
||||
export function timelineFooterRange(page: Page): Locator {
|
||||
return page.getByTestId('timeline-footer-range');
|
||||
}
|
||||
|
||||
export function statsCard(page: Page, title: string): Locator {
|
||||
return page.locator(`[data-testid="stats-card"][data-stats-title="${title}"]`);
|
||||
}
|
||||
|
||||
/** Open the ACTIONS popover on timeline row `index` (0-based). */
|
||||
export async function openTimelineRowActions(
|
||||
page: Page,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
await timelineRows(page)
|
||||
.nth(index)
|
||||
.getByTestId('timeline-row-actions')
|
||||
.click();
|
||||
}
|
||||
|
||||
// ─── History request matchers ──────────────────────────────────────────────
|
||||
|
||||
/** The four v2 endpoints one history page load hits. */
|
||||
export const HISTORY_ENDPOINTS = [
|
||||
'stats',
|
||||
'timeline',
|
||||
'top_contributors',
|
||||
'overall_status',
|
||||
] as const;
|
||||
|
||||
export type HistoryEndpoint = (typeof HISTORY_ENDPOINTS)[number];
|
||||
|
||||
/** Match a request against one history endpoint, whatever the rule id. */
|
||||
export function isHistoryRequest(
|
||||
request: Request,
|
||||
endpoint: HistoryEndpoint,
|
||||
): boolean {
|
||||
return new RegExp(`/api/v2/rules/[^/]+/history/${endpoint}`).test(
|
||||
request.url(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a history API response. Common pattern across history specs.
|
||||
*
|
||||
* Optionally narrow by HTTP status code or by the `filterExpression` the
|
||||
* request carried. The latter matters whenever a scenario reacts to *its own*
|
||||
* request: the page's own load is still in flight when the spec starts typing,
|
||||
* so an unqualified matcher happily resolves with that earlier response.
|
||||
*/
|
||||
export function waitForHistoryResponse(
|
||||
page: Page,
|
||||
endpoint: HistoryEndpoint,
|
||||
options?: { status?: number; filterExpression?: string },
|
||||
): Promise<Response> {
|
||||
return page.waitForResponse((res) => {
|
||||
if (!isHistoryRequest(res.request(), endpoint)) return false;
|
||||
if (options?.status !== undefined && res.status() !== options.status)
|
||||
return false;
|
||||
if (
|
||||
options?.filterExpression !== undefined &&
|
||||
(requestUrl(res.request()).searchParams.get('filterExpression') ?? '') !==
|
||||
options.filterExpression
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── History interactions ──────────────────────────────────────────────────
|
||||
|
||||
/** Apply a filter expression through the real editor + Run button. */
|
||||
export async function runFilterExpression(
|
||||
page: Page,
|
||||
expression: string,
|
||||
): Promise<void> {
|
||||
await typeExpression(page, expression);
|
||||
await page.getByRole('button', { name: /run query/i }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the timeline descending through the STATE header.
|
||||
*
|
||||
* The antd table is *uncontrolled* — it has `sorter: true` but no `sortOrder`,
|
||||
* so its internal cycle is none → ascend → descend regardless of the `order`
|
||||
* the hook already sends. Reaching `desc` therefore takes two clicks, and the
|
||||
* first one only resets the page (asc is nuqs's default, so it writes no param).
|
||||
*/
|
||||
export async function sortTimelineDescending(page: Page): Promise<void> {
|
||||
const header = page.getByRole('columnheader', { name: 'STATE' });
|
||||
const descRequest = page.waitForRequest(
|
||||
(req) =>
|
||||
isHistoryRequest(req, 'timeline') &&
|
||||
requestUrl(req).searchParams.get('order') === 'desc',
|
||||
);
|
||||
await header.click();
|
||||
await header.click();
|
||||
await descRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the LABELS cell of every rendered row. Scenarios that compare two
|
||||
* snapshots taken at different times (page 1 vs page 2, one timezone vs
|
||||
* another) cannot express that as a web-first assertion, so the read lives in a
|
||||
* helper rather than inline in the test.
|
||||
*/
|
||||
export async function timelineRowLabels(page: Page): Promise<string[]> {
|
||||
return timelineRows(page).getByTestId('timeline-row-labels').allInnerTexts();
|
||||
}
|
||||
|
||||
/** Snapshot the first row's CREATED AT cell. See {@link timelineRowLabels}. */
|
||||
export async function firstTimelineRowCreatedAt(page: Page): Promise<string> {
|
||||
return timelineRows(page)
|
||||
.first()
|
||||
.getByTestId('timeline-row-created-at')
|
||||
.innerText();
|
||||
}
|
||||
77
tests/e2e/helpers/alerts/navigation.ts
Normal file
77
tests/e2e/helpers/alerts/navigation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
ALERT_LIST_PAGE_SIZE,
|
||||
ALERT_OVERVIEW_PATH,
|
||||
ALERTS_LIST_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
} from './constants';
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the alert overview (edit) page for `ruleId` and wait until it has fully
|
||||
* settled: the condition editor is visible and the query builder has finished
|
||||
* serializing the loaded query into the URL.
|
||||
*/
|
||||
export async function gotoAlertOverview(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}`);
|
||||
// `.first()` because a rule may have several thresholds, and the editor renders
|
||||
// one input per threshold. Without it this is a strict-mode violation that only
|
||||
// appears once the *second* row has rendered — i.e. a timing-dependent failure
|
||||
// for multi-threshold rules.
|
||||
await expect(page.getByTestId('threshold-value-input').first()).toBeVisible();
|
||||
// The builder rewrites location.search shortly after load (adds compositeQuery).
|
||||
await page.waitForURL(/compositeQuery=/, { timeout: 15_000 });
|
||||
// Let post-load state updates flush so callers read the settled value.
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout -- no DOM signal for the async settle
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the alert details shell (Overview tab) for `ruleId` and wait until it has
|
||||
* mounted. Unlike {@link gotoAlertOverview} this does **not** wait for the
|
||||
* condition editor or the serialised query — use it for scenarios about the
|
||||
* shell itself (header, tabs, actions menu) rather than the rule's contents.
|
||||
*/
|
||||
export async function gotoAlertDetails(
|
||||
page: Page,
|
||||
ruleId: string,
|
||||
): Promise<void> {
|
||||
await page.goto(
|
||||
`${ALERT_OVERVIEW_PATH}?ruleId=${ruleId}&relativeTime=${DEFAULT_RELATIVE_TIME}`,
|
||||
);
|
||||
await expect(page.getByTestId('alert-details-root')).toBeVisible();
|
||||
}
|
||||
|
||||
/** Rows currently rendered in the alert-rules table body. */
|
||||
export function alertRuleRows(page: Page): Locator {
|
||||
return page.locator('tbody tr');
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the alert-rules list and wait until it has rows. `params` is merged into
|
||||
* the query string (`search`, `page`, `orderBy`, …); `limit` defaults to
|
||||
* {@link ALERT_LIST_PAGE_SIZE} so row counts are viewport-independent.
|
||||
*
|
||||
* Pass `expectRows: false` for scenarios whose filters are *meant* to match
|
||||
* nothing — the row wait would otherwise fail before the assertion runs.
|
||||
*/
|
||||
export async function gotoAlertList(
|
||||
page: Page,
|
||||
params: Record<string, string> = {},
|
||||
{ expectRows = true }: { expectRows?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const query = new URLSearchParams({
|
||||
limit: String(ALERT_LIST_PAGE_SIZE),
|
||||
...params,
|
||||
});
|
||||
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
|
||||
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
|
||||
if (expectRows) {
|
||||
await expect(alertRuleRows(page).first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
324
tests/e2e/helpers/alerts/payloads.ts
Normal file
324
tests/e2e/helpers/alerts/payloads.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
import type { ThresholdAlertSeed, ThresholdSeedSpec } from './types';
|
||||
|
||||
// ─── Payload builders ────────────────────────────────────────────────────
|
||||
|
||||
const ANNOTATIONS = {
|
||||
description:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
summary:
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})',
|
||||
};
|
||||
|
||||
// A minimal but valid v2 (schemaVersion v2alpha1 / version v5) threshold rule
|
||||
// on the always-present `signoz_calls_total` metric. Mirrors the shape the
|
||||
// CreateAlertV2 UI posts to POST /api/v2/rules.
|
||||
export function buildThresholdRulePayload({
|
||||
name,
|
||||
target,
|
||||
channels,
|
||||
labels,
|
||||
thresholds,
|
||||
evalWindow = '5m0s',
|
||||
frequency = '1m',
|
||||
groupBy = [],
|
||||
queryGroupBy = [],
|
||||
renotify = { enabled: false, interval: '30m', alertStates: [] },
|
||||
alertOnAbsent,
|
||||
recoveryTarget = null,
|
||||
}: ThresholdAlertSeed): Record<string, unknown> {
|
||||
const thresholdSpec = (
|
||||
thresholds ?? [{ name: 'critical', target, channels, recoveryTarget }]
|
||||
).map((spec: ThresholdSeedSpec) => ({
|
||||
name: spec.name,
|
||||
target: spec.target,
|
||||
targetUnit: spec.targetUnit ?? '',
|
||||
recoveryTarget: spec.recoveryTarget ?? null,
|
||||
matchType: spec.matchType ?? 'at_least_once',
|
||||
op: spec.op ?? 'above',
|
||||
channels: spec.channels,
|
||||
}));
|
||||
|
||||
return {
|
||||
alert: name,
|
||||
alertType: 'METRIC_BASED_ALERT',
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
...(labels ? { labels } : {}),
|
||||
annotations: ANNOTATIONS,
|
||||
evaluation: {
|
||||
kind: 'rolling',
|
||||
spec: { evalWindow, frequency },
|
||||
},
|
||||
notificationSettings: {
|
||||
groupBy,
|
||||
renotify,
|
||||
usePolicy: false,
|
||||
},
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
...(alertOnAbsent
|
||||
? { alertOnAbsent: true, absentFor: alertOnAbsent.absentFor }
|
||||
: {}),
|
||||
compositeQuery: {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
aggregations: [
|
||||
{
|
||||
metricName: 'signoz_calls_total',
|
||||
temporality: '',
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
disabled: false,
|
||||
filter: { expression: '' },
|
||||
...(queryGroupBy.length > 0
|
||||
? {
|
||||
groupBy: queryGroupBy.map((key) => ({
|
||||
name: key,
|
||||
fieldContext: 'attribute',
|
||||
fieldDataType: 'string',
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: thresholdSpec,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The v5 `queries[]` envelope is identical for both schema versions
|
||||
// (`AlertCompositeQuery` in pkg/types/ruletypes/alerting.go) — only the
|
||||
// threshold / evaluation / channel envelopes differ. That keeps one builder
|
||||
// per signal and a thin branch over the wrapper.
|
||||
export function logsCompositeQuery(marker: string): Record<string, unknown> {
|
||||
return {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'logs',
|
||||
source: '',
|
||||
disabled: false,
|
||||
filter: { expression: `body CONTAINS '${marker}'` },
|
||||
groupBy: [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Same shape as the logs query, one signal over: the rule's signal is what
|
||||
// decides which related link the history rows carry (`links()` in
|
||||
// `pkg/modules/rulestatehistory/implrulestatehistory/links.go` returns *either*
|
||||
// a logs link *or* a traces link, never both).
|
||||
export function tracesCompositeQuery(marker: string): Record<string, unknown> {
|
||||
return {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
source: '',
|
||||
disabled: false,
|
||||
filter: { expression: `name = '${marker}'` },
|
||||
groupBy: [
|
||||
{
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
],
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function metricsCompositeQuery(
|
||||
metricName: string,
|
||||
groupByKey: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
panelType: 'graph',
|
||||
queryType: 'builder',
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'metrics',
|
||||
source: '',
|
||||
disabled: false,
|
||||
filter: { expression: '' },
|
||||
groupBy: [
|
||||
{ name: groupByKey, fieldContext: 'attribute', fieldDataType: 'string' },
|
||||
],
|
||||
aggregations: [
|
||||
{
|
||||
metricName,
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'max',
|
||||
},
|
||||
],
|
||||
having: { expression: '' },
|
||||
legend: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// `target 0 / op above / matchType at_least_once` fires on the first evaluation
|
||||
// that sees any matching record, which is what keeps the ruler wait to ~20-35s.
|
||||
export function v2RulePayload({
|
||||
name,
|
||||
alertType,
|
||||
compositeQuery,
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
}: {
|
||||
name: string;
|
||||
alertType: string;
|
||||
compositeQuery: Record<string, unknown>;
|
||||
channels: string[];
|
||||
severity: string;
|
||||
extraLabels?: Record<string, string>;
|
||||
evalWindow: string;
|
||||
frequency: string;
|
||||
extraCondition?: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType,
|
||||
ruleType: 'threshold_rule',
|
||||
schemaVersion: 'v2alpha1',
|
||||
version: 'v5',
|
||||
disabled: false,
|
||||
source: '',
|
||||
labels: { severity, ...extraLabels },
|
||||
annotations: ANNOTATIONS,
|
||||
evaluation: { kind: 'rolling', spec: { evalWindow, frequency } },
|
||||
notificationSettings: {
|
||||
groupBy: [],
|
||||
renotify: { enabled: false, interval: '30m', alertStates: [] },
|
||||
usePolicy: false,
|
||||
},
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
compositeQuery,
|
||||
thresholds: {
|
||||
kind: 'basic',
|
||||
spec: [
|
||||
{
|
||||
name: severity,
|
||||
target: 0,
|
||||
targetUnit: '',
|
||||
recoveryTarget: null,
|
||||
matchType: 'at_least_once',
|
||||
op: 'above',
|
||||
channels,
|
||||
},
|
||||
],
|
||||
},
|
||||
...extraCondition,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy schema: `evalWindow`/`frequency` sit at the top level, channels are
|
||||
// `preferredChannels`, and `condition.{op,target,matchType}` are the numeric
|
||||
// enum forms the v1 validator requires. `labels.severity` becomes the history
|
||||
// `threshold.name`.
|
||||
export function v1RulePayload({
|
||||
name,
|
||||
alertType,
|
||||
compositeQuery,
|
||||
channels,
|
||||
severity,
|
||||
extraLabels,
|
||||
evalWindow,
|
||||
frequency,
|
||||
extraCondition,
|
||||
target = 0,
|
||||
op = '1',
|
||||
matchType = '1',
|
||||
}: {
|
||||
name: string;
|
||||
alertType: string;
|
||||
compositeQuery: Record<string, unknown>;
|
||||
channels: string[];
|
||||
severity: string;
|
||||
extraLabels?: Record<string, string>;
|
||||
evalWindow: string;
|
||||
frequency: string;
|
||||
extraCondition?: Record<string, unknown>;
|
||||
target?: number;
|
||||
op?: string;
|
||||
matchType?: string;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
alert: name,
|
||||
alertType,
|
||||
ruleType: 'threshold_rule',
|
||||
disabled: false,
|
||||
source: '',
|
||||
evalWindow,
|
||||
frequency,
|
||||
preferredChannels: channels,
|
||||
labels: { severity, ...extraLabels },
|
||||
annotations: ANNOTATIONS,
|
||||
condition: {
|
||||
selectedQueryName: 'A',
|
||||
// Defaults match the history seeds' original shape — `target 0 / op above /
|
||||
// matchType at_least_once` fires on the first evaluation that sees data — so
|
||||
// overriding them is opt-in and cannot change what those seeds do.
|
||||
op,
|
||||
target,
|
||||
matchType,
|
||||
compositeQuery,
|
||||
...extraCondition,
|
||||
},
|
||||
};
|
||||
}
|
||||
207
tests/e2e/helpers/alerts/seeding.ts
Normal file
207
tests/e2e/helpers/alerts/seeding.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { seederUrl } from '../common';
|
||||
|
||||
import { createThresholdAlertViaApi } from './api';
|
||||
import { SEED_B_SEVERITIES } from './constants';
|
||||
import type {
|
||||
AlertRulesSeedOptions,
|
||||
LogsSeedOptions,
|
||||
MetricsSeedOptions,
|
||||
TracesSeedOptions,
|
||||
} from './types';
|
||||
|
||||
// ─── 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 spans the traces history rule matches on — one root span per
|
||||
* `service.name`, all sharing the span `name` marker. Returns the generated
|
||||
* service names (⇒ one timeline row each), same contract as
|
||||
* {@link seedAlertHistoryLogs}, and the same "seed immediately before creating
|
||||
* the rule" rule applies.
|
||||
*/
|
||||
export async function seedAlertHistoryTraces(
|
||||
page: Page,
|
||||
{
|
||||
marker,
|
||||
services,
|
||||
spansPerService = 2,
|
||||
ageSeconds = 150,
|
||||
minAgeSeconds = 30,
|
||||
servicePrefix = 'e2e-aht-svc',
|
||||
}: TracesSeedOptions,
|
||||
): Promise<string[]> {
|
||||
const now = Date.now();
|
||||
const span = Math.max(ageSeconds - minAgeSeconds, 1);
|
||||
const serviceNames: string[] = [];
|
||||
const spans: Record<string, unknown>[] = [];
|
||||
|
||||
for (let i = 0; i < services; i += 1) {
|
||||
const service = `${servicePrefix}-${i}`;
|
||||
serviceNames.push(service);
|
||||
for (let s = 0; s < spansPerService; s += 1) {
|
||||
const fraction = (i * spansPerService + s) / (services * spansPerService);
|
||||
const offset = ageSeconds - Math.floor(fraction * span);
|
||||
spans.push({
|
||||
timestamp: new Date(now - offset * 1000).toISOString(),
|
||||
trace_id: randomBytes(16).toString('hex'),
|
||||
span_id: randomBytes(8).toString('hex'),
|
||||
name: marker,
|
||||
kind: 2,
|
||||
duration: 'PT0.05S',
|
||||
resources: { 'service.name': service },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await postToSeeder(page, '/telemetry/traces', spans);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
191
tests/e2e/helpers/alerts/types.ts
Normal file
191
tests/e2e/helpers/alerts/types.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ThresholdAlertSeed {
|
||||
/** Alert rule name. Keep unique per test to avoid collisions. */
|
||||
name: string;
|
||||
/** The critical-threshold target value to persist and later assert. */
|
||||
target: number;
|
||||
/**
|
||||
* Notification channel names for the critical threshold. At least one is
|
||||
* required by the API — seed one with {@link createEmailChannelViaApi}.
|
||||
*/
|
||||
channels: string[];
|
||||
/**
|
||||
* 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>;
|
||||
|
||||
// ── SEED-RV2 extras ─────────────────────────────────────────────────────
|
||||
// Everything below exists so an *edit* spec can prove the form prefilled from
|
||||
// the rule rather than from its own defaults. A prefill assertion against a
|
||||
// value that equals `INITIAL_CREATE_ALERT_STATE` proves nothing, so each of
|
||||
// these deliberately differs from the corresponding UI default.
|
||||
|
||||
/**
|
||||
* Replaces the single `critical` threshold. Use two or more to exercise the
|
||||
* multi-threshold prefill — and note the UI only reads `op`/`matchType` back
|
||||
* from `spec[0]`, so entries after the first should keep them identical unless
|
||||
* the test is *about* that defect.
|
||||
*/
|
||||
thresholds?: ThresholdSeedSpec[];
|
||||
/** Go duration; UI default is `5m0s`, so pass something else. */
|
||||
evalWindow?: string;
|
||||
/** Go duration; UI default is `1m`, so pass something else. */
|
||||
frequency?: string;
|
||||
/**
|
||||
* `notificationSettings.groupBy`. The UI's group-by select only offers keys
|
||||
* that the *query* groups by (`MultipleNotifications.tsx:20-48`), so set
|
||||
* {@link ThresholdAlertSeed.queryGroupBy} to the same keys or the prefilled
|
||||
* value has no matching option.
|
||||
*/
|
||||
groupBy?: string[];
|
||||
/** Attribute keys the query groups by. Also what unlocks the group-by select. */
|
||||
queryGroupBy?: string[];
|
||||
/** `notificationSettings.renotify`. UI default is `{enabled: false}`. */
|
||||
renotify?: {
|
||||
enabled: boolean;
|
||||
/** Go duration; UI default is `30m`. */
|
||||
interval: string;
|
||||
alertStates: ('firing' | 'nodata')[];
|
||||
};
|
||||
/** `condition.alertOnAbsent` + `condition.absentFor` (minutes). */
|
||||
alertOnAbsent?: { absentFor: number };
|
||||
/** `condition.recoveryTarget` on the first threshold — the UI never renders it. */
|
||||
recoveryTarget?: number | null;
|
||||
}
|
||||
|
||||
export interface ThresholdSeedSpec {
|
||||
name: string;
|
||||
target: number;
|
||||
targetUnit?: string;
|
||||
matchType?: string;
|
||||
op?: string;
|
||||
channels: string[];
|
||||
recoveryTarget?: number | null;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
// ── SEED-RV1 extras ─────────────────────────────────────────────────────
|
||||
// v1 only. Same reasoning as SEED-RV2's block: an `EV1-*` prefill assertion
|
||||
// against the value the create form would have produced anyway proves nothing,
|
||||
// so each of these exists to differ from `alertDefaults`
|
||||
// (`container/CreateAlertRule/defaults.ts`).
|
||||
|
||||
/** `condition.target`. The v1 default is *absent*, so any number differs. */
|
||||
target?: number;
|
||||
/** `condition.op` as the legacy numeric string. `1` above, `2` below, … */
|
||||
op?: string;
|
||||
/** `condition.matchType`, same encoding. `1` at-least-once, `2` all-the-times. */
|
||||
matchType?: string;
|
||||
}
|
||||
|
||||
export interface TracesAlertSeed {
|
||||
name: string;
|
||||
/** Span name the rule matches on (`name = '<marker>'`). */
|
||||
marker: string;
|
||||
channels: string[];
|
||||
evalWindow?: string;
|
||||
frequency?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface TracesSeedOptions {
|
||||
/** Span `name` the rule matches on. */
|
||||
marker: string;
|
||||
/** Number of distinct `service.name` values ⇒ number of timeline rows. */
|
||||
services: number;
|
||||
spansPerService?: number;
|
||||
/** Oldest span age in seconds; spans spread from here up to `minAgeSeconds`. */
|
||||
ageSeconds?: number;
|
||||
minAgeSeconds?: number;
|
||||
servicePrefix?: 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,34 +1,123 @@
|
||||
import type { Browser, BrowserContext } from '@playwright/test';
|
||||
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!,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export async function newAdminContext(
|
||||
browser: Browser,
|
||||
): Promise<BrowserContext> {
|
||||
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;
|
||||
return browser.newContext({
|
||||
...contextDefaults,
|
||||
storageState: await storageStateFor(browser, ADMIN),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import type { Page, Request } from '@playwright/test';
|
||||
|
||||
// Shared helpers used across feature-specific helper modules (dashboards,
|
||||
// trace-details, …). Keep this to genuinely cross-feature utilities.
|
||||
@@ -18,6 +18,108 @@ 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',
|
||||
'ErrorResponseHandler: unclassified error',
|
||||
];
|
||||
|
||||
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,7 +5,11 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/setup.py::test_setup",
|
||||
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/setup.py::test_teardown",
|
||||
"env: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:staging": "SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// .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 });
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
@@ -33,8 +55,17 @@ export default defineConfig({
|
||||
// Retry on CI only
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Workers
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
// Workers. Playwright's local default is `cpus / 2`, which on a 32-core box is
|
||||
// 16 — and 16 is strictly worse than 6 here, because every worker's browser
|
||||
// shares one SigNoz container: measured on `tests/alerts/{create,edit}` at
|
||||
// `--repeat-each=3` (224 tests), 16 workers took 128 s with 3 failures while 6
|
||||
// took 119 s with none. Past ~6 the extra workers only add queueing, which shows
|
||||
// up as 4-6 s app mounts and save requests that outlive the test timeout — i.e.
|
||||
// as flakes that look like product bugs. Capped rather than fixed at 6 so a
|
||||
// 4-core laptop still gets `cpus / 2`.
|
||||
workers: process.env.CI
|
||||
? 2
|
||||
: Math.max(1, Math.min(6, Math.floor(os.cpus().length / 2))),
|
||||
|
||||
// The SPA hydrates slowly on CI, so the 5s expect default fires mid-load.
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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),
|
||||
);
|
||||
});
|
||||
});
|
||||
45
tests/e2e/tests/alerts/channels/edit.spec.ts
Normal file
45
tests/e2e/tests/alerts/channels/edit.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
deleteChannelViaApi,
|
||||
} from '../../../helpers/alerts/api';
|
||||
|
||||
test.describe('Notification channels — edit', () => {
|
||||
// Regression guard for engineering-pod#5509: after channels moved from
|
||||
// /settings/channels to /alerts/channels, the edit container still parsed the
|
||||
// channel id out of the old pathname, so every save PUT went to an empty id
|
||||
// and no edit ever persisted. Nothing in the suite navigated into the edit
|
||||
// page, so the whole class of "edits silently do nothing" was invisible.
|
||||
test('TC-01 an edited recipient persists after reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// The channel *name* is read-only on the edit page, so the editable field
|
||||
// this exercises is the email recipient.
|
||||
const name = `e2e-nc-${Date.now()}`;
|
||||
const updatedTo = 'e2e-updated@signoz.test';
|
||||
const { id } = await createEmailChannelViaApi(page, name);
|
||||
|
||||
try {
|
||||
await page.goto(`/alerts/channels/edit/${id}`);
|
||||
|
||||
const toBox = page.getByRole('textbox', { name: 'To' });
|
||||
await expect(toBox).toHaveValue('e2e@signoz.test');
|
||||
await toBox.fill(updatedTo);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('/api/v1/channels') && r.request().method() === 'PUT',
|
||||
),
|
||||
page.getByTestId('save-channel-button').click(),
|
||||
]);
|
||||
|
||||
await page.goto(`/alerts/channels/edit/${id}`);
|
||||
await expect(page.getByRole('textbox', { name: 'To' })).toHaveValue(
|
||||
updatedTo,
|
||||
);
|
||||
} finally {
|
||||
await deleteChannelViaApi(page, id);
|
||||
}
|
||||
});
|
||||
});
|
||||
139
tests/e2e/tests/alerts/create/edge.spec.ts
Normal file
139
tests/e2e/tests/alerts/create/edge.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { expect, test } from '../../../fixtures/alerts/alert-rules';
|
||||
import { AlertType } from '../../../helpers/alert-forms/constants';
|
||||
import {
|
||||
gotoCreateAlertV1,
|
||||
gotoCreateAlertV2,
|
||||
} from '../../../helpers/alert-forms/navigation';
|
||||
import { v1SaveButton } from '../../../helpers/alert-forms/v1';
|
||||
import {
|
||||
elementAtPointClassName,
|
||||
selectThresholdChannel,
|
||||
v2DiscardButton,
|
||||
v2SaveButton,
|
||||
} from '../../../helpers/alert-forms/v2';
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
deleteChannelViaApi,
|
||||
} from '../../../helpers/alerts/api';
|
||||
import {
|
||||
gotoAlertDetails,
|
||||
gotoAlertOverview,
|
||||
} from '../../../helpers/alerts/navigation';
|
||||
import { watchConsole } from '../../../helpers/common';
|
||||
|
||||
// TC-* — errors and edges that are not specific to one form.
|
||||
|
||||
test.describe('Alert create — errors and edges', () => {
|
||||
test('TC-01 a server-side rejection opens the error modal and keeps the draft', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// A duplicate rule name is *not* rejected — the API happily creates two rules
|
||||
// with the same `alert`. A missing channel is, with
|
||||
// `400 invalid_input: channels: the following channels do not exist`.
|
||||
//
|
||||
// So the 4xx comes from a real race rather than a stub: the form is filled with
|
||||
// a channel that exists, and the channel is deleted behind its back before the
|
||||
// save. Nothing about the response is faked.
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ce04-ch-${Date.now()}`,
|
||||
);
|
||||
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
const name = `e2e-ce04-${Date.now()}`;
|
||||
await page.getByTestId('alert-name-input').fill(name);
|
||||
await selectThresholdChannel(page, 0, channel.name);
|
||||
|
||||
await deleteChannelViaApi(page, channel.id);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
|
||||
),
|
||||
v2SaveButton(page).click(),
|
||||
]);
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
// Both forms funnel every save error into the shared error modal, which is
|
||||
// antd's wrapped in `.error-modal__wrap`.
|
||||
await expect(page.locator('.error-modal__wrap')).toBeVisible();
|
||||
await expect(page.getByText(/do not exist/)).toBeVisible();
|
||||
|
||||
// A rejected save must not navigate, and must not lose what the user typed.
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts/new');
|
||||
await page.getByTestId('close-button').click();
|
||||
await expect(page.locator('.error-modal__wrap')).toBeHidden();
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
|
||||
});
|
||||
|
||||
test('TC-02 none of the four builder mounts logs a console error', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const watch = watchConsole(page);
|
||||
|
||||
// v2 create.
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
// v1 create. Metrics-based on purpose: it is the only alert type whose classic
|
||||
// form renders the detection-method step and the PromQL tab, i.e. the most code.
|
||||
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
|
||||
|
||||
// v2 edit.
|
||||
const v2Rule = await ownedRules.threshold(`e2e-ce07-v2-${Date.now()}`);
|
||||
await gotoAlertOverview(page, v2Rule);
|
||||
|
||||
// v1 edit. `gotoAlertOverview` is wrong here — it waits for
|
||||
// `threshold-value-input`, which only the v2 builder renders — so the shell-level
|
||||
// wait is used and the classic form is asserted directly.
|
||||
const v1Rule = await ownedRules.logs({
|
||||
name: `e2e-ce07-v1-${Date.now()}`,
|
||||
schema: 'v1',
|
||||
});
|
||||
await gotoAlertDetails(page, v1Rule);
|
||||
await expect(v1SaveButton(page)).toBeVisible();
|
||||
|
||||
expect(watch.errors).toEqual([]);
|
||||
});
|
||||
|
||||
// TODO: enable once the covered-Discard bug is fixed, and revert
|
||||
// `v2ClickDiscard` (`helpers/alert-forms.ts`) to a plain `.click()` in the same
|
||||
// commit — CV2-22 and EV2-11 both go through it.
|
||||
//
|
||||
// 🐞 **A user cannot discard an alert draft.** The footer is
|
||||
// `position: fixed; left: 63px` — the *collapsed* nav rail width — while the side
|
||||
// navigation is 240px wide whenever expanded, which is the default (pinned for a
|
||||
// fresh admin) and also happens transiently on hover when unpinned. Discard is the
|
||||
// footer's left-most control, so the nav sits on top of it and wins the stacking
|
||||
// contest despite the footer's `z-index: 1000`.
|
||||
//
|
||||
// Observed live: `document.elementFromPoint` at the button's centre returns the
|
||||
// nav's `.nav-item-data`, and `page.click()` fails with
|
||||
// *"div.nav-item-data … intercepts pointer events"*. `{ force: true }` does not
|
||||
// help — it skips the actionability wait but still delivers a real mouse event at
|
||||
// those coordinates. Only `dispatchEvent('click')` gets through, which proves the
|
||||
// handler is fine and the defect is purely pointer delivery.
|
||||
//
|
||||
// Fix is one of: make the footer's `left` follow the nav's actual width, move
|
||||
// Discard to the right-hand group, or lift the footer out of the nav's stacking
|
||||
// context.
|
||||
test.skip('TC-03 the v2 Discard button is clickable', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
|
||||
// Nothing from the side navigation may sit over the button's centre.
|
||||
const box = await v2DiscardButton(page).boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
const covering = await elementAtPointClassName(
|
||||
page,
|
||||
box!.x + box!.width / 2,
|
||||
box!.y + box!.height / 2,
|
||||
);
|
||||
expect(covering).not.toMatch(/nav-item/);
|
||||
|
||||
// And the consequence that matters: a real click lands and leaves the form.
|
||||
await v2DiscardButton(page).click({ timeout: 3_000 });
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts');
|
||||
});
|
||||
});
|
||||
266
tests/e2e/tests/alerts/create/prefill.spec.ts
Normal file
266
tests/e2e/tests/alerts/create/prefill.spec.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/alerts/alert-rules';
|
||||
import {
|
||||
ALERTS_NEW_PATH,
|
||||
AlertType,
|
||||
type AlertTypeValue,
|
||||
RuleType,
|
||||
ThresholdMatchType,
|
||||
ThresholdOperator,
|
||||
} from '../../../helpers/alert-forms/constants';
|
||||
import { gotoCreateAlertV2 } from '../../../helpers/alert-forms/navigation';
|
||||
import {
|
||||
evaluationSettingsButton,
|
||||
thresholdRows,
|
||||
} from '../../../helpers/alert-forms/v2';
|
||||
import { gotoAlertOverview } from '../../../helpers/alerts/navigation';
|
||||
|
||||
// TC-* — deep-link prefill.
|
||||
//
|
||||
// The contract is producer-agnostic (`context/resolveUrlAlertPrefill.ts`), but the
|
||||
// three producers do **not** write the same params: dashboards
|
||||
// (`buildAlertUrl`) and the explorer only ever emit query/panel params, while
|
||||
// metering (`MultiIngestionSettings`) is the sole producer of `ruleName`,
|
||||
// `yAxisUnit` and `evaluationWindowPreset`. CD-04 and CD-05 therefore drive the
|
||||
// *metering* URL shape — aiming them at a dashboard URL would test a link nobody
|
||||
// generates.
|
||||
|
||||
/**
|
||||
* A `compositeQuery` param harvested from the app itself.
|
||||
*
|
||||
* Hand-writing the v5 envelope would be a second, drifting copy of the query
|
||||
* builder's serialiser — the thing these scenarios are *reading*, not testing. So
|
||||
* the builder is opened once, allowed to serialise its own default query into the
|
||||
* URL, and that exact value is reused as the deep link.
|
||||
*/
|
||||
async function harvestCompositeQuery(
|
||||
page: Page,
|
||||
alertType: AlertTypeValue,
|
||||
): Promise<string> {
|
||||
await gotoCreateAlertV2(page, { alertType });
|
||||
const value = new URL(page.url()).searchParams.get('compositeQuery');
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
'the builder did not serialise a compositeQuery into the URL',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** `Threshold` as `context/types.ts` declares it — the shape the URL param carries. */
|
||||
function urlThreshold(
|
||||
overrides: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: 'e2e-url-threshold',
|
||||
label: 'from-url',
|
||||
thresholdValue: 0,
|
||||
recoveryThresholdValue: null,
|
||||
unit: '',
|
||||
channels: [],
|
||||
color: '#e5484d',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function prefillUrl(params: Record<string, string>): string {
|
||||
return `${ALERTS_NEW_PATH}?${new URLSearchParams(params).toString()}`;
|
||||
}
|
||||
|
||||
test.describe('Alert create — deep-link prefill', () => {
|
||||
test('TC-01 a compositeQuery alone selects the alert type', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const compositeQuery = await harvestCompositeQuery(page, AlertType.LOGS);
|
||||
|
||||
// No `alertType` and no `ruleType` in this URL: both come from the query's data
|
||||
// source through `ALERT_TYPE_VS_SOURCE_MAPPING`. The presence of
|
||||
// `compositeQuery` is also what skips the type-selection page, so this one
|
||||
// param decides two things at once.
|
||||
await page.goto(prefillUrl({ compositeQuery }));
|
||||
|
||||
await expect(page.getByTestId('alert-name-input')).toBeVisible();
|
||||
|
||||
// Asserted on the *rendered* signal tab, not on the URL: the mapping only feeds
|
||||
// the memo that picks the form — it does **not** write `alertType` back into the
|
||||
// query string. So a spec waiting for `alertType=LOGS_BASED_ALERT` in the URL
|
||||
// waits forever.
|
||||
await expect(
|
||||
page.locator('.list-view-tab.active-tab', {
|
||||
has: page.getByTestId('logs-view'),
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
expect(new URL(page.url()).searchParams.get('alertType')).toBeNull();
|
||||
|
||||
// A stale `compositeQuery` silently bypasses card selection, so the cards must
|
||||
// not be on screen.
|
||||
await expect(page.locator('[data-testid^="alert-type-card-"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-02 thresholds prefill from JSON, and a malformed value falls back', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const base = {
|
||||
alertType: AlertType.LOGS,
|
||||
ruleType: RuleType.THRESHOLD,
|
||||
};
|
||||
|
||||
await page.goto(
|
||||
prefillUrl({
|
||||
...base,
|
||||
thresholds: JSON.stringify([
|
||||
urlThreshold({ label: 'page-me', thresholdValue: 42 }),
|
||||
urlThreshold({
|
||||
id: 'e2e-url-threshold-2',
|
||||
label: 'warn-me',
|
||||
thresholdValue: 7,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(thresholdRows(page)).toHaveCount(2);
|
||||
const names = page.getByTestId('threshold-name-input');
|
||||
await expect(names.nth(0)).toHaveValue('page-me');
|
||||
await expect(names.nth(1)).toHaveValue('warn-me');
|
||||
await expect(page.getByTestId('threshold-value-input').nth(0)).toHaveValue(
|
||||
'42',
|
||||
);
|
||||
|
||||
// A malformed value is swallowed by `parseThresholds` and the form falls back to
|
||||
// its own single `critical` row. That path also writes
|
||||
// `console.error('Error parsing thresholds from URL:', …)`, which is why this
|
||||
// scenario must never be paired with CE-07's clean-console assertion.
|
||||
await page.goto(prefillUrl({ ...base, thresholds: 'not-json-at-all' }));
|
||||
await expect(thresholdRows(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
|
||||
'critical',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-03 matchType and compareOp aliases normalise to the enum', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// `avg` and `<` are aliases the *backend* accepts (`normalizeMatchType` /
|
||||
// `normalizeOperator` mirror `pkg/types/ruletypes/{match,compare}.go`), not values
|
||||
// the UI ever writes — so a producer or a hand-edited link can carry them.
|
||||
await page.goto(
|
||||
prefillUrl({
|
||||
alertType: AlertType.LOGS,
|
||||
ruleType: RuleType.THRESHOLD,
|
||||
matchType: 'avg',
|
||||
compareOp: '<',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByTestId('alert-threshold-match-type-select'),
|
||||
).toContainText(ThresholdMatchType.ON_AVERAGE.label);
|
||||
await expect(
|
||||
page.getByTestId('alert-threshold-operator-select'),
|
||||
).toContainText(ThresholdOperator.BELOW.label);
|
||||
});
|
||||
|
||||
test('TC-04 ruleName and yAxisUnit apply once and never stomp an edit', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const compositeQuery = await harvestCompositeQuery(page, AlertType.METRICS);
|
||||
const ruleName =
|
||||
'[ingestion][logs] e2e key has exceeded daily ingestion limit';
|
||||
|
||||
// The metering URL shape, verbatim from `MultiIngestionSettings.tsx`.
|
||||
await page.goto(
|
||||
prefillUrl({
|
||||
compositeQuery,
|
||||
thresholds: JSON.stringify([
|
||||
urlThreshold({ label: 'critical', thresholdValue: 100, unit: 'bytes' }),
|
||||
]),
|
||||
ruleName,
|
||||
yAxisUnit: 'bytes',
|
||||
matchType: ThresholdMatchType.IN_TOTAL.value,
|
||||
evaluationWindowPreset: 'meter',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveValue(ruleName);
|
||||
// `yAxisUnit` is what makes the per-threshold unit select usable at all — with no
|
||||
// unit the control is permanently disabled (CV2-12).
|
||||
await expect(
|
||||
page.getByTestId('threshold-unit-select').first(),
|
||||
).not.toHaveClass(/ant-select-disabled/);
|
||||
|
||||
// Now the half the `ruleNameAppliedRef` / `yAxisUnitAppliedRef` guards exist for.
|
||||
// The prefill effect re-runs on *every* change to location.search, and the query
|
||||
// builder rewrites it constantly — without the refs, a hand-edited name would be
|
||||
// silently reverted to the URL's the next time that happened.
|
||||
const edited = 'e2e-cd-04-renamed-by-hand';
|
||||
await page.getByTestId('alert-name-input').fill(edited);
|
||||
|
||||
// Switching the signal tab is a real user action that rewrites the URL *and*
|
||||
// changes `alertType`, which is also in the effect's dependency list.
|
||||
await page.getByTestId('logs-view').click();
|
||||
await page.waitForURL(/alertType=LOGS_BASED_ALERT/);
|
||||
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveValue(edited);
|
||||
});
|
||||
|
||||
test('TC-05 evaluationWindowPreset=meter switches to the cumulative daily window', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const compositeQuery = await harvestCompositeQuery(page, AlertType.METRICS);
|
||||
|
||||
await page.goto(
|
||||
prefillUrl({
|
||||
compositeQuery,
|
||||
matchType: ThresholdMatchType.IN_TOTAL.value,
|
||||
evaluationWindowPreset: 'meter',
|
||||
}),
|
||||
);
|
||||
|
||||
// `SET_INITIAL_STATE_FOR_METER` is a *cumulative* window starting at midnight
|
||||
// UTC — not one of the rolling presets — so the trigger button's whole text
|
||||
// changes shape, type included.
|
||||
await expect(evaluationSettingsButton(page)).toContainText('Cumulative');
|
||||
await expect(evaluationSettingsButton(page)).toContainText(
|
||||
'Current day, starting from 00:00:00 (UTC)',
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-06 URL prefill is ignored in edit mode', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(`e2e-cd-06-${Date.now()}`, {
|
||||
target: 42,
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
// Append a prefill param to the *edit* URL, which is what a stale link or a copied
|
||||
// query string produces in practice.
|
||||
await page.goto(
|
||||
`${new URL(page.url()).pathname}?${new URLSearchParams({
|
||||
ruleId,
|
||||
thresholds: JSON.stringify([
|
||||
urlThreshold({ label: 'from-url', thresholdValue: 999 }),
|
||||
]),
|
||||
}).toString()}`,
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('threshold-value-input').first()).toBeVisible();
|
||||
|
||||
// The effect early-returns in edit mode. Without that return the `RESET` at the
|
||||
// top of the block would wipe the loaded rule's thresholds every time the query
|
||||
// builder rewrote location.search.
|
||||
await expect(thresholdRows(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
|
||||
'critical',
|
||||
);
|
||||
await expect(page.getByTestId('threshold-value-input')).toHaveValue('42');
|
||||
await expect(page.getByTestId('alert-name-input')).not.toHaveValue(
|
||||
'from-url',
|
||||
);
|
||||
});
|
||||
});
|
||||
170
tests/e2e/tests/alerts/create/shell.spec.ts
Normal file
170
tests/e2e/tests/alerts/create/shell.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { expect, test } from '../../../fixtures/alerts/alert-rules';
|
||||
import {
|
||||
AlertListTab,
|
||||
AlertType,
|
||||
RuleType,
|
||||
STOCK_ALERT_TYPE_CARDS,
|
||||
} from '../../../helpers/alert-forms/constants';
|
||||
import {
|
||||
alertTypeCard,
|
||||
createAlertUrl,
|
||||
expectAlertTypeCardSet,
|
||||
gotoAlertTypeSelection,
|
||||
gotoCreateAlertV1,
|
||||
gotoCreateAlertV2,
|
||||
hasAnomalyAlertTypeCard,
|
||||
} from '../../../helpers/alert-forms/navigation';
|
||||
import { v1SaveButton } from '../../../helpers/alert-forms/v1';
|
||||
|
||||
// TC-* — the create *shell*: type selection, how a card click writes the
|
||||
// URL, the breadcrumb, the surrounding alerts tab bar, and the two ways to reach
|
||||
// the classic form. Nothing here saves a rule, so no scenario needs a channel.
|
||||
|
||||
test.describe('Alert create — shell & type selection', () => {
|
||||
test('TC-01 bare /alerts/new lists exactly the expected alert-type cards', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoAlertTypeSelection(page);
|
||||
|
||||
await expect(page.getByText('Choose a type for the alert')).toBeVisible();
|
||||
|
||||
// The four stock signals are unconditional; anomaly is added only when
|
||||
// ANOMALY_DETECTION is active. `expectAlertTypeCardSet` pins the exact set *and
|
||||
// order* for whichever branch applies, so adding a sixth signal still fails.
|
||||
for (const type of STOCK_ALERT_TYPE_CARDS) {
|
||||
await expect(alertTypeCard(page, type)).toBeVisible();
|
||||
}
|
||||
await expectAlertTypeCardSet(page);
|
||||
});
|
||||
|
||||
test('TC-02 picking a card writes both params and mounts the v2 builder', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoAlertTypeSelection(page);
|
||||
await alertTypeCard(page, AlertType.METRICS).click();
|
||||
|
||||
await expect(page.getByTestId('alert-name-input')).toBeVisible();
|
||||
|
||||
const params = new URL(page.url()).searchParams;
|
||||
expect(params.get('ruleType')).toBe(RuleType.THRESHOLD);
|
||||
expect(params.get('alertType')).toBe(AlertType.METRICS);
|
||||
});
|
||||
|
||||
test('TC-03 the anomaly card rewrites the rule type, not the alert type', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoAlertTypeSelection(page);
|
||||
|
||||
test.skip(
|
||||
!(await hasAnomalyAlertTypeCard(page)),
|
||||
'ANOMALY_DETECTION feature flag is inactive on this stack (see CS-01)',
|
||||
);
|
||||
|
||||
await alertTypeCard(page, AlertType.ANOMALY).click();
|
||||
|
||||
const params = new URL(page.url()).searchParams;
|
||||
expect(params.get('ruleType')).toBe(RuleType.ANOMALY);
|
||||
// The card's own value is deliberately *not* written: `handleSelectType`
|
||||
// forces the metrics alert type for anomaly rules, and the rendered form
|
||||
// resolves back to anomaly from `ruleType` alone.
|
||||
expect(params.get('alertType')).toBe(AlertType.METRICS);
|
||||
});
|
||||
|
||||
test('TC-04 modifier-clicking a card opens the builder in a new tab', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoAlertTypeSelection(page);
|
||||
|
||||
const [newTab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
alertTypeCard(page, AlertType.METRICS).click({
|
||||
modifiers: ['ControlOrMeta'],
|
||||
}),
|
||||
]);
|
||||
|
||||
await newTab.waitForLoadState();
|
||||
const params = new URL(newTab.url()).searchParams;
|
||||
expect(params.get('ruleType')).toBe(RuleType.THRESHOLD);
|
||||
expect(params.get('alertType')).toBe(AlertType.METRICS);
|
||||
|
||||
// A modifier click that *also* navigates in place is the regression this half
|
||||
// guards.
|
||||
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
|
||||
|
||||
await newTab.close();
|
||||
});
|
||||
|
||||
test('TC-05 breadcrumb gains a third crumb after a type is picked', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoAlertTypeSelection(page);
|
||||
|
||||
const breadcrumb = page.locator('.ant-breadcrumb');
|
||||
await expect(breadcrumb.getByText('Alert Rules')).toBeVisible();
|
||||
await expect(breadcrumb.getByText('Select Alert Type')).toBeVisible();
|
||||
|
||||
await alertTypeCard(page, AlertType.METRICS).click();
|
||||
await expect(page.getByTestId('alert-name-input')).toBeVisible();
|
||||
|
||||
await expect(breadcrumb.getByText('Metric-Based Alert')).toBeVisible();
|
||||
|
||||
// The middle crumb is now navigable and goes back to bare /alerts/new.
|
||||
await breadcrumb.getByRole('button', { name: 'Select Alert Type' }).click();
|
||||
await expect(alertTypeCard(page, AlertType.METRICS)).toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get('alertType')).toBeNull();
|
||||
});
|
||||
|
||||
test('TC-06 create renders inside the Alert Rules tab and leaving drops subTab/search', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// `subTab` and `search` are seeded here precisely so their removal is
|
||||
// observable — `handleTabChange` deletes them while keeping everything else.
|
||||
await page.goto(
|
||||
createAlertUrl({
|
||||
alertType: AlertType.LOGS,
|
||||
params: { subTab: 'Alert Rules', search: 'stale' },
|
||||
}),
|
||||
);
|
||||
await expect(page.getByTestId('alert-name-input')).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('tab', { name: /Alert Rules/ })).toBeVisible();
|
||||
await page.getByRole('tab', { name: /Triggered Alerts/ }).click();
|
||||
|
||||
await page.waitForURL(/\/alerts\?/);
|
||||
const params = new URL(page.url()).searchParams;
|
||||
// The param carries the space-less enum value, not the tab's visible label.
|
||||
expect(params.get('tab')).toBe(AlertListTab.TRIGGERED_ALERTS);
|
||||
expect(params.get('subTab')).toBeNull();
|
||||
expect(params.get('search')).toBeNull();
|
||||
});
|
||||
|
||||
test('TC-07 showClassicCreateAlertsPage=true renders the v1 form instead', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
|
||||
|
||||
await expect(v1SaveButton(page)).toBeVisible();
|
||||
// The clearest v1/v2 discriminator: the v2 header input simply is not there.
|
||||
await expect(page.getByTestId('alert-name-input')).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-08 Switch to Classic Experience replaces history, so Back does not return to v2', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.METRICS });
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: 'Switch to Classic Experience' })
|
||||
.click();
|
||||
|
||||
await expect(v1SaveButton(page)).toBeVisible();
|
||||
expect(
|
||||
new URL(page.url()).searchParams.get('showClassicCreateAlertsPage'),
|
||||
).toBe('true');
|
||||
|
||||
// `safeNavigate(url, { replace: true })` — going back must skip the v2 entry
|
||||
// entirely rather than bouncing between the two experiences.
|
||||
await page.goBack();
|
||||
await expect(page.getByTestId('alert-name-input')).toBeHidden();
|
||||
});
|
||||
});
|
||||
568
tests/e2e/tests/alerts/create/v2.spec.ts
Normal file
568
tests/e2e/tests/alerts/create/v2.spec.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
import { expect, test } from '../../../fixtures/alerts/alert-rules';
|
||||
import {
|
||||
AlertType,
|
||||
ThresholdMatchType,
|
||||
ThresholdOperator,
|
||||
} from '../../../helpers/alert-forms/constants';
|
||||
import { gotoCreateAlertV2 } from '../../../helpers/alert-forms/navigation';
|
||||
import {
|
||||
dropdownOption,
|
||||
openDropdown,
|
||||
ownDropdown,
|
||||
stubNoChannels,
|
||||
} from '../../../helpers/alert-forms/shared';
|
||||
import {
|
||||
addAlertLabel,
|
||||
advancedOptionToggle,
|
||||
evaluationCadenceInput,
|
||||
expandAdvancedOptions,
|
||||
labelPill,
|
||||
selectEvaluationTimeframe,
|
||||
selectThresholdChannel,
|
||||
thresholdRows,
|
||||
v2ClickDiscard,
|
||||
v2DiscardButton,
|
||||
v2SaveButton,
|
||||
v2SaveTooltip,
|
||||
v2TestButton,
|
||||
} from '../../../helpers/alert-forms/v2';
|
||||
|
||||
// CV2-* — the v2 create builder.
|
||||
//
|
||||
// Every scenario uses a **logs**-based alert unless it says otherwise: its default
|
||||
// query is valid with no seeded metrics. CV2-12 (unit select) and CV2-16 (group-by
|
||||
// select) are about what that choice costs — both are gated on query state a
|
||||
// default logs query does not provide, and both assert the gate.
|
||||
|
||||
const VALIDATION = {
|
||||
name: 'Please enter an alert name',
|
||||
thresholdLabel: 'Please enter a label for each threshold',
|
||||
channels:
|
||||
'Please select at least one channel for each threshold or enable routing policies',
|
||||
} as const;
|
||||
|
||||
test.describe('Alert create — v2 builder', () => {
|
||||
test('CV2-01 initial state: one critical threshold, both actions gated', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
expect(alertChannel.name).toBeTruthy();
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveValue('');
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveAttribute(
|
||||
'placeholder',
|
||||
'Enter alert rule name',
|
||||
);
|
||||
|
||||
// `INITIAL_CRITICAL_THRESHOLD` — label `critical`, value 0, `channels: []`.
|
||||
// The empty channel list is what makes the save gate reachable at all.
|
||||
await expect(thresholdRows(page)).toHaveCount(1);
|
||||
await expect(page.getByTestId('threshold-name-input')).toHaveValue(
|
||||
'critical',
|
||||
);
|
||||
await expect(page.getByTestId('threshold-value-input')).toHaveValue('0');
|
||||
|
||||
await expect(v2SaveButton(page)).toBeDisabled();
|
||||
await expect(v2TestButton(page)).toBeDisabled();
|
||||
});
|
||||
|
||||
test('CV2-02 the save tooltip walks from the name gate to the channel gate', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
expect(alertChannel.name).toBeTruthy();
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
|
||||
// `validateCreateAlertState` returns the *first* failure, so the message order
|
||||
// encodes the validation order: name, then per-threshold label, then channels.
|
||||
expect(await v2SaveTooltip(page)).toBe(VALIDATION.name);
|
||||
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-02-${Date.now()}`);
|
||||
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
|
||||
});
|
||||
|
||||
test('CV2-03 clearing a threshold label re-gates the save', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-03-${Date.now()}`);
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
|
||||
// With a name and a channel the only remaining gate is the label.
|
||||
await expect(v2SaveButton(page)).toBeEnabled();
|
||||
|
||||
await page.getByTestId('threshold-name-input').fill('');
|
||||
expect(await v2SaveTooltip(page)).toBe(VALIDATION.thresholdLabel);
|
||||
});
|
||||
|
||||
test('CV2-04 a label added in the header survives the save round-trip', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
const name = `e2e-cv2-04-${Date.now()}`;
|
||||
await page.getByTestId('alert-name-input').fill(name);
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
|
||||
await addAlertLabel(page, 'team', 'payments');
|
||||
await expect(labelPill(page, 'team', 'payments')).toBeVisible();
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
|
||||
),
|
||||
v2SaveButton(page).click(),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
// Asserted on the request body rather than on the pill: the pill only proves
|
||||
// local state, and the defect worth guarding is a label that renders but is
|
||||
// never posted.
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.labels).toMatchObject({ team: 'payments' });
|
||||
});
|
||||
|
||||
test('CV2-05 a rejected label key surfaces as a notification, not an inline message', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
|
||||
// Both rejection branches in `LabelsInput` — duplicate key and group-by key —
|
||||
// raise an antd **notification**, so a spec looking for inline text fails. Only
|
||||
// the duplicate branch is reachable here: the group-by branch needs a query that
|
||||
// already groups by something, which the default logs query does not.
|
||||
await addAlertLabel(page, 'team', 'payments');
|
||||
await expect(labelPill(page, 'team', 'payments')).toBeVisible();
|
||||
|
||||
await page.getByTestId('alert-add-label-button').click();
|
||||
const input = page.getByTestId('alert-add-label-input');
|
||||
await input.fill('team');
|
||||
await input.press('Enter');
|
||||
|
||||
await expect(
|
||||
page.getByText('Label with this key already exists'),
|
||||
).toBeVisible();
|
||||
// Rejected, so no second pill appeared.
|
||||
await expect(page.locator('[data-testid^="label-pill-team-"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('CV2-06 CV2-07 the operator and match-type selects offer the documented options', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
|
||||
await page.getByTestId('alert-threshold-operator-select').click();
|
||||
// Exact-text matching, because `hasText: 'EQUAL TO'` also matches the
|
||||
// `NOT EQUAL TO` option and the count assertion then reads 2.
|
||||
for (const operator of Object.values(ThresholdOperator)) {
|
||||
await expect(dropdownOption(page, operator.label)).toHaveCount(1);
|
||||
}
|
||||
await expect(
|
||||
openDropdown(page).locator('.ant-select-item-option'),
|
||||
).toHaveCount(Object.keys(ThresholdOperator).length);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await page.getByTestId('alert-threshold-match-type-select').click();
|
||||
for (const matchType of Object.values(ThresholdMatchType)) {
|
||||
await expect(dropdownOption(page, matchType.label)).toHaveCount(1);
|
||||
}
|
||||
await expect(
|
||||
openDropdown(page).locator('.ant-select-item-option'),
|
||||
).toHaveCount(Object.keys(ThresholdMatchType).length);
|
||||
});
|
||||
|
||||
test('CV2-08 the operator is rule-wide: one change reaches every threshold', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-08-${Date.now()}`);
|
||||
|
||||
await page.getByTestId('add-threshold-button').click();
|
||||
await expect(thresholdRows(page)).toHaveCount(2);
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
await selectThresholdChannel(page, 1, alertChannel.name);
|
||||
|
||||
await page.getByTestId('alert-threshold-operator-select').click();
|
||||
await dropdownOption(page, ThresholdOperator.BELOW.label).click();
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
|
||||
),
|
||||
v2SaveButton(page).click(),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
// The UI models one operator per rule while the schema stores one per
|
||||
// threshold, so a single change is fanned out across `spec[]`.
|
||||
const spec = response.request().postDataJSON().condition.thresholds.spec;
|
||||
expect(spec).toHaveLength(2);
|
||||
expect(spec.map((entry: { op: string }) => entry.op)).toEqual([
|
||||
ThresholdOperator.BELOW.value,
|
||||
ThresholdOperator.BELOW.value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('CV2-09 CV2-10 added thresholds take preset tiers, and the first cannot be removed', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
|
||||
// `addThreshold` branches on the current count: 2nd ⇒ warning, 3rd ⇒ info,
|
||||
// 4th and beyond ⇒ an unnamed row with a random colour.
|
||||
await page.getByTestId('add-threshold-button').click();
|
||||
await page.getByTestId('add-threshold-button').click();
|
||||
await page.getByTestId('add-threshold-button').click();
|
||||
await expect(thresholdRows(page)).toHaveCount(4);
|
||||
|
||||
const names = page.getByTestId('threshold-name-input');
|
||||
await expect(names.nth(0)).toHaveValue('critical');
|
||||
await expect(names.nth(1)).toHaveValue('warning');
|
||||
await expect(names.nth(2)).toHaveValue('info');
|
||||
await expect(names.nth(3)).toHaveValue('');
|
||||
|
||||
// `showRemoveButton` is `index !== 0 && length > 1`, so there are three remove
|
||||
// buttons for four rows and the first row can never be removed.
|
||||
await expect(page.getByTestId('remove-threshold-button')).toHaveCount(3);
|
||||
|
||||
// To see the unnamed row's own gate, the earlier gates have to be satisfied
|
||||
// first: `validateCreateAlertState` loops thresholds and returns on the first
|
||||
// failure, checking label *then* channels **per threshold** — so with row 0
|
||||
// lacking a channel the channel message wins before row 3 is ever examined.
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-09-${Date.now()}`);
|
||||
for (const index of [0, 1, 2, 3]) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await selectThresholdChannel(page, index, alertChannel.name);
|
||||
}
|
||||
expect(await v2SaveTooltip(page)).toBe(VALIDATION.thresholdLabel);
|
||||
|
||||
await names.nth(3).fill('page-me');
|
||||
await expect(v2SaveButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
test('CV2-11 a channel on one threshold is not enough — the validator loops all of them', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-11-${Date.now()}`);
|
||||
await page.getByTestId('add-threshold-button').click();
|
||||
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
|
||||
|
||||
await selectThresholdChannel(page, 1, alertChannel.name);
|
||||
await expect(v2SaveButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
test('CV2-12 the unit select is disabled while the query has no y-axis unit', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
|
||||
// `disabled={units.length === 0}`, and `units` is derived from
|
||||
// `alertState.yAxisUnit`. A logs alert carries no unit, so the control is dead on
|
||||
// this path — CD-04 covers a URL that does supply one.
|
||||
const unitSelect = page.getByTestId('threshold-unit-select').first();
|
||||
await expect(unitSelect).toHaveClass(/ant-select-disabled/);
|
||||
});
|
||||
|
||||
test('CV2-13 the recovery threshold control is never rendered', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
|
||||
// `showRecoveryThreshold` starts false and the only setter is commented out, so
|
||||
// neither the input nor its remove button can appear.
|
||||
await expect(page.getByTestId('recovery-threshold-value-input')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId('remove-recovery-threshold-button'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('CV2-14 CV2-15 the evaluation window and cadence reach the payload', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-14-${Date.now()}`);
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
|
||||
// Written as one test because the two settings share a payload branch:
|
||||
// `getEvaluationProps` emits `evalWindow` and `frequency` together, and a
|
||||
// scenario that changed only one would still pass with the other hardcoded.
|
||||
await selectEvaluationTimeframe(page, '30m0s');
|
||||
|
||||
await expandAdvancedOptions(page);
|
||||
await evaluationCadenceInput(page).fill('5');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
|
||||
),
|
||||
v2SaveButton(page).click(),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
const { evaluation } = response.request().postDataJSON();
|
||||
expect(evaluation.kind).toBe('rolling');
|
||||
expect(evaluation.spec.evalWindow).toBe('30m0s');
|
||||
// `getFormattedTimeValue` maps value + unit onto a Go duration; the unit is
|
||||
// left at its default Minutes, which is what makes `5m` the expected string.
|
||||
expect(evaluation.spec.frequency).toBe('5m');
|
||||
});
|
||||
|
||||
test('CV2-18 with no channels the dropdown offers only a way to create one', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// The single deliberate network stub in the alerts suite — its justification is
|
||||
// in `stubNoChannels`, and the state it produces is the one every fresh install
|
||||
// starts in.
|
||||
await stubNoChannels(page);
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-18-${Date.now()}`);
|
||||
|
||||
const select = page
|
||||
.getByTestId('threshold-notification-channel-select')
|
||||
.first();
|
||||
await select.click();
|
||||
const dropdown = await ownDropdown(page, select);
|
||||
|
||||
// `NotificationChannelsNotFoundContent` branches on the user's role. The harness
|
||||
// user is an admin, so this is the "create one here" half — asserting the
|
||||
// non-admin string instead would fail for the wrong reason.
|
||||
await expect(dropdown.getByText('No channels yet.')).toBeVisible();
|
||||
await expect(dropdown.getByRole('button', { name: 'here.' })).toBeVisible();
|
||||
await expect(dropdown.getByRole('button', { name: 'Refresh' })).toBeVisible();
|
||||
await expect(
|
||||
dropdown.getByText('Please ask your admin to create one.'),
|
||||
).toBeHidden();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// With a name filled and no channel selectable, the channel gate is the only
|
||||
// thing left — and there is no way to satisfy it from this page.
|
||||
expect(await v2SaveTooltip(page)).toBe(VALIDATION.channels);
|
||||
await expect(v2SaveButton(page)).toBeDisabled();
|
||||
});
|
||||
|
||||
test('CV2-19 routing policies unlock the save with zero channels', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await stubNoChannels(page);
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-19-${Date.now()}`);
|
||||
await expect(v2SaveButton(page)).toBeDisabled();
|
||||
|
||||
await page.getByTestId('routing-policies-switch').click();
|
||||
|
||||
// The validator skips the channel check when `routingPolicies` is on, and the
|
||||
// threshold row *removes* its channel select rather than disabling it — so this
|
||||
// is the one route to a saveable rule on a stack with no channels at all.
|
||||
await expect(
|
||||
page.getByTestId('threshold-notification-channel-select'),
|
||||
).toHaveCount(0);
|
||||
await expect(v2SaveButton(page)).toBeEnabled();
|
||||
|
||||
await page.getByTestId('view-routing-policies-button').click();
|
||||
await page.waitForURL(/subTab=routing-policies/);
|
||||
const url = new URL(page.url());
|
||||
expect(url.pathname).toBe('/alerts');
|
||||
expect(url.searchParams.get('tab')).toBe('Configuration');
|
||||
});
|
||||
|
||||
test('CV2-16 the group-by select is disabled until the query groups by something', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
|
||||
// `isMultipleNotificationsEnabled` is `spaceAggregationOptions.length > 0`, and
|
||||
// the options come from the query's `groupBy` keys. The default logs query has
|
||||
// none, so notification grouping is unreachable without editing the query first.
|
||||
const groupBy = page.getByTestId('multiple-notifications-select');
|
||||
await expect(groupBy).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(page.getByText('No grouping fields available')).toBeVisible();
|
||||
});
|
||||
|
||||
test('CV2-17 repeat notifications enable their inputs and reach the payload', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-17-${Date.now()}`);
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
|
||||
const interval = page.getByTestId('repeat-notifications-time-input');
|
||||
await expect(interval).toBeDisabled();
|
||||
|
||||
await advancedOptionToggle(page, 'repeat-notifications-container').click();
|
||||
await expect(interval).toBeEnabled();
|
||||
await interval.fill('45');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
|
||||
),
|
||||
v2SaveButton(page).click(),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
// `getFormattedTimeValue` turns value+unit into a Go duration.
|
||||
const renotify = response.request().postDataJSON()
|
||||
.notificationSettings.renotify;
|
||||
expect(renotify.enabled).toBe(true);
|
||||
expect(renotify.interval).toBe('45m');
|
||||
});
|
||||
|
||||
test('CV2-20 happy-path save posts the v2 shape and lands on the list', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
const name = `e2e-cv2-20-${Date.now()}`;
|
||||
await page.getByTestId('alert-name-input').fill(name);
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
|
||||
// One click — v2 has no confirm dialog, unlike v1.
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
|
||||
),
|
||||
v2SaveButton(page).click(),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
|
||||
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.schemaVersion).toBe('v2alpha1');
|
||||
expect(body.version).toBe('v5');
|
||||
expect(body.alert).toBe(name);
|
||||
expect(body.condition.thresholds.kind).toBe('basic');
|
||||
expect(body.condition.thresholds.spec[0]).toMatchObject({
|
||||
name: 'critical',
|
||||
target: 0,
|
||||
matchType: ThresholdMatchType.AT_LEAST_ONCE.value,
|
||||
op: ThresholdOperator.ABOVE.value,
|
||||
channels: [alertChannel.name],
|
||||
targetUnit: '',
|
||||
});
|
||||
// The payload carries no recovery field at all — see CV2-13.
|
||||
expect(body.condition.thresholds.spec[0]).not.toHaveProperty(
|
||||
'recoveryTarget',
|
||||
);
|
||||
|
||||
await expect(page.getByText('Alert rule created successfully')).toBeVisible();
|
||||
// `safeNavigate('/alerts')`; the list page then appends its own defaults, so
|
||||
// only the pathname is asserted.
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts');
|
||||
});
|
||||
|
||||
test('CV2-21 test notification reports that a non-firing rule matched nothing', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-21-${Date.now()}`);
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('/api/v2/rules/test') && r.request().method() === 'POST',
|
||||
),
|
||||
v2TestButton(page).click(),
|
||||
]);
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
// `alertCount === 0` is an *error* toast, not a success one — the rule evaluated
|
||||
// fine, it just did not fire. Asserted permissively so a stack that happens to
|
||||
// have matching data does not flip it.
|
||||
await expect(
|
||||
page.getByText(/No alerts found during the evaluation|sent successfully/),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('CV2-22 discard leaves without posting and resets the form', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-22-${Date.now()}`);
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
|
||||
let sawPost = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'POST' && request.url().includes('/api/v2/rules')) {
|
||||
sawPost = true;
|
||||
}
|
||||
});
|
||||
|
||||
// dispatchEvent, not click — the side navigation covers the button (CE-09).
|
||||
await v2ClickDiscard(page);
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
expect(sawPost).toBe(false);
|
||||
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveValue('');
|
||||
await expect(thresholdRows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('CV2-23 every footer button is disabled while the save is in flight', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await gotoCreateAlertV2(page, { alertType: AlertType.LOGS });
|
||||
await page.getByTestId('alert-name-input').fill(`e2e-cv2-23-${Date.now()}`);
|
||||
await selectThresholdChannel(page, 0, alertChannel.name);
|
||||
|
||||
// The in-flight window is a few milliseconds against a local stack, so it is
|
||||
// widened by *delaying* the request — `route.continue()` still sends it to the
|
||||
// real backend and the real 201 comes back, so nothing about the response is
|
||||
// faked.
|
||||
await page.route('**/api/v2/rules', async (route) => {
|
||||
if (route.request().method() !== 'POST') {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2_000);
|
||||
});
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/v2/rules') && r.request().method() === 'POST',
|
||||
);
|
||||
await v2SaveButton(page).click();
|
||||
|
||||
// `disableButtons` is one flag shared by all three, so Discard going disabled is
|
||||
// what proves a user cannot abandon a half-created rule mid-request.
|
||||
await expect(page.getByTestId('save-alert-rule-loader-icon')).toBeVisible();
|
||||
await expect(page.getByTestId('save-alert-rule-check-icon')).toHaveCount(0);
|
||||
await expect(v2SaveButton(page)).toBeDisabled();
|
||||
await expect(v2TestButton(page)).toBeDisabled();
|
||||
await expect(v2DiscardButton(page)).toBeDisabled();
|
||||
|
||||
const response = await responsePromise;
|
||||
await ownedRules.register(response);
|
||||
expect(response.status()).toBe(201);
|
||||
await expect(page.getByText('Alert rule created successfully')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user