mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-09 14:40:38 +01:00
Compare commits
23 Commits
feat/alert
...
ns/saved-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfade5b070 | ||
|
|
377eb89f85 | ||
|
|
228a944956 | ||
|
|
ad15653789 | ||
|
|
450685ba3e | ||
|
|
6372af75a6 | ||
|
|
dc86f88bf7 | ||
|
|
c4eff4edcd | ||
|
|
b9069afb4d | ||
|
|
40a036f2b0 | ||
|
|
d752452d40 | ||
|
|
4a8a1efb03 | ||
|
|
7d2afa5a54 | ||
|
|
b579190140 | ||
|
|
6222204af7 | ||
|
|
0fcd6aeada | ||
|
|
32af113d0c | ||
|
|
61588b9303 | ||
|
|
dbc40efe24 | ||
|
|
33a428496b | ||
|
|
85bf5ce644 | ||
|
|
a654f648ee | ||
|
|
a81c7d3f97 |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -61,6 +61,7 @@ jobs:
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
- savedview
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- querier_json_body
|
||||
|
||||
@@ -4212,6 +4212,21 @@ components:
|
||||
- missingOptionalMetrics
|
||||
- missingRequiredAttributes
|
||||
type: object
|
||||
InframonitoringtypesClusterFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByNodeReadiness:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
|
||||
nullable: true
|
||||
type: array
|
||||
filterByPodStatus:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
InframonitoringtypesClusterRecord:
|
||||
properties:
|
||||
clusterCPU:
|
||||
@@ -4349,6 +4364,16 @@ components:
|
||||
- containerCannotRun
|
||||
- unknown
|
||||
type: object
|
||||
InframonitoringtypesContainerFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByContainerStatus:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerStatus'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
InframonitoringtypesContainerReady:
|
||||
enum:
|
||||
- ready
|
||||
@@ -4448,6 +4473,16 @@ components:
|
||||
- total
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDaemonSetFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByPodStatus:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
InframonitoringtypesDaemonSetRecord:
|
||||
properties:
|
||||
currentNodes:
|
||||
@@ -4520,6 +4555,16 @@ components:
|
||||
- total
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesDeploymentFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByPodStatus:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
InframonitoringtypesDeploymentRecord:
|
||||
properties:
|
||||
availablePods:
|
||||
@@ -4661,6 +4706,16 @@ components:
|
||||
- total
|
||||
- endTimeBeforeRetention
|
||||
type: object
|
||||
InframonitoringtypesJobFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByPodStatus:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
InframonitoringtypesJobRecord:
|
||||
properties:
|
||||
activePods:
|
||||
@@ -4784,6 +4839,16 @@ components:
|
||||
- message
|
||||
- documentationLink
|
||||
type: object
|
||||
InframonitoringtypesNamespaceFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByPodStatus:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
InframonitoringtypesNamespaceRecord:
|
||||
properties:
|
||||
counts:
|
||||
@@ -4865,6 +4930,21 @@ components:
|
||||
- ready
|
||||
- notReady
|
||||
type: object
|
||||
InframonitoringtypesNodeFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByNodeReadiness:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
|
||||
nullable: true
|
||||
type: array
|
||||
filterByPodStatus:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
InframonitoringtypesNodeRecord:
|
||||
properties:
|
||||
condition:
|
||||
@@ -4981,6 +5061,16 @@ components:
|
||||
- shutdown
|
||||
- unexpectedAdmissionError
|
||||
type: object
|
||||
InframonitoringtypesPodFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByPodStatus:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
InframonitoringtypesPodRecord:
|
||||
properties:
|
||||
meta:
|
||||
@@ -5080,7 +5170,7 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
$ref: '#/components/schemas/InframonitoringtypesClusterFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
@@ -5106,7 +5196,7 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
$ref: '#/components/schemas/InframonitoringtypesContainerFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
@@ -5132,7 +5222,7 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
$ref: '#/components/schemas/InframonitoringtypesDaemonSetFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
@@ -5158,7 +5248,7 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
$ref: '#/components/schemas/InframonitoringtypesDeploymentFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
@@ -5210,7 +5300,7 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
$ref: '#/components/schemas/InframonitoringtypesJobFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
@@ -5236,7 +5326,7 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
$ref: '#/components/schemas/InframonitoringtypesNamespaceFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
@@ -5262,7 +5352,7 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
@@ -5288,7 +5378,7 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
@@ -5314,7 +5404,7 @@ components:
|
||||
format: int64
|
||||
type: integer
|
||||
filter:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Filter'
|
||||
$ref: '#/components/schemas/InframonitoringtypesStatefulSetFilter'
|
||||
groupBy:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
|
||||
@@ -5365,6 +5455,16 @@ components:
|
||||
- list
|
||||
- grouped_list
|
||||
type: string
|
||||
InframonitoringtypesStatefulSetFilter:
|
||||
properties:
|
||||
expression:
|
||||
type: string
|
||||
filterByPodStatus:
|
||||
items:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
nullable: true
|
||||
type: array
|
||||
type: object
|
||||
InframonitoringtypesStatefulSetRecord:
|
||||
properties:
|
||||
currentPods:
|
||||
@@ -7780,17 +7880,20 @@ components:
|
||||
type: string
|
||||
SavedviewtypesPostableSavedView:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
|
||||
generateName:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
schemaVersion:
|
||||
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
|
||||
source:
|
||||
$ref: '#/components/schemas/SavedviewtypesSource'
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
required:
|
||||
- source
|
||||
- data
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
SavedviewtypesSavedView:
|
||||
properties:
|
||||
@@ -7799,14 +7902,16 @@ components:
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schemaVersion:
|
||||
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
|
||||
source:
|
||||
$ref: '#/components/schemas/SavedviewtypesSource'
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
@@ -7814,14 +7919,6 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
SavedviewtypesSavedViewData:
|
||||
properties:
|
||||
schemaVersion:
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
required:
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
@@ -7836,6 +7933,7 @@ components:
|
||||
queries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryEnvelope'
|
||||
minItems: 1
|
||||
type: array
|
||||
selectedFields:
|
||||
items:
|
||||
@@ -7845,9 +7943,11 @@ components:
|
||||
- displayName
|
||||
- panelType
|
||||
- queries
|
||||
- selectedFields
|
||||
- display
|
||||
type: object
|
||||
SavedviewtypesSchemaVersion:
|
||||
enum:
|
||||
- v2
|
||||
type: string
|
||||
SavedviewtypesSource:
|
||||
enum:
|
||||
- traces
|
||||
@@ -7857,13 +7957,16 @@ components:
|
||||
type: string
|
||||
SavedviewtypesUpdatableSavedView:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewData'
|
||||
schemaVersion:
|
||||
$ref: '#/components/schemas/SavedviewtypesSchemaVersion'
|
||||
source:
|
||||
$ref: '#/components/schemas/SavedviewtypesSource'
|
||||
spec:
|
||||
$ref: '#/components/schemas/SavedviewtypesSavedViewSpec'
|
||||
required:
|
||||
- source
|
||||
- data
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
@@ -22872,6 +22975,12 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"409":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Conflict
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
|
||||
@@ -232,14 +232,11 @@ cd tests/e2e
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single sub-area
|
||||
npx playwright test tests/alerts/history/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/page.spec.ts --project=chromium
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "AL-01"
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
@@ -273,14 +270,7 @@ yarn test:staging
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) → `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
|
||||
|
||||
```bash
|
||||
# runs against a locally served frontend, not whatever .env.local points at
|
||||
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
|
||||
```
|
||||
|
||||
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
|
||||
### Playwright options
|
||||
|
||||
|
||||
@@ -5648,6 +5648,47 @@ export interface InframonitoringtypesChecksDTO {
|
||||
type: InframonitoringtypesCheckTypeDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesNodeConditionDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export enum InframonitoringtypesPodStatusDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
failed = 'failed',
|
||||
unknown = 'unknown',
|
||||
crashloopbackoff = 'crashloopbackoff',
|
||||
imagepullbackoff = 'imagepullbackoff',
|
||||
errimagepull = 'errimagepull',
|
||||
createcontainerconfigerror = 'createcontainerconfigerror',
|
||||
containercreating = 'containercreating',
|
||||
oomkilled = 'oomkilled',
|
||||
completed = 'completed',
|
||||
error = 'error',
|
||||
containercannotrun = 'containercannotrun',
|
||||
evicted = 'evicted',
|
||||
nodeaffinity = 'nodeaffinity',
|
||||
nodelost = 'nodelost',
|
||||
shutdown = 'shutdown',
|
||||
unexpectedadmissionerror = 'unexpectedadmissionerror',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export interface InframonitoringtypesClusterFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesClusterRecordDTOCounts = {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -5923,21 +5964,6 @@ export interface InframonitoringtypesContainerCountsByStatusDTO {
|
||||
waiting: number;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesContainerReadyDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesContainerRecordDTOMeta =
|
||||
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
|
||||
|
||||
export enum InframonitoringtypesContainerStatusDTO {
|
||||
running = 'running',
|
||||
waiting = 'waiting',
|
||||
@@ -5954,6 +5980,32 @@ export enum InframonitoringtypesContainerStatusDTO {
|
||||
unknown = 'unknown',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export interface InframonitoringtypesContainerFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByContainerStatus?: InframonitoringtypesContainerStatusDTO[] | null;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesContainerReadyDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export type InframonitoringtypesContainerRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type InframonitoringtypesContainerRecordDTOMeta =
|
||||
InframonitoringtypesContainerRecordDTOMetaAnyOf | null;
|
||||
|
||||
export interface InframonitoringtypesContainerRecordDTO {
|
||||
containerCountsByReady: InframonitoringtypesContainerCountsByReadyDTO;
|
||||
containerCountsByStatus: InframonitoringtypesContainerCountsByStatusDTO;
|
||||
@@ -6025,6 +6077,17 @@ export interface InframonitoringtypesContainersDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesDaemonSetFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -6110,6 +6173,17 @@ export interface InframonitoringtypesDaemonSetsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesDeploymentFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesDeploymentRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -6272,6 +6346,17 @@ export interface InframonitoringtypesHostsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesJobFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesJobRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -6357,6 +6442,17 @@ export interface InframonitoringtypesJobsDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNamespaceFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesNamespaceRecordDTOCounts = {
|
||||
/**
|
||||
* @type integer
|
||||
@@ -6433,11 +6529,21 @@ export interface InframonitoringtypesNamespacesDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesNodeConditionDTO {
|
||||
ready = 'ready',
|
||||
not_ready = 'not_ready',
|
||||
no_data = 'no_data',
|
||||
export interface InframonitoringtypesNodeFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByNodeReadiness?: InframonitoringtypesNodeConditionDTO[] | null;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesNodeRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -6499,6 +6605,17 @@ export interface InframonitoringtypesNodesDTO {
|
||||
warning?: Querybuildertypesv5QueryWarnDataDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPodFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
|
||||
}
|
||||
|
||||
export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
@@ -6509,27 +6626,6 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
|
||||
export type InframonitoringtypesPodRecordDTOMeta =
|
||||
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
|
||||
|
||||
export enum InframonitoringtypesPodStatusDTO {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
failed = 'failed',
|
||||
unknown = 'unknown',
|
||||
crashloopbackoff = 'crashloopbackoff',
|
||||
imagepullbackoff = 'imagepullbackoff',
|
||||
errimagepull = 'errimagepull',
|
||||
createcontainerconfigerror = 'createcontainerconfigerror',
|
||||
containercreating = 'containercreating',
|
||||
oomkilled = 'oomkilled',
|
||||
completed = 'completed',
|
||||
error = 'error',
|
||||
containercannotrun = 'containercannotrun',
|
||||
evicted = 'evicted',
|
||||
nodeaffinity = 'nodeaffinity',
|
||||
nodelost = 'nodelost',
|
||||
shutdown = 'shutdown',
|
||||
unexpectedadmissionerror = 'unexpectedadmissionerror',
|
||||
no_data = 'no_data',
|
||||
}
|
||||
export interface InframonitoringtypesPodRecordDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
@@ -6606,7 +6702,7 @@ export interface InframonitoringtypesPostableClustersDTO {
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
filter?: InframonitoringtypesClusterFilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -6633,7 +6729,7 @@ export interface InframonitoringtypesPostableContainersDTO {
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
filter?: InframonitoringtypesContainerFilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -6660,7 +6756,7 @@ export interface InframonitoringtypesPostableDaemonSetsDTO {
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
filter?: InframonitoringtypesDaemonSetFilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -6687,7 +6783,7 @@ export interface InframonitoringtypesPostableDeploymentsDTO {
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
filter?: InframonitoringtypesDeploymentFilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -6741,7 +6837,7 @@ export interface InframonitoringtypesPostableJobsDTO {
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
filter?: InframonitoringtypesJobFilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -6768,7 +6864,7 @@ export interface InframonitoringtypesPostableNamespacesDTO {
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
filter?: InframonitoringtypesNamespaceFilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -6795,7 +6891,7 @@ export interface InframonitoringtypesPostableNodesDTO {
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
filter?: InframonitoringtypesNodeFilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -6822,7 +6918,7 @@ export interface InframonitoringtypesPostablePodsDTO {
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
filter?: InframonitoringtypesPodFilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -6843,13 +6939,24 @@ export interface InframonitoringtypesPostablePodsDTO {
|
||||
start: number;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesStatefulSetFilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
expression?: string;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
filterByPodStatus?: InframonitoringtypesPodStatusDTO[] | null;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesPostableStatefulSetsDTO {
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
end: number;
|
||||
filter?: Querybuildertypesv5FilterDTO;
|
||||
filter?: InframonitoringtypesStatefulSetFilterDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
@@ -8884,8 +8991,17 @@ export enum SavedviewtypesPanelTypeDTO {
|
||||
list = 'list',
|
||||
trace = 'trace',
|
||||
}
|
||||
export enum SavedviewtypesSchemaVersionDTO {
|
||||
v2 = 'v2',
|
||||
}
|
||||
export enum SavedviewtypesSourceDTO {
|
||||
traces = 'traces',
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display: SavedviewtypesDisplayDTO;
|
||||
display?: SavedviewtypesDisplayDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -8898,25 +9014,10 @@ export interface SavedviewtypesSavedViewSpecDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
selectedFields: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
selectedFields?: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
}
|
||||
|
||||
export interface SavedviewtypesSavedViewDataDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
}
|
||||
|
||||
export enum SavedviewtypesSourceDTO {
|
||||
traces = 'traces',
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
}
|
||||
export interface SavedviewtypesPostableSavedViewDTO {
|
||||
data: SavedviewtypesSavedViewDataDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -8925,7 +9026,9 @@ export interface SavedviewtypesPostableSavedViewDTO {
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO;
|
||||
source: SavedviewtypesSourceDTO;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
}
|
||||
|
||||
export interface SavedviewtypesSavedViewDTO {
|
||||
@@ -8938,7 +9041,6 @@ export interface SavedviewtypesSavedViewDTO {
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
data?: SavedviewtypesSavedViewDataDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -8947,7 +9049,9 @@ export interface SavedviewtypesSavedViewDTO {
|
||||
* @type string
|
||||
*/
|
||||
name?: string;
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO;
|
||||
source?: SavedviewtypesSourceDTO;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
@@ -8960,8 +9064,9 @@ export interface SavedviewtypesSavedViewDTO {
|
||||
}
|
||||
|
||||
export interface SavedviewtypesUpdatableSavedViewDTO {
|
||||
data: SavedviewtypesSavedViewDataDTO;
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO;
|
||||
source: SavedviewtypesSourceDTO;
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
|
||||
@@ -29,7 +29,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -41,7 +40,6 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
>
|
||||
<div className="icon">
|
||||
<DraftingCompass
|
||||
|
||||
@@ -26,10 +26,7 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -41,10 +38,7 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -56,10 +50,7 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,12 +103,7 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
|
||||
data-testid="stats-card"
|
||||
data-stats-title={title}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
>
|
||||
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -137,7 +123,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
<div className="count-label">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,11 +81,7 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,16 +48,11 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="top-contributors-card">
|
||||
<div className="top-contributors-card__header">
|
||||
<div className="title">top contributors</div>
|
||||
{topContributorsData.length > 3 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="view-all"
|
||||
onClick={toggleViewAllDrawer}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,10 +68,7 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
<div className="total-contribution">
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -81,10 +78,7 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,10 +31,7 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -32,8 +32,8 @@ function GraphWrapper({
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
|
||||
@@ -118,10 +118,7 @@ function TimelineTableContent(): JSX.Element {
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
@@ -131,15 +128,12 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-table" data-testid="timeline-table">
|
||||
<div className="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div
|
||||
className="timeline-table__filter-search"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<div className="timeline-table__filter-search">
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
@@ -161,7 +155,6 @@ function TimelineTableContent(): JSX.Element {
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -179,17 +172,14 @@ function TimelineTableContent(): JSX.Element {
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error" data-testid="timeline-error">
|
||||
<div className="timeline-table__error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div
|
||||
className="timeline-table__pagination-info"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
<div className="timeline-table__pagination-info">
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
|
||||
@@ -21,7 +21,7 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule-state" data-testid="timeline-row-state">
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
),
|
||||
@@ -30,7 +30,7 @@ export const timelineTableColumns = ({
|
||||
title: 'LABELS',
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels" data-testid="timeline-row-labels">
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
),
|
||||
@@ -40,10 +40,7 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
<div className="alert-rule__created-at">
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -56,7 +53,7 @@ export const timelineTableColumns = ({
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Button type="text" ghost disabled>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -68,7 +65,7 @@ export const timelineTableColumns = ({
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Button type="text" ghost>
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
|
||||
@@ -23,7 +23,6 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -34,7 +33,6 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -59,17 +57,14 @@ function TimelineFilters(): JSX.Element {
|
||||
{
|
||||
value: TimelineFilter.ALL,
|
||||
label: 'All',
|
||||
testId: 'timeline-filter-all',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.FIRED,
|
||||
label: 'Fired',
|
||||
testId: 'timeline-filter-fired',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.RESOLVED,
|
||||
label: 'Resolved',
|
||||
testId: 'timeline-filter-resolved',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
testId="send-notification-if-data-is-missing-input"
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
@@ -67,7 +66,6 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
testId="enforce-minimum-datapoints-input"
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,6 @@ 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,7 +186,6 @@ 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} />
|
||||
@@ -219,7 +218,6 @@ 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} />
|
||||
@@ -251,7 +249,6 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
disabled={disableButtons}
|
||||
testId="discard-alert-rule-button"
|
||||
>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
|
||||
@@ -119,7 +119,6 @@ function BasicInfo({
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
@@ -148,7 +147,6 @@ function BasicInfo({
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
@@ -163,7 +161,6 @@ 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" data-testid="query-builder-tab">
|
||||
<Button className="nav-btns">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
@@ -122,11 +122,7 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -166,11 +162,7 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -188,11 +180,7 @@ function QuerySection({
|
||||
: 'PromQL'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="promql-tab"
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,6 @@ 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) || '';
|
||||
|
||||
@@ -117,7 +116,6 @@ 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>
|
||||
@@ -179,7 +177,6 @@ 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>
|
||||
@@ -197,7 +194,6 @@ 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>
|
||||
@@ -399,7 +395,6 @@ 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,6 +844,8 @@ function FormAlertRules({
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className={`form-alert-rules-container ${
|
||||
@@ -966,7 +968,6 @@ function FormAlertRules({
|
||||
!isChannelConfigurationValid ||
|
||||
queryStatus === 'error'
|
||||
}
|
||||
data-testid="alert-save-button"
|
||||
>
|
||||
{isNewRule ? t('button_createrule') : t('button_savechanges')}
|
||||
</ActionButton>
|
||||
@@ -980,7 +981,6 @@ function FormAlertRules({
|
||||
}
|
||||
type="default"
|
||||
onClick={onTestRuleHandler}
|
||||
data-testid="alert-test-button"
|
||||
>
|
||||
{' '}
|
||||
{t('button_testrule')}
|
||||
@@ -989,7 +989,6 @@ function FormAlertRules({
|
||||
disabled={loading || false}
|
||||
type="default"
|
||||
onClick={onCancelHandler}
|
||||
data-testid="alert-cancel-button"
|
||||
>
|
||||
{isNewRule && t('button_cancelchanges')}
|
||||
{ruleId && !isEmpty(ruleId) && t('button_discard')}
|
||||
@@ -999,7 +998,6 @@ function FormAlertRules({
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
testId="alert-save-confirm-dialog"
|
||||
open={isConfirmSaveOpen}
|
||||
onOpenChange={setIsConfirmSaveOpen}
|
||||
title={t('confirm_save_title')}
|
||||
|
||||
@@ -174,7 +174,6 @@ function LabelSelect({
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
|
||||
@@ -94,8 +94,6 @@ function AlertDetails(): JSX.Element {
|
||||
>
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
data-testid="alert-details-root"
|
||||
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
|
||||
@@ -117,11 +117,7 @@ function AlertActionButtons({
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
@@ -133,7 +129,6 @@ function AlertActionButtons({
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
|
||||
@@ -47,29 +47,21 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<div data-testid="alert-header-state">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
</div>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
<div className="alert-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && (
|
||||
<div data-testid="alert-header-severity">
|
||||
<AlertSeverity severity={labels.severity} />
|
||||
</div>
|
||||
)}
|
||||
{labels?.severity && <AlertSeverity severity={labels.severity} />}
|
||||
|
||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||
{/* <AlertStatus
|
||||
status="firing"
|
||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||
/> */}
|
||||
<div data-testid="alert-header-labels">
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
</div>
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -127,7 +127,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<div className="tab-item">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<div className="tab-item">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
|
||||
@@ -13,8 +13,6 @@ interface Tab {
|
||||
disabled?: boolean;
|
||||
icon?: string | JSX.Element;
|
||||
isBeta?: boolean;
|
||||
/** Optional `data-testid` for the tab button. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
interface TimelineTabsProps {
|
||||
@@ -65,7 +63,6 @@ function Tabs2({
|
||||
disabled={tab.disabled}
|
||||
icon={tab.icon}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
data-testid={tab.testId}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceSavedView.Scope(coretypes.VerbCreate)}),
|
||||
},
|
||||
|
||||
@@ -84,20 +84,38 @@ func buildClusterRecords(
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopClusterGroupsAndMetadata concurrently fetches metadata + the ordering-metric
|
||||
// ranking (plus the full-scope pod-status / node-readiness keysets when filtering,
|
||||
// to intersect all).
|
||||
func (m *module) getTopClusterGroupsAndMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableClusters,
|
||||
) ([]map[string]string, map[string]map[string]string, error) {
|
||||
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, map[string]nodeConditionCounts, error) {
|
||||
|
||||
var (
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
nodeConditionCounts map[string]nodeConditionCounts
|
||||
filter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
filterByNodeReadiness []inframonitoringtypes.NodeCondition
|
||||
)
|
||||
|
||||
orderByKey = req.OrderBy.Key.Name
|
||||
|
||||
// When filtering by pod status / node readiness, resolve the full-scope
|
||||
// keyset(s) concurrently (pageGroups=nil spans all groups under the user
|
||||
// filter) to intersect metadata + ranked groups below. Filters compose as AND.
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -106,12 +124,37 @@ func (m *module) getTopClusterGroupsAndMetadata(
|
||||
return err
|
||||
})
|
||||
|
||||
if len(filterByPodStatus) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if len(filterByNodeReadiness) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByNodeReadiness)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if orderByKey == inframonitoringtypes.ClusterNameAttrKey {
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
// Secondary filter: keep only status/readiness-matching groups. A missing
|
||||
// metric yields an empty statusCounts, so this correctly empties the result
|
||||
// (the caller also surfaces the warning). Filters compose as AND.
|
||||
if len(filterByPodStatus) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
if len(filterByNodeReadiness) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
|
||||
}
|
||||
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.ClusterNameAttrKey)
|
||||
return pageGroups, metadataMap, nil
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
|
||||
}
|
||||
|
||||
queryNamesForOrderBy := orderByToClustersQueryNames[orderByKey]
|
||||
@@ -157,10 +200,23 @@ func (m *module) getTopClusterGroupsAndMetadata(
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
|
||||
// Secondary filter: intersect ranked groups + metadata with the status/readiness
|
||||
// keyset. A missing metric yields an empty keyset, correctly emptying the result
|
||||
// (the caller also surfaces the warning). Filters compose as AND.
|
||||
if len(filterByPodStatus) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
if len(filterByNodeReadiness) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, nodeConditionCounts)
|
||||
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
|
||||
}
|
||||
|
||||
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
|
||||
}
|
||||
|
||||
func (m *module) getClustersTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (map[string]map[string]string, error) {
|
||||
@@ -170,5 +226,9 @@ func (m *module) getClustersTableMetadata(ctx context.Context, orgID valuer.UUID
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, clustersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -139,20 +139,34 @@ func buildContainerRecords(
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopContainerGroupsAndMetadata concurrently fetches metadata + the ordering-metric
|
||||
// ranking (plus the full-scope container-status keyset when filtering, to intersect both).
|
||||
func (m *module) getTopContainerGroupsAndMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableContainers,
|
||||
) ([]map[string]string, map[string]map[string]string, error) {
|
||||
) ([]map[string]string, map[string]map[string]string, map[string]containerStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
|
||||
var (
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
statusCounts map[string]containerStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
filter *qbtypes.Filter
|
||||
filterByContainerStatus []inframonitoringtypes.ContainerStatus
|
||||
)
|
||||
|
||||
orderByKey = req.OrderBy.Key.Name
|
||||
|
||||
// When filtering by container status, resolve the full-scope status keyset
|
||||
// concurrently (pageGroups=nil spans all groups under the user filter) so it
|
||||
// can intersect metadata + ranked groups below.
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
filterByContainerStatus = req.Filter.FilterByContainerStatus
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -161,12 +175,26 @@ func (m *module) getTopContainerGroupsAndMetadata(
|
||||
return err
|
||||
})
|
||||
|
||||
if len(filterByContainerStatus) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByContainerStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if orderByKey == inframonitoringtypes.ContainerNameAttrKey {
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
// Secondary filter: keep only status-matching groups. A missing metric
|
||||
// yields an empty statusCounts, so this correctly empties the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByContainerStatus) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.ContainerNameAttrKey)
|
||||
return pageGroups, metadataMap, nil
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
queryNamesForOrderBy := orderByToContainersQueryNames[orderByKey]
|
||||
@@ -212,10 +240,19 @@ func (m *module) getTopContainerGroupsAndMetadata(
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
|
||||
// Secondary filter: intersect ranked groups + metadata with the status keyset.
|
||||
// A missing metric yields an empty statusCounts, correctly emptying the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByContainerStatus) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
|
||||
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
func (m *module) getContainersTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableContainers) (map[string]map[string]string, error) {
|
||||
@@ -225,7 +262,11 @@ func (m *module) getContainersTableMetadata(ctx context.Context, orgID valuer.UU
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, containersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, containersTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
// getPerGroupContainerStatusCountsWithReqMetricChecks gates
|
||||
@@ -241,6 +282,7 @@ func (m *module) getPerGroupContainerStatusCountsWithReqMetricChecks(
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
filterByContainerStatus []inframonitoringtypes.ContainerStatus,
|
||||
) (map[string]containerStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
present, err := m.getMetricsExistence(ctx, containerStatusMetricNamesList)
|
||||
if err != nil {
|
||||
@@ -266,13 +308,28 @@ func (m *module) getPerGroupContainerStatusCountsWithReqMetricChecks(
|
||||
return map[string]containerStatusCounts{}, warning, nil
|
||||
}
|
||||
|
||||
counts, err := m.getPerGroupContainerStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups)
|
||||
counts, err := m.getPerGroupContainerStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups, filterByContainerStatus)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return counts, nil, nil
|
||||
}
|
||||
|
||||
// applyContainerStatusFilter adds the display-status push-down (lower(display_status)
|
||||
// IN (...)) to the outer count builder. valuer lowercases the wire value while
|
||||
// display_status is kubectl-cased, so we compare lower() on both. No-op when the
|
||||
// requested set is empty.
|
||||
func applyContainerStatusFilter(cb *sqlbuilder.SelectBuilder, filterByContainerStatus []inframonitoringtypes.ContainerStatus) {
|
||||
if len(filterByContainerStatus) == 0 {
|
||||
return
|
||||
}
|
||||
vals := make([]string, len(filterByContainerStatus))
|
||||
for i, c := range filterByContainerStatus {
|
||||
vals[i] = c.StringValue()
|
||||
}
|
||||
cb.Where(cb.In("lower(display_status)", sqlbuilder.List(vals)))
|
||||
}
|
||||
|
||||
// getPerGroupContainerStatusCounts computes per-group counts of distinct
|
||||
// containers bucketed by their latest kubectl-style display status in window.
|
||||
// Caller must ensure the required metrics exist
|
||||
@@ -297,8 +354,11 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
filterByContainerStatus []inframonitoringtypes.ContainerStatus,
|
||||
) (map[string]containerStatusCounts, error) {
|
||||
if len(pageGroups) == 0 || len(groupBy) == 0 {
|
||||
// Empty pageGroups means "span all under user filter", allowed only in
|
||||
// full-scope mode (filtering by status). Otherwise it's an empty page.
|
||||
if len(groupBy) == 0 || (len(pageGroups) == 0 && len(filterByContainerStatus) == 0) {
|
||||
return map[string]containerStatusCounts{}, nil
|
||||
}
|
||||
|
||||
@@ -482,11 +542,15 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
countGroupBy = append(countGroupBy, col)
|
||||
}
|
||||
countSelectCols = append(countSelectCols, statusCountCols...)
|
||||
countSQL := fmt.Sprintf(
|
||||
"SELECT %s FROM container_status GROUP BY %s",
|
||||
strings.Join(countSelectCols, ", "),
|
||||
strings.Join(countGroupBy, ", "),
|
||||
)
|
||||
|
||||
// Outer count query. Built with sqlbuilder so the status push-down uses a
|
||||
// proper IN (keep only containers whose display status is in the requested set).
|
||||
countBuilder := sqlbuilder.NewSelectBuilder()
|
||||
countBuilder.Select(countSelectCols...)
|
||||
countBuilder.From("container_status")
|
||||
applyContainerStatusFilter(countBuilder, filterByContainerStatus)
|
||||
countBuilder.GroupBy(countGroupBy...)
|
||||
countSQL, countArgs := countBuilder.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// Combine CTEs + outer. Arg order mirrors CTE declaration order.
|
||||
cteFragments := []string{
|
||||
@@ -499,7 +563,7 @@ func (m *module) getPerGroupContainerStatusCounts(
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + countSQL
|
||||
finalArgs := querybuilder.PrependArgs([][]any{
|
||||
stateFpsArgs, containerStateArgs, reasonFpsArgs, reasonInnerArgs,
|
||||
}, nil)
|
||||
}, countArgs)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestApplyContainerStatusFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statuses []inframonitoringtypes.ContainerStatus
|
||||
wantWhere bool
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "empty set yields no clause",
|
||||
statuses: nil,
|
||||
wantWhere: false,
|
||||
wantArgs: nil,
|
||||
},
|
||||
{
|
||||
name: "single status pushes lowercased arg via IN",
|
||||
statuses: []inframonitoringtypes.ContainerStatus{inframonitoringtypes.ContainerStatusRunning},
|
||||
wantWhere: true,
|
||||
wantArgs: []any{"running"},
|
||||
},
|
||||
{
|
||||
name: "multiple statuses push lowercased args via IN",
|
||||
statuses: []inframonitoringtypes.ContainerStatus{
|
||||
inframonitoringtypes.ContainerStatusRunning,
|
||||
inframonitoringtypes.ContainerStatusCrashLoopBackOff,
|
||||
},
|
||||
wantWhere: true,
|
||||
wantArgs: []any{"running", "crashloopbackoff"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cb := sqlbuilder.NewSelectBuilder()
|
||||
cb.Select("pod_uid")
|
||||
cb.From("container_status")
|
||||
applyContainerStatusFilter(cb, tt.statuses)
|
||||
sql, args := cb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
hasWhere := strings.Contains(sql, "lower(display_status) IN (")
|
||||
assert.Equal(t, tt.wantWhere, hasWhere)
|
||||
if len(tt.wantArgs) == 0 {
|
||||
assert.Empty(t, args)
|
||||
} else {
|
||||
assert.Equal(t, tt.wantArgs, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -90,20 +90,34 @@ func buildDaemonSetRecords(
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopDaemonSetGroupsAndMetadata concurrently fetches metadata + the ordering-metric
|
||||
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
|
||||
func (m *module) getTopDaemonSetGroupsAndMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableDaemonSets,
|
||||
) ([]map[string]string, map[string]map[string]string, error) {
|
||||
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
|
||||
var (
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
filter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
)
|
||||
|
||||
orderByKey = req.OrderBy.Key.Name
|
||||
|
||||
// When filtering by pod status, resolve the full-scope status keyset
|
||||
// concurrently (pageGroups=nil spans all groups under the user filter) so it
|
||||
// can intersect metadata + ranked groups below.
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -112,12 +126,26 @@ func (m *module) getTopDaemonSetGroupsAndMetadata(
|
||||
return err
|
||||
})
|
||||
|
||||
if len(filterByPodStatus) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if orderByKey == inframonitoringtypes.DaemonSetNameAttrKey {
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
// Secondary filter: keep only status-matching groups. A missing metric
|
||||
// yields an empty statusCounts, so this correctly empties the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.DaemonSetNameAttrKey)
|
||||
return pageGroups, metadataMap, nil
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
queryNamesForOrderBy := orderByToDaemonSetsQueryNames[orderByKey]
|
||||
@@ -163,10 +191,19 @@ func (m *module) getTopDaemonSetGroupsAndMetadata(
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
|
||||
// Secondary filter: intersect ranked groups + metadata with the status keyset.
|
||||
// A missing metric yields an empty statusCounts, correctly emptying the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
|
||||
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
func (m *module) getDaemonSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDaemonSets) (map[string]map[string]string, error) {
|
||||
@@ -176,5 +213,9 @@ func (m *module) getDaemonSetsTableMetadata(ctx context.Context, orgID valuer.UU
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, daemonSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, daemonSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -82,20 +82,34 @@ func buildDeploymentRecords(
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopDeploymentGroupsAndMetadata concurrently fetches metadata + the ordering-metric
|
||||
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
|
||||
func (m *module) getTopDeploymentGroupsAndMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableDeployments,
|
||||
) ([]map[string]string, map[string]map[string]string, error) {
|
||||
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
|
||||
var (
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
filter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
)
|
||||
|
||||
orderByKey = req.OrderBy.Key.Name
|
||||
|
||||
// When filtering by pod status, resolve the full-scope status keyset
|
||||
// concurrently (pageGroups=nil spans all groups under the user filter) so it
|
||||
// can intersect metadata + ranked groups below.
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -104,12 +118,26 @@ func (m *module) getTopDeploymentGroupsAndMetadata(
|
||||
return err
|
||||
})
|
||||
|
||||
if len(filterByPodStatus) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if orderByKey == inframonitoringtypes.DeploymentNameAttrKey {
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
// Secondary filter: keep only status-matching groups. A missing metric
|
||||
// yields an empty statusCounts, so this correctly empties the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.DeploymentNameAttrKey)
|
||||
return pageGroups, metadataMap, nil
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
queryNamesForOrderBy := orderByToDeploymentsQueryNames[orderByKey]
|
||||
@@ -155,10 +183,19 @@ func (m *module) getTopDeploymentGroupsAndMetadata(
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
|
||||
// Secondary filter: intersect ranked groups + metadata with the status keyset.
|
||||
// A missing metric yields an empty statusCounts, correctly emptying the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
|
||||
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
func (m *module) getDeploymentsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDeployments) (map[string]map[string]string, error) {
|
||||
@@ -168,5 +205,9 @@ func (m *module) getDeploymentsTableMetadata(ctx context.Context, orgID valuer.U
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, deploymentsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, deploymentsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,33 @@ func compositeKeyFromLabels(labels map[string]string, groupBy []qbtypes.GroupByK
|
||||
return compositeKeyFromList(parts)
|
||||
}
|
||||
|
||||
// intersectMap returns the entries of m whose key is present in keep (a new
|
||||
// map). keep's value type is irrelevant — only its keys are read — so a
|
||||
// per-group counts map (already filtered by the SQL push-down) can be passed
|
||||
// directly. Used to trim metadataMap to the status-matching groups.
|
||||
func intersectMap[V any, K any](m map[string]V, keep map[string]K) map[string]V {
|
||||
out := make(map[string]V, len(m))
|
||||
for k, v := range m {
|
||||
if _, ok := keep[k]; ok {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// intersectRankedGroups returns the ranked groups whose compositeKey is present
|
||||
// in keep, preserving order. Keeps status-unmatched groups out of the ranked
|
||||
// page slots.
|
||||
func intersectRankedGroups[K any](groups []rankedGroup, keep map[string]K) []rankedGroup {
|
||||
out := make([]rankedGroup, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
if _, ok := keep[g.compositeKey]; ok {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseAndSortGroups extracts group label maps from a ScalarData response and
|
||||
// sorts them by the ranking query's aggregation value.
|
||||
func parseAndSortGroups(
|
||||
@@ -850,8 +877,10 @@ func (m *module) getPerGroupDistinctCounts(
|
||||
valueExpr = fmt.Sprintf("(%s)", strings.Join(parts, ", "))
|
||||
}
|
||||
|
||||
// Prefix the alias so it never collides with a groupBy col alias
|
||||
// (e.g. clusters grouped by k8s.node.name, which is also counted).
|
||||
selectCols = append(selectCols,
|
||||
fmt.Sprintf("uniqExactIf(%s, %s != '') AS %s", valueExpr, extract, quoteIdentifier(attr)),
|
||||
fmt.Sprintf("uniqExactIf(%s, %s != '') AS %s", valueExpr, extract, quoteIdentifier(fmt.Sprintf("__count_%s", attr))),
|
||||
)
|
||||
}
|
||||
sb.Select(selectCols...)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func groupByKey(name string) qbtypes.GroupByKey {
|
||||
@@ -88,10 +89,7 @@ func TestIsKeyInGroupByAttrs(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := isKeyInGroupByAttrs(tt.groupByAttrs, tt.key)
|
||||
if got != tt.expectedFound {
|
||||
t.Errorf("isKeyInGroupByAttrs(%v, %q) = %v, want %v",
|
||||
tt.groupByAttrs, tt.key, got, tt.expectedFound)
|
||||
}
|
||||
assert.Equal(t, tt.expectedFound, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -156,10 +154,7 @@ func TestMergeFilterExpressions(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mergeFilterExpressions(tt.queryFilterExpr, tt.reqFilterExpr)
|
||||
if got != tt.expected {
|
||||
t.Errorf("mergeFilterExpressions(%q, %q) = %q, want %q",
|
||||
tt.queryFilterExpr, tt.reqFilterExpr, got, tt.expected)
|
||||
}
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -205,10 +200,7 @@ func TestCompositeKeyFromList(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compositeKeyFromList(tt.parts)
|
||||
if got != tt.expected {
|
||||
t.Errorf("compositeKeyFromList(%v) = %q, want %q",
|
||||
tt.parts, got, tt.expected)
|
||||
}
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -376,10 +368,81 @@ func TestCompositeKeyFromLabels(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := compositeKeyFromLabels(tt.labels, tt.groupBy)
|
||||
if got != tt.expected {
|
||||
t.Errorf("compositeKeyFromLabels(%v, %v) = %q, want %q",
|
||||
tt.labels, tt.groupBy, got, tt.expected)
|
||||
}
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntersectMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
m map[string]int
|
||||
keep map[string]podStatusCounts
|
||||
expected map[string]int
|
||||
}{
|
||||
{
|
||||
name: "keep subset",
|
||||
m: map[string]int{"a": 1, "b": 2, "c": 3},
|
||||
keep: map[string]podStatusCounts{"a": {}, "c": {}},
|
||||
expected: map[string]int{"a": 1, "c": 3},
|
||||
},
|
||||
{
|
||||
name: "empty keep drops everything",
|
||||
m: map[string]int{"a": 1, "b": 2},
|
||||
keep: map[string]podStatusCounts{},
|
||||
expected: map[string]int{},
|
||||
},
|
||||
{
|
||||
name: "keep key absent from m is ignored",
|
||||
m: map[string]int{"a": 1},
|
||||
keep: map[string]podStatusCounts{"a": {}, "z": {}},
|
||||
expected: map[string]int{"a": 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := intersectMap(tt.m, tt.keep)
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntersectRankedGroups(t *testing.T) {
|
||||
groups := []rankedGroup{
|
||||
{compositeKey: "a", value: 3},
|
||||
{compositeKey: "b", value: 2},
|
||||
{compositeKey: "c", value: 1},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
groups []rankedGroup
|
||||
keep map[string]podStatusCounts
|
||||
expected []string // compositeKeys in order
|
||||
}{
|
||||
{
|
||||
name: "preserves order, drops non-matching",
|
||||
groups: groups,
|
||||
keep: map[string]podStatusCounts{"a": {}, "c": {}},
|
||||
expected: []string{"a", "c"},
|
||||
},
|
||||
{
|
||||
name: "empty keep drops all",
|
||||
groups: groups,
|
||||
keep: map[string]podStatusCounts{},
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := intersectRankedGroups(tt.groups, tt.keep)
|
||||
gotKeys := make([]string, 0, len(got))
|
||||
for _, g := range got {
|
||||
gotKeys = append(gotKeys, g.compositeKey)
|
||||
}
|
||||
assert.Equal(t, tt.expected, gotKeys)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,20 +90,34 @@ func buildJobRecords(
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopJobGroupsAndMetadata concurrently fetches metadata + the ordering-metric
|
||||
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
|
||||
func (m *module) getTopJobGroupsAndMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableJobs,
|
||||
) ([]map[string]string, map[string]map[string]string, error) {
|
||||
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
|
||||
var (
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
filter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
)
|
||||
|
||||
orderByKey = req.OrderBy.Key.Name
|
||||
|
||||
// When filtering by pod status, resolve the full-scope status keyset
|
||||
// concurrently (pageGroups=nil spans all groups under the user filter) so it
|
||||
// can intersect metadata + ranked groups below.
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -112,12 +126,26 @@ func (m *module) getTopJobGroupsAndMetadata(
|
||||
return err
|
||||
})
|
||||
|
||||
if len(filterByPodStatus) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if orderByKey == inframonitoringtypes.JobNameAttrKey {
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
// Secondary filter: keep only status-matching groups. A missing metric
|
||||
// yields an empty statusCounts, so this correctly empties the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.JobNameAttrKey)
|
||||
return pageGroups, metadataMap, nil
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
queryNamesForOrderBy := orderByToJobsQueryNames[orderByKey]
|
||||
@@ -163,10 +191,19 @@ func (m *module) getTopJobGroupsAndMetadata(
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
|
||||
// Secondary filter: intersect ranked groups + metadata with the status keyset.
|
||||
// A missing metric yields an empty statusCounts, correctly emptying the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
|
||||
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
func (m *module) getJobsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (map[string]map[string]string, error) {
|
||||
@@ -176,5 +213,9 @@ func (m *module) getJobsTableMetadata(ctx context.Context, orgID valuer.UUID, re
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, jobsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, jobsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -286,11 +286,36 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pageGroups, metadataMap, err := m.getTopPodGroupsAndMetadata(ctx, orgID, req)
|
||||
var (
|
||||
filterExpr string
|
||||
podFilter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
restartCounts map[string]int64
|
||||
)
|
||||
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
podFilter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
// getTopPodGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
|
||||
// status when filtering) concurrently, intersecting metadata/ranked groups
|
||||
// against the status keyset. It returns the keyset + its warning.
|
||||
pageGroups, metadataMap, statusCounts, statusWarning, err := m.getTopPodGroupsAndMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required metric missing while filtering: surface the warning + empty result.
|
||||
if len(filterByPodStatus) != 0 && statusWarning != nil {
|
||||
resp.Warning = statusWarning
|
||||
resp.Records = []inframonitoringtypes.PodRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
@@ -298,20 +323,8 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newPodsTableListQuery())
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
restartCounts map[string]int64
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -321,14 +334,18 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
restartCounts, err = m.getPerGroupPodRestartCounts(gCtx, orgID, req.Start, req.End, podFilter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
// When filtering, statusCounts already holds the full-scope map (a superset
|
||||
// of the page); otherwise compute it page-scoped here.
|
||||
if len(filterByPodStatus) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, podFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
@@ -379,11 +396,37 @@ func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pageGroups, metadataMap, err := m.getTopContainerGroupsAndMetadata(ctx, orgID, req)
|
||||
var (
|
||||
filterExpr string
|
||||
containerFilter *qbtypes.Filter
|
||||
filterByContainerStatus []inframonitoringtypes.ContainerStatus
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
restartCounts map[string]int64
|
||||
readyCounts map[string]containerReadyCounts
|
||||
)
|
||||
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
containerFilter = &req.Filter.Filter
|
||||
filterByContainerStatus = req.Filter.FilterByContainerStatus
|
||||
}
|
||||
|
||||
// getTopContainerGroupsAndMetadata fetches metadata + ranking (+ full-scope
|
||||
// container status when filtering) concurrently, intersecting metadata/ranked
|
||||
// groups against the status keyset. It returns the keyset + its warning.
|
||||
pageGroups, metadataMap, statusCounts, statusWarning, err := m.getTopContainerGroupsAndMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required metric missing while filtering: surface the warning + empty result.
|
||||
if len(filterByContainerStatus) != 0 && statusWarning != nil {
|
||||
resp.Warning = statusWarning
|
||||
resp.Records = []inframonitoringtypes.ContainerRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
@@ -391,21 +434,8 @@ func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newContainersTableListQuery())
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
statusCounts map[string]containerStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
restartCounts map[string]int64
|
||||
readyCounts map[string]containerReadyCounts
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -415,19 +445,23 @@ func (m *module) ListContainers(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
restartCounts, err = m.getPerGroupContainerRestartCounts(gCtx, orgID, req.Start, req.End, containerFilter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
restartCounts, err = m.getPerGroupContainerRestartCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
readyCounts, err = m.getPerGroupContainerReadyCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
readyCounts, err = m.getPerGroupContainerReadyCounts(gCtx, orgID, req.Start, req.End, containerFilter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
// When filtering, statusCounts already holds the full-scope map (a superset
|
||||
// of the page); otherwise compute it page-scoped here.
|
||||
if len(filterByContainerStatus) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupContainerStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, containerFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
@@ -478,11 +512,37 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pageGroups, metadataMap, err := m.getTopNodeGroupsAndMetadata(ctx, orgID, req)
|
||||
var (
|
||||
filterExpr string
|
||||
nodeFilter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
filterByNodeReadiness []inframonitoringtypes.NodeCondition
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
)
|
||||
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
nodeFilter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
|
||||
}
|
||||
|
||||
// getTopNodeGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
|
||||
// status / node readiness when filtering) concurrently, intersecting
|
||||
// metadata/ranked groups against the keysets. It returns the keysets + warning.
|
||||
pageGroups, metadataMap, podStatusCounts, podStatusWarning, nodeConditionCounts, err := m.getTopNodeGroupsAndMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required metric missing while filtering: surface the warning + empty result.
|
||||
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
|
||||
resp.Warning = podStatusWarning
|
||||
resp.Records = []inframonitoringtypes.NodeRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
@@ -490,20 +550,8 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
nodeConditionCounts map[string]nodeConditionCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -511,16 +559,24 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
// When filtering by readiness, nodeConditionCounts already holds the full-scope
|
||||
// map (a superset of the page); otherwise compute it page-scoped here.
|
||||
if len(filterByNodeReadiness) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, nodeFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
// When filtering by pod status, podStatusCounts already holds the full-scope
|
||||
// map; otherwise compute it page-scoped here.
|
||||
if len(filterByPodStatus) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, nodeFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
@@ -571,11 +627,36 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pageGroups, metadataMap, err := m.getTopNamespaceGroupsAndMetadata(ctx, orgID, req)
|
||||
var (
|
||||
filterExpr string
|
||||
namespaceFilter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
resourceCounts map[string]map[string]int64
|
||||
)
|
||||
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
namespaceFilter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
// getTopNamespaceGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
|
||||
// status when filtering) concurrently, intersecting metadata/ranked groups
|
||||
// against the status keyset. It returns the keyset + its warning.
|
||||
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopNamespaceGroupsAndMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required metric missing while filtering: surface the warning + empty result.
|
||||
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
|
||||
resp.Warning = podStatusWarning
|
||||
resp.Records = []inframonitoringtypes.NamespaceRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
@@ -583,20 +664,8 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNamespacesTableListQuery())
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
resourceCounts map[string]map[string]int64
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -606,14 +675,18 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups, namespaceCountAttrKeys, namespacesMetricNamesListForCounts)
|
||||
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, namespaceFilter, req.GroupBy, pageGroups, namespaceCountAttrKeys, namespacesMetricNamesListForCounts)
|
||||
return err
|
||||
})
|
||||
// When filtering, podStatusCounts already holds the full-scope map (a superset
|
||||
// of the page); otherwise compute it page-scoped here.
|
||||
if len(filterByPodStatus) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, namespaceFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
@@ -663,11 +736,39 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pageGroups, metadataMap, err := m.getTopClusterGroupsAndMetadata(ctx, orgID, req)
|
||||
var (
|
||||
filterExpr string
|
||||
clusterFilter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
filterByNodeReadiness []inframonitoringtypes.NodeCondition
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
nodeConditionCountsMap map[string]nodeConditionCounts
|
||||
resourceCounts map[string]map[string]int64
|
||||
)
|
||||
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
clusterFilter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
|
||||
}
|
||||
|
||||
// getTopClusterGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
|
||||
// status / node readiness when filtering) concurrently, intersecting
|
||||
// metadata/ranked groups against the keysets. It returns the keysets + warning.
|
||||
pageGroups, metadataMap, podStatusCounts, podStatusWarning, nodeConditionCountsMap, err := m.getTopClusterGroupsAndMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required metric missing while filtering: surface the warning + empty result.
|
||||
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
|
||||
resp.Warning = podStatusWarning
|
||||
resp.Records = []inframonitoringtypes.ClusterRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
@@ -675,23 +776,8 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newClustersTableListQuery())
|
||||
|
||||
// With default groupBy [k8s.cluster.name], counts are bucketed per cluster;
|
||||
// with a custom groupBy, they aggregate across clusters in that group.
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
nodeConditionCountsMap map[string]nodeConditionCounts
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
resourceCounts map[string]map[string]int64
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -699,21 +785,29 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
// When filtering by readiness, nodeConditionCountsMap already holds the
|
||||
// full-scope map (a superset of the page); otherwise compute it page-scoped here.
|
||||
if len(filterByNodeReadiness) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, clusterFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCountsMap, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups, clusterCountAttrKeys, clusterMetricNamesListForCounts)
|
||||
resourceCounts, err = m.getPerGroupDistinctCounts(gCtx, orgID, req.Start, req.End, clusterFilter, req.GroupBy, pageGroups, clusterCountAttrKeys, clusterMetricNamesListForCounts)
|
||||
return err
|
||||
})
|
||||
// When filtering by pod status, podStatusCounts already holds the full-scope
|
||||
// map; otherwise compute it page-scoped here.
|
||||
if len(filterByPodStatus) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, clusterFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
@@ -827,7 +921,7 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
|
||||
// Bake the deployments base filter into req.Filter so all downstream helpers pick it up.
|
||||
if req.Filter == nil {
|
||||
req.Filter = &qbtypes.Filter{}
|
||||
req.Filter = &inframonitoringtypes.DeploymentFilter{}
|
||||
}
|
||||
req.Filter.Expression = mergeFilterExpressions(deploymentsBaseFilterExpr, req.Filter.Expression)
|
||||
|
||||
@@ -842,11 +936,35 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pageGroups, metadataMap, err := m.getTopDeploymentGroupsAndMetadata(ctx, orgID, req)
|
||||
var (
|
||||
filterExpr string
|
||||
deploymentFilter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
)
|
||||
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
deploymentFilter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
// getTopDeploymentGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
|
||||
// status when filtering) concurrently, intersecting metadata/ranked groups
|
||||
// against the status keyset. It returns the keyset + its warning.
|
||||
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopDeploymentGroupsAndMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required metric missing while filtering: surface the warning + empty result.
|
||||
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
|
||||
resp.Warning = podStatusWarning
|
||||
resp.Records = []inframonitoringtypes.DeploymentRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
@@ -854,19 +972,8 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDeploymentsTableListQuery())
|
||||
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -874,11 +981,15 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
// When filtering, podStatusCounts already holds the full-scope map (a superset
|
||||
// of the page); otherwise compute it page-scoped here.
|
||||
if len(filterByPodStatus) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, deploymentFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
@@ -919,7 +1030,7 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
|
||||
// Bake the workload base filter into req.Filter so all downstream helpers pick it up.
|
||||
if req.Filter == nil {
|
||||
req.Filter = &qbtypes.Filter{}
|
||||
req.Filter = &inframonitoringtypes.StatefulSetFilter{}
|
||||
}
|
||||
req.Filter.Expression = mergeFilterExpressions(statefulSetsBaseFilterExpr, req.Filter.Expression)
|
||||
|
||||
@@ -934,11 +1045,35 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pageGroups, metadataMap, err := m.getTopStatefulSetGroupsAndMetadata(ctx, orgID, req)
|
||||
var (
|
||||
filterExpr string
|
||||
statefulSetFilter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
)
|
||||
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
statefulSetFilter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
// getTopStatefulSetGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
|
||||
// status when filtering) concurrently, intersecting metadata/ranked groups
|
||||
// against the status keyset. It returns the keyset + its warning.
|
||||
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopStatefulSetGroupsAndMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required metric missing while filtering: surface the warning + empty result.
|
||||
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
|
||||
resp.Warning = podStatusWarning
|
||||
resp.Records = []inframonitoringtypes.StatefulSetRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
@@ -946,21 +1081,8 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newStatefulSetsTableListQuery())
|
||||
|
||||
// Pods owned by a StatefulSet carry k8s.statefulset.name as a resource attribute,
|
||||
// so default-groupBy gives per-statefulset status counts automatically.
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -968,11 +1090,15 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
// When filtering, podStatusCounts already holds the full-scope map (a superset
|
||||
// of the page); otherwise compute it page-scoped here.
|
||||
if len(filterByPodStatus) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, statefulSetFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
@@ -1013,7 +1139,7 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
|
||||
// Bake the jobs base filter into req.Filter so all downstream helpers pick it up.
|
||||
if req.Filter == nil {
|
||||
req.Filter = &qbtypes.Filter{}
|
||||
req.Filter = &inframonitoringtypes.JobFilter{}
|
||||
}
|
||||
req.Filter.Expression = mergeFilterExpressions(jobsBaseFilterExpr, req.Filter.Expression)
|
||||
|
||||
@@ -1028,11 +1154,35 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pageGroups, metadataMap, err := m.getTopJobGroupsAndMetadata(ctx, orgID, req)
|
||||
var (
|
||||
filterExpr string
|
||||
jobFilter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
)
|
||||
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
jobFilter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
// getTopJobGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
|
||||
// status when filtering) concurrently, intersecting metadata/ranked groups
|
||||
// against the status keyset. It returns the keyset + its warning.
|
||||
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopJobGroupsAndMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required metric missing while filtering: surface the warning + empty result.
|
||||
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
|
||||
resp.Warning = podStatusWarning
|
||||
resp.Records = []inframonitoringtypes.JobRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
@@ -1040,21 +1190,8 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newJobsTableListQuery())
|
||||
|
||||
// Pods owned by a Job carry k8s.job.name as a resource attribute, so default-groupBy
|
||||
// gives per-job status counts automatically.
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -1062,11 +1199,15 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
// When filtering, podStatusCounts already holds the full-scope map (a superset
|
||||
// of the page); otherwise compute it page-scoped here.
|
||||
if len(filterByPodStatus) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, jobFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
@@ -1107,7 +1248,7 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
|
||||
// Bake the workload base filter into req.Filter so all downstream helpers pick it up.
|
||||
if req.Filter == nil {
|
||||
req.Filter = &qbtypes.Filter{}
|
||||
req.Filter = &inframonitoringtypes.DaemonSetFilter{}
|
||||
}
|
||||
req.Filter.Expression = mergeFilterExpressions(daemonSetsBaseFilterExpr, req.Filter.Expression)
|
||||
|
||||
@@ -1122,11 +1263,35 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
pageGroups, metadataMap, err := m.getTopDaemonSetGroupsAndMetadata(ctx, orgID, req)
|
||||
var (
|
||||
filterExpr string
|
||||
daemonSetFilter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
)
|
||||
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
daemonSetFilter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
// getTopDaemonSetGroupsAndMetadata fetches metadata + ranking (+ full-scope pod
|
||||
// status when filtering) concurrently, intersecting metadata/ranked groups
|
||||
// against the status keyset. It returns the keyset + its warning.
|
||||
pageGroups, metadataMap, podStatusCounts, podStatusWarning, err := m.getTopDaemonSetGroupsAndMetadata(ctx, orgID, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Required metric missing while filtering: surface the warning + empty result.
|
||||
if len(filterByPodStatus) != 0 && podStatusWarning != nil {
|
||||
resp.Warning = podStatusWarning
|
||||
resp.Records = []inframonitoringtypes.DaemonSetRecord{}
|
||||
resp.Total = 0
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
resp.Total = len(metadataMap)
|
||||
|
||||
if len(pageGroups) == 0 {
|
||||
@@ -1134,21 +1299,8 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if req.Filter != nil {
|
||||
filterExpr = req.Filter.Expression
|
||||
}
|
||||
|
||||
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newDaemonSetsTableListQuery())
|
||||
|
||||
// Pods owned by a DaemonSet carry k8s.daemonset.name as a resource attribute,
|
||||
// so default-groupBy gives per-daemonset status counts automatically.
|
||||
var (
|
||||
queryResp *qbtypes.QueryRangeResponse
|
||||
podStatusCounts map[string]podStatusCounts
|
||||
podStatusWarning *qbtypes.QueryWarnData
|
||||
)
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -1156,11 +1308,15 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
|
||||
queryResp, err = m.querier.QueryRange(gCtx, orgID, fullQueryReq)
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, req.Filter, req.GroupBy, pageGroups)
|
||||
return err
|
||||
})
|
||||
// When filtering, podStatusCounts already holds the full-scope map (a superset
|
||||
// of the page); otherwise compute it page-scoped here.
|
||||
if len(filterByPodStatus) == 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
podStatusCounts, podStatusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, daemonSetFilter, req.GroupBy, pageGroups, nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -65,20 +65,34 @@ func buildNamespaceRecords(
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopNamespaceGroupsAndMetadata concurrently fetches metadata + the ordering-metric
|
||||
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
|
||||
func (m *module) getTopNamespaceGroupsAndMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableNamespaces,
|
||||
) ([]map[string]string, map[string]map[string]string, error) {
|
||||
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
|
||||
var (
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
filter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
)
|
||||
|
||||
orderByKey = req.OrderBy.Key.Name
|
||||
|
||||
// When filtering by pod status, resolve the full-scope status keyset
|
||||
// concurrently (pageGroups=nil spans all groups under the user filter) so it
|
||||
// can intersect metadata + ranked groups below.
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -87,12 +101,26 @@ func (m *module) getTopNamespaceGroupsAndMetadata(
|
||||
return err
|
||||
})
|
||||
|
||||
if len(filterByPodStatus) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if orderByKey == inframonitoringtypes.NamespaceNameAttrKey {
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
// Secondary filter: keep only status-matching groups. A missing metric
|
||||
// yields an empty statusCounts, so this correctly empties the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.NamespaceNameAttrKey)
|
||||
return pageGroups, metadataMap, nil
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
queryNamesForOrderBy := orderByToNamespacesQueryNames[orderByKey]
|
||||
@@ -138,10 +166,19 @@ func (m *module) getTopNamespaceGroupsAndMetadata(
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
|
||||
// Secondary filter: intersect ranked groups + metadata with the status keyset.
|
||||
// A missing metric yields an empty statusCounts, correctly emptying the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
|
||||
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
func (m *module) getNamespacesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (map[string]map[string]string, error) {
|
||||
@@ -151,5 +188,9 @@ func (m *module) getNamespacesTableMetadata(ctx context.Context, orgID valuer.UU
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, namespacesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, namespacesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
|
||||
@@ -92,20 +91,38 @@ func buildNodeRecords(
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopNodeGroupsAndMetadata concurrently fetches metadata + the ordering-metric
|
||||
// ranking (plus the full-scope pod-status / node-readiness keysets when filtering,
|
||||
// to intersect all).
|
||||
func (m *module) getTopNodeGroupsAndMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableNodes,
|
||||
) ([]map[string]string, map[string]map[string]string, error) {
|
||||
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, map[string]nodeConditionCounts, error) {
|
||||
|
||||
var (
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
nodeConditionCounts map[string]nodeConditionCounts
|
||||
filter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
filterByNodeReadiness []inframonitoringtypes.NodeCondition
|
||||
)
|
||||
|
||||
orderByKey = req.OrderBy.Key.Name
|
||||
|
||||
// When filtering by pod status / node readiness, resolve the full-scope
|
||||
// keyset(s) concurrently (pageGroups=nil spans all groups under the user
|
||||
// filter) to intersect metadata + ranked groups below. Filters compose as AND.
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
filterByNodeReadiness = req.Filter.FilterByNodeReadiness
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -114,12 +131,37 @@ func (m *module) getTopNodeGroupsAndMetadata(
|
||||
return err
|
||||
})
|
||||
|
||||
if len(filterByPodStatus) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if len(filterByNodeReadiness) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
nodeConditionCounts, err = m.getPerGroupNodeConditionCounts(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByNodeReadiness)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if orderByKey == inframonitoringtypes.NodeNameAttrKey {
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
// Secondary filter: keep only status/readiness-matching groups. A missing
|
||||
// metric yields an empty statusCounts, so this correctly empties the result
|
||||
// (the caller also surfaces the warning). Filters compose as AND.
|
||||
if len(filterByPodStatus) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
if len(filterByNodeReadiness) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
|
||||
}
|
||||
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.NodeNameAttrKey)
|
||||
return pageGroups, metadataMap, nil
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
|
||||
}
|
||||
|
||||
queryNamesForOrderBy := orderByToNodesQueryNames[orderByKey]
|
||||
@@ -165,10 +207,23 @@ func (m *module) getTopNodeGroupsAndMetadata(
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
|
||||
// Secondary filter: intersect ranked groups + metadata with the status/readiness
|
||||
// keyset. A missing metric yields an empty keyset, correctly emptying the result
|
||||
// (the caller also surfaces the warning). Filters compose as AND.
|
||||
if len(filterByPodStatus) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
if len(filterByNodeReadiness) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, nodeConditionCounts)
|
||||
metadataMap = intersectMap(metadataMap, nodeConditionCounts)
|
||||
}
|
||||
|
||||
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nodeConditionCounts, nil
|
||||
}
|
||||
|
||||
func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
|
||||
@@ -178,7 +233,11 @@ func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, r
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
// getPerGroupNodeConditionCounts computes per-group node counts bucketed by each
|
||||
@@ -192,6 +251,24 @@ func (m *module) getNodesTableMetadata(ctx context.Context, orgID valuer.UUID, r
|
||||
// countNodesPerCondition: per-group uniqExactIf into ready/not_ready buckets.
|
||||
//
|
||||
// Groups absent from the result map have implicit zero counts (caller default).
|
||||
// applyNodeReadinessFilter adds the readiness push-down (condition_value IN (...))
|
||||
// to the outer count builder. condition_value is numeric (1=Ready, 0=NotReady), so
|
||||
// we map each requested enum to its int. No-op when the requested set is empty.
|
||||
func applyNodeReadinessFilter(cb *sqlbuilder.SelectBuilder, filterByNodeReadiness []inframonitoringtypes.NodeCondition) {
|
||||
if len(filterByNodeReadiness) == 0 {
|
||||
return
|
||||
}
|
||||
nums := make([]int, len(filterByNodeReadiness))
|
||||
for i, c := range filterByNodeReadiness {
|
||||
v := inframonitoringtypes.NodeConditionNumNotReady
|
||||
if c == inframonitoringtypes.NodeConditionReady {
|
||||
v = inframonitoringtypes.NodeConditionNumReady
|
||||
}
|
||||
nums[i] = v
|
||||
}
|
||||
cb.Where(cb.In("condition_value", sqlbuilder.List(nums)))
|
||||
}
|
||||
|
||||
func (m *module) getPerGroupNodeConditionCounts(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
@@ -199,8 +276,11 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
filterByNodeReadiness []inframonitoringtypes.NodeCondition,
|
||||
) (map[string]nodeConditionCounts, error) {
|
||||
if len(pageGroups) == 0 || len(groupBy) == 0 {
|
||||
// Empty pageGroups means "span all under user filter", allowed only in
|
||||
// full-scope mode (filtering by readiness). Otherwise it's an empty page.
|
||||
if len(groupBy) == 0 || (len(pageGroups) == 0 && len(filterByNodeReadiness) == 0) {
|
||||
return map[string]nodeConditionCounts{}, nil
|
||||
}
|
||||
|
||||
@@ -288,11 +368,14 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS ready_count", inframonitoringtypes.NodeConditionNumReady),
|
||||
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS not_ready_count", inframonitoringtypes.NodeConditionNumNotReady),
|
||||
)
|
||||
countNodesPerConditionSQL := fmt.Sprintf(
|
||||
"SELECT %s FROM latest_condition_per_node GROUP BY %s",
|
||||
strings.Join(countNodesPerConditionSelectCols, ", "),
|
||||
strings.Join(countNodesPerConditionGroupBy, ", "),
|
||||
)
|
||||
// Outer count query. Built with sqlbuilder so the readiness push-down uses a
|
||||
// proper IN (keep only nodes whose readiness is in the requested set).
|
||||
countBuilder := sqlbuilder.NewSelectBuilder()
|
||||
countBuilder.Select(countNodesPerConditionSelectCols...)
|
||||
countBuilder.From("latest_condition_per_node")
|
||||
applyNodeReadinessFilter(countBuilder, filterByNodeReadiness)
|
||||
countBuilder.GroupBy(countNodesPerConditionGroupBy...)
|
||||
countNodesPerConditionSQL, countArgs := countBuilder.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// Combine CTEs + outer.
|
||||
cteFragments := []string{
|
||||
@@ -300,7 +383,7 @@ func (m *module) getPerGroupNodeConditionCounts(
|
||||
fmt.Sprintf("latest_condition_per_node AS (%s)", latestConditionPerNodeSQL),
|
||||
}
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + countNodesPerConditionSQL
|
||||
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, nil)
|
||||
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, countArgs)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestApplyNodeReadinessFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
readiness []inframonitoringtypes.NodeCondition
|
||||
wantWhere bool
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "empty set yields no clause",
|
||||
readiness: nil,
|
||||
wantWhere: false,
|
||||
wantArgs: nil,
|
||||
},
|
||||
{
|
||||
name: "ready maps to 1 via IN",
|
||||
readiness: []inframonitoringtypes.NodeCondition{inframonitoringtypes.NodeConditionReady},
|
||||
wantWhere: true,
|
||||
wantArgs: []any{inframonitoringtypes.NodeConditionNumReady},
|
||||
},
|
||||
{
|
||||
name: "not_ready maps to 0 via IN",
|
||||
readiness: []inframonitoringtypes.NodeCondition{inframonitoringtypes.NodeConditionNotReady},
|
||||
wantWhere: true,
|
||||
wantArgs: []any{inframonitoringtypes.NodeConditionNumNotReady},
|
||||
},
|
||||
{
|
||||
name: "multiple conditions map to their ints via IN",
|
||||
readiness: []inframonitoringtypes.NodeCondition{
|
||||
inframonitoringtypes.NodeConditionReady,
|
||||
inframonitoringtypes.NodeConditionNotReady,
|
||||
},
|
||||
wantWhere: true,
|
||||
wantArgs: []any{inframonitoringtypes.NodeConditionNumReady, inframonitoringtypes.NodeConditionNumNotReady},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cb := sqlbuilder.NewSelectBuilder()
|
||||
cb.Select("node_name")
|
||||
cb.From("latest_condition_per_node")
|
||||
applyNodeReadinessFilter(cb, tt.readiness)
|
||||
sql, args := cb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
hasWhere := strings.Contains(sql, "condition_value IN (")
|
||||
assert.Equal(t, tt.wantWhere, hasWhere)
|
||||
if len(tt.wantArgs) == 0 {
|
||||
assert.Empty(t, args)
|
||||
} else {
|
||||
assert.Equal(t, tt.wantArgs, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -146,24 +146,34 @@ func buildPodRecords(
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopPodGroupsAndMetadata fetches the group metadata and the ordering-metric
|
||||
// ranking concurrently, then pages the ranked groups, backfilling from metadata
|
||||
// when the page extends past the metric-ranked groups. Returns the page of
|
||||
// groups and the metadata map (needed by the caller for Total and records).
|
||||
// getTopPodGroupsAndMetadata concurrently fetches metadata + the ordering-metric
|
||||
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
|
||||
func (m *module) getTopPodGroupsAndMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostablePods,
|
||||
) ([]map[string]string, map[string]map[string]string, error) {
|
||||
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
|
||||
var (
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
filter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
)
|
||||
|
||||
orderByKey = req.OrderBy.Key.Name
|
||||
|
||||
// When filtering by pod status, resolve the full-scope status keyset
|
||||
// concurrently (pageGroups=nil spans all groups under the user filter) so it
|
||||
// can intersect metadata + ranked groups below.
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -172,12 +182,26 @@ func (m *module) getTopPodGroupsAndMetadata(
|
||||
return err
|
||||
})
|
||||
|
||||
if len(filterByPodStatus) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if orderByKey == inframonitoringtypes.PodNameAttrKey {
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
// Secondary filter: keep only status-matching groups. A missing metric
|
||||
// yields an empty statusCounts, so this correctly empties the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.PodNameAttrKey)
|
||||
return pageGroups, metadataMap, nil
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
queryNamesForOrderBy := orderByToPodsQueryNames[orderByKey]
|
||||
@@ -223,10 +247,19 @@ func (m *module) getTopPodGroupsAndMetadata(
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
|
||||
// Secondary filter: intersect ranked groups + metadata with the status keyset.
|
||||
// A missing metric yields an empty statusCounts, correctly emptying the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
|
||||
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
func (m *module) getPodsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (map[string]map[string]string, error) {
|
||||
@@ -236,7 +269,11 @@ func (m *module) getPodsTableMetadata(ctx context.Context, orgID valuer.UUID, re
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, podsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, podsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
// getPerGroupPodStatusCountsWithReqMetricChecks gates getPerGroupPodStatusCounts
|
||||
@@ -251,6 +288,7 @@ func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus,
|
||||
) (map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
present, err := m.getMetricsExistence(ctx, podStatusMetricNamesList)
|
||||
if err != nil {
|
||||
@@ -276,13 +314,28 @@ func (m *module) getPerGroupPodStatusCountsWithReqMetricChecks(
|
||||
return map[string]podStatusCounts{}, warning, nil
|
||||
}
|
||||
|
||||
counts, err := m.getPerGroupPodStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups)
|
||||
counts, err := m.getPerGroupPodStatusCounts(ctx, orgID, start, end, filter, groupBy, pageGroups, filterByPodStatus)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return counts, nil, nil
|
||||
}
|
||||
|
||||
// applyPodStatusFilter adds the display-status push-down (lower(display_status)
|
||||
// IN (...)) to the outer count builder. valuer lowercases the wire value while
|
||||
// display_status is kubectl-cased, so we compare lower() on both. No-op when the
|
||||
// requested set is empty.
|
||||
func applyPodStatusFilter(cb *sqlbuilder.SelectBuilder, filterByPodStatus []inframonitoringtypes.PodStatus) {
|
||||
if len(filterByPodStatus) == 0 {
|
||||
return
|
||||
}
|
||||
vals := make([]string, len(filterByPodStatus))
|
||||
for i, s := range filterByPodStatus {
|
||||
vals[i] = s.StringValue()
|
||||
}
|
||||
cb.Where(cb.In("lower(display_status)", sqlbuilder.List(vals)))
|
||||
}
|
||||
|
||||
// getPerGroupPodStatusCounts computes per-group pod counts bucketed by each
|
||||
// pod's latest kubectl-style display status in the requested window. Caller
|
||||
// must ensure the required metrics exist (getPerGroupPodStatusCountsWithReqMetricChecks).
|
||||
@@ -303,13 +356,20 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
filter *qbtypes.Filter,
|
||||
groupBy []qbtypes.GroupByKey,
|
||||
pageGroups []map[string]string,
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus,
|
||||
) (map[string]podStatusCounts, error) {
|
||||
if len(pageGroups) == 0 || len(groupBy) == 0 {
|
||||
// return early if no group by or (no pagegroups provided plus no filterBystatus given for a full scan)
|
||||
if len(groupBy) == 0 || (len(pageGroups) == 0 && len(filterByPodStatus) == 0) {
|
||||
return map[string]podStatusCounts{}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
err error
|
||||
userFilterExpr string
|
||||
)
|
||||
|
||||
// Merge user filter with page-groups IN clauses.
|
||||
userFilterExpr := ""
|
||||
if filter != nil {
|
||||
userFilterExpr = filter.Expression
|
||||
}
|
||||
@@ -322,10 +382,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
// CTEs, and buildFilterClause hits the metadata store + parses the
|
||||
// expression, so we don't want to repeat it per CTE. AddWhereClause only
|
||||
// reads the clause, so the same instance is safe to attach to each builder.
|
||||
var (
|
||||
filterClause *sqlbuilder.WhereClause
|
||||
err error
|
||||
)
|
||||
|
||||
if mergedFilterExpr != "" {
|
||||
filterClause, err = m.buildFilterClause(ctx, orgID, &qbtypes.Filter{Expression: mergedFilterExpr}, start, end)
|
||||
if err != nil {
|
||||
@@ -540,11 +597,15 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
countGroupBy = append(countGroupBy, col)
|
||||
}
|
||||
countSelectCols = append(countSelectCols, statusCountCols...)
|
||||
countSQL := fmt.Sprintf(
|
||||
"SELECT %s FROM pod_status GROUP BY %s",
|
||||
strings.Join(countSelectCols, ", "),
|
||||
strings.Join(countGroupBy, ", "),
|
||||
)
|
||||
|
||||
// Outer count query. Built with sqlbuilder so the status push-down uses a
|
||||
// proper IN (keep only pods whose display status is in the requested set).
|
||||
countBuilder := sqlbuilder.NewSelectBuilder()
|
||||
countBuilder.Select(countSelectCols...)
|
||||
countBuilder.From("pod_status")
|
||||
applyPodStatusFilter(countBuilder, filterByPodStatus)
|
||||
countBuilder.GroupBy(countGroupBy...)
|
||||
countSQL, countArgs := countBuilder.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
// Combine CTEs + outer. Arg order mirrors CTE declaration order.
|
||||
cteFragments := []string{
|
||||
@@ -561,7 +622,7 @@ func (m *module) getPerGroupPodStatusCounts(
|
||||
phaseFpsArgs, phasePerPodArgs,
|
||||
podReasonFpsArgs, podReasonPerPodArgs,
|
||||
containerReasonFpsArgs, containerInnerArgs,
|
||||
}, nil)
|
||||
}, countArgs)
|
||||
|
||||
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
|
||||
if err != nil {
|
||||
|
||||
59
pkg/modules/inframonitoring/implinframonitoring/pods_test.go
Normal file
59
pkg/modules/inframonitoring/implinframonitoring/pods_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package implinframonitoring
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestApplyPodStatusFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statuses []inframonitoringtypes.PodStatus
|
||||
wantWhere bool
|
||||
wantArgs []any
|
||||
}{
|
||||
{
|
||||
name: "empty set yields no clause",
|
||||
statuses: nil,
|
||||
wantWhere: false,
|
||||
wantArgs: nil,
|
||||
},
|
||||
{
|
||||
name: "single status pushes lowercased arg via IN",
|
||||
statuses: []inframonitoringtypes.PodStatus{inframonitoringtypes.PodStatusRunning},
|
||||
wantWhere: true,
|
||||
wantArgs: []any{"running"},
|
||||
},
|
||||
{
|
||||
name: "multiple statuses push lowercased args via IN",
|
||||
statuses: []inframonitoringtypes.PodStatus{
|
||||
inframonitoringtypes.PodStatusRunning,
|
||||
inframonitoringtypes.PodStatusCrashLoopBackOff,
|
||||
},
|
||||
wantWhere: true,
|
||||
wantArgs: []any{"running", "crashloopbackoff"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cb := sqlbuilder.NewSelectBuilder()
|
||||
cb.Select("pod_uid")
|
||||
cb.From("pod_status")
|
||||
applyPodStatusFilter(cb, tt.statuses)
|
||||
sql, args := cb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
hasWhere := strings.Contains(sql, "lower(display_status) IN (")
|
||||
assert.Equal(t, tt.wantWhere, hasWhere)
|
||||
if len(tt.wantArgs) == 0 {
|
||||
assert.Empty(t, args)
|
||||
} else {
|
||||
assert.Equal(t, tt.wantArgs, args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -82,20 +82,34 @@ func buildStatefulSetRecords(
|
||||
return records
|
||||
}
|
||||
|
||||
// getTopStatefulSetGroupsAndMetadata concurrently fetches metadata + the ordering-metric
|
||||
// ranking (plus the full-scope pod-status keyset when filtering, to intersect both).
|
||||
func (m *module) getTopStatefulSetGroupsAndMetadata(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
req *inframonitoringtypes.PostableStatefulSets,
|
||||
) ([]map[string]string, map[string]map[string]string, error) {
|
||||
) ([]map[string]string, map[string]map[string]string, map[string]podStatusCounts, *qbtypes.QueryWarnData, error) {
|
||||
|
||||
var (
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
orderByKey string
|
||||
metadataMap map[string]map[string]string
|
||||
allMetricGroups []rankedGroup
|
||||
statusCounts map[string]podStatusCounts
|
||||
statusWarning *qbtypes.QueryWarnData
|
||||
filter *qbtypes.Filter
|
||||
filterByPodStatus []inframonitoringtypes.PodStatus
|
||||
)
|
||||
|
||||
orderByKey = req.OrderBy.Key.Name
|
||||
|
||||
// When filtering by pod status, resolve the full-scope status keyset
|
||||
// concurrently (pageGroups=nil spans all groups under the user filter) so it
|
||||
// can intersect metadata + ranked groups below.
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
filterByPodStatus = req.Filter.FilterByPodStatus
|
||||
}
|
||||
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
@@ -104,12 +118,26 @@ func (m *module) getTopStatefulSetGroupsAndMetadata(
|
||||
return err
|
||||
})
|
||||
|
||||
if len(filterByPodStatus) != 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
statusCounts, statusWarning, err = m.getPerGroupPodStatusCountsWithReqMetricChecks(gCtx, orgID, req.Start, req.End, filter, req.GroupBy, nil, filterByPodStatus)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
if orderByKey == inframonitoringtypes.StatefulSetNameAttrKey {
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
// Secondary filter: keep only status-matching groups. A missing metric
|
||||
// yields an empty statusCounts, so this correctly empties the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
pageGroups := inframonitoringtypes.PaginateMetadataByName(metadataMap, req.GroupBy, req.OrderBy.Direction, req.Offset, req.Limit, inframonitoringtypes.StatefulSetNameAttrKey)
|
||||
return pageGroups, metadataMap, nil
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
queryNamesForOrderBy := orderByToStatefulSetsQueryNames[orderByKey]
|
||||
@@ -155,10 +183,19 @@ func (m *module) getTopStatefulSetGroupsAndMetadata(
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
|
||||
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), metadataMap, nil
|
||||
// Secondary filter: intersect ranked groups + metadata with the status keyset.
|
||||
// A missing metric yields an empty statusCounts, correctly emptying the result
|
||||
// (the caller also surfaces the warning).
|
||||
if len(filterByPodStatus) != 0 {
|
||||
allMetricGroups = intersectRankedGroups(allMetricGroups, statusCounts)
|
||||
metadataMap = intersectMap(metadataMap, statusCounts)
|
||||
}
|
||||
|
||||
pageGroups := paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit)
|
||||
return pageGroups, metadataMap, statusCounts, statusWarning, nil
|
||||
}
|
||||
|
||||
func (m *module) getStatefulSetsTableMetadata(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableStatefulSets) (map[string]map[string]string, error) {
|
||||
@@ -168,5 +205,9 @@ func (m *module) getStatefulSetsTableMetadata(ctx context.Context, orgID valuer.
|
||||
nonGroupByAttrs = append(nonGroupByAttrs, key)
|
||||
}
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, statefulSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
|
||||
var filter *qbtypes.Filter
|
||||
if req.Filter != nil {
|
||||
filter = &req.Filter.Filter
|
||||
}
|
||||
return m.getMetadata(ctx, orgID, statefulSetsTableMetricNamesList, req.GroupBy, nonGroupByAttrs, filter, req.Start, req.End)
|
||||
}
|
||||
|
||||
@@ -39,26 +39,24 @@ type legacyExtraData struct {
|
||||
func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.PostableSavedView {
|
||||
var legacy legacyExtraData
|
||||
if v.ExtraData != "" {
|
||||
// Best-effort: malformed/older extraData shapes never fail the request
|
||||
// Best-effort: malformed/older extraData shapes never fail the request.
|
||||
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
|
||||
}
|
||||
|
||||
return savedviewtypes.PostableSavedView{
|
||||
GenerateName: true,
|
||||
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
|
||||
Data: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: v.Name,
|
||||
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
|
||||
Queries: v.CompositeQuery.Queries,
|
||||
SelectedFields: legacy.SelectColumns,
|
||||
Display: savedviewtypes.Display{
|
||||
MaxLines: legacy.MaxLines,
|
||||
FontSize: legacy.FontSize,
|
||||
Format: legacy.Format,
|
||||
Color: legacy.Color,
|
||||
},
|
||||
GenerateName: true,
|
||||
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: v.Name,
|
||||
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
|
||||
Queries: v.CompositeQuery.Queries,
|
||||
SelectedFields: legacy.SelectColumns,
|
||||
Display: savedviewtypes.Display{
|
||||
MaxLines: legacy.MaxLines,
|
||||
FontSize: legacy.FontSize,
|
||||
Format: legacy.Format,
|
||||
Color: legacy.Color,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -68,25 +66,23 @@ func newPostableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.Postable
|
||||
func newUpdatableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.UpdatableSavedView {
|
||||
var legacy legacyExtraData
|
||||
if v.ExtraData != "" {
|
||||
// Best-effort: malformed/older extraData shapes never fail the request
|
||||
// Best-effort: malformed/older extraData shapes never fail the request.
|
||||
_ = json.Unmarshal([]byte(v.ExtraData), &legacy)
|
||||
}
|
||||
|
||||
return savedviewtypes.UpdatableSavedView{
|
||||
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
|
||||
Data: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: v.Name,
|
||||
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
|
||||
Queries: v.CompositeQuery.Queries,
|
||||
SelectedFields: legacy.SelectColumns,
|
||||
Display: savedviewtypes.Display{
|
||||
MaxLines: legacy.MaxLines,
|
||||
FontSize: legacy.FontSize,
|
||||
Format: legacy.Format,
|
||||
Color: legacy.Color,
|
||||
},
|
||||
Source: savedviewtypes.Source{String: valuer.NewString(v.SourcePage)},
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: v.Name,
|
||||
PanelType: savedviewtypes.PanelType{String: valuer.NewString(string(v.CompositeQuery.PanelType))},
|
||||
Queries: v.CompositeQuery.Queries,
|
||||
SelectedFields: legacy.SelectColumns,
|
||||
Display: savedviewtypes.Display{
|
||||
MaxLines: legacy.MaxLines,
|
||||
FontSize: legacy.FontSize,
|
||||
Format: legacy.Format,
|
||||
Color: legacy.Color,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -95,11 +91,11 @@ func newUpdatableSavedViewFromLegacyView(v *v3.SavedView) savedviewtypes.Updatab
|
||||
// newLegacyViewFromSavedView renders a v2 SavedView back into the v1 shape.
|
||||
func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, error) {
|
||||
extraData, err := json.Marshal(legacyExtraData{
|
||||
Color: v.Data.Spec.Display.Color,
|
||||
SelectColumns: v.Data.Spec.SelectedFields,
|
||||
Format: v.Data.Spec.Display.Format,
|
||||
MaxLines: v.Data.Spec.Display.MaxLines,
|
||||
FontSize: v.Data.Spec.Display.FontSize,
|
||||
Color: v.Spec.Display.Color,
|
||||
SelectColumns: v.Spec.SelectedFields,
|
||||
Format: v.Spec.Display.Format,
|
||||
MaxLines: v.Spec.Display.MaxLines,
|
||||
FontSize: v.Spec.Display.FontSize,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in marshalling extra data")
|
||||
@@ -107,17 +103,17 @@ func newLegacyViewFromSavedView(v *savedviewtypes.SavedView) (*v3.SavedView, err
|
||||
|
||||
return &v3.SavedView{
|
||||
ID: v.ID,
|
||||
Name: v.Data.Spec.DisplayName,
|
||||
Name: v.Spec.DisplayName,
|
||||
CreatedAt: v.CreatedAt,
|
||||
CreatedBy: v.CreatedBy,
|
||||
UpdatedAt: v.UpdatedAt,
|
||||
UpdatedBy: v.UpdatedBy,
|
||||
SourcePage: v.Source.StringValue(),
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
PanelType: v3.PanelType(v.Data.Spec.PanelType.StringValue()),
|
||||
PanelType: v3.PanelType(v.Spec.PanelType.StringValue()),
|
||||
// Saved views are only ever created from the explorer's builder mode.
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
Queries: v.Data.Spec.Queries,
|
||||
Queries: v.Spec.Queries,
|
||||
},
|
||||
ExtraData: string(extraData),
|
||||
}, nil
|
||||
@@ -156,7 +152,14 @@ func (handler *handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, newPostableSavedViewFromLegacyView(&view))
|
||||
postable := newPostableSavedViewFromLegacyView(&view)
|
||||
|
||||
if err := postable.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
uuid, err := handler.module.CreateView(ctx, claims.OrgID, postable)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
@@ -224,8 +227,14 @@ func (handler *handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpdateView(ctx, claims.OrgID, viewUUID, newUpdatableSavedViewFromLegacyView(&view))
|
||||
if err != nil {
|
||||
updatable := newUpdatableSavedViewFromLegacyView(&view)
|
||||
|
||||
if err := updatable.Validate(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := handler.module.UpdateView(ctx, claims.OrgID, viewUUID, updatable); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -42,13 +42,13 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
|
||||
|
||||
assert.Empty(t, postable.Name, "v1 has no slug concept -- name must always be generated")
|
||||
assert.True(t, postable.GenerateName, "v1 has no slug concept -- name must always be generated")
|
||||
assert.Equal(t, "my view", postable.Data.Spec.DisplayName)
|
||||
assert.Equal(t, "my view", postable.Spec.DisplayName)
|
||||
assert.Equal(t, savedviewtypes.SourceLogs, postable.Source)
|
||||
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion, postable.Data.SchemaVersion)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeGraph, postable.Data.Spec.PanelType)
|
||||
assert.Equal(t, legacy.CompositeQuery.Queries, postable.Data.Spec.Queries)
|
||||
assert.Equal(t, []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}, postable.Data.Spec.SelectedFields)
|
||||
assert.Equal(t, savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"}, postable.Data.Spec.Display)
|
||||
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion, postable.SchemaVersion)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeGraph, postable.Spec.PanelType)
|
||||
assert.Equal(t, legacy.CompositeQuery.Queries, postable.Spec.Queries)
|
||||
assert.Equal(t, []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}}, postable.Spec.SelectedFields)
|
||||
assert.Equal(t, savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"}, postable.Spec.Display)
|
||||
})
|
||||
|
||||
t.Run("empty extra data leaves display and selected fields zero-valued", func(t *testing.T) {
|
||||
@@ -64,8 +64,8 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
|
||||
|
||||
postable := newPostableSavedViewFromLegacyView(legacy)
|
||||
|
||||
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
|
||||
assert.Nil(t, postable.Data.Spec.SelectedFields)
|
||||
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
|
||||
assert.Nil(t, postable.Spec.SelectedFields)
|
||||
})
|
||||
|
||||
t.Run("malformed extra data is ignored, not an error", func(t *testing.T) {
|
||||
@@ -81,8 +81,25 @@ func TestNewPostableSavedViewFromLegacyView(t *testing.T) {
|
||||
|
||||
postable := newPostableSavedViewFromLegacyView(legacy)
|
||||
|
||||
assert.Equal(t, "malformed extra data", postable.Data.Spec.DisplayName)
|
||||
assert.Equal(t, savedviewtypes.Display{}, postable.Data.Spec.Display)
|
||||
assert.Equal(t, "malformed extra data", postable.Spec.DisplayName)
|
||||
assert.Equal(t, savedviewtypes.Display{}, postable.Spec.Display)
|
||||
})
|
||||
|
||||
t.Run("legacy validation gap: empty builderQueries map with no queries", func(t *testing.T) {
|
||||
legacy := &v3.SavedView{
|
||||
Name: "no real queries",
|
||||
SourcePage: "logs",
|
||||
CompositeQuery: &v3.CompositeQuery{
|
||||
PanelType: v3.PanelTypeGraph,
|
||||
QueryType: v3.QueryTypeBuilder,
|
||||
BuilderQueries: map[string]*v3.BuilderQuery{},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, legacy.Validate(), "the legacy CompositeQuery check is expected to miss this")
|
||||
|
||||
postable := newPostableSavedViewFromLegacyView(legacy)
|
||||
assert.Error(t, postable.Validate(), "the converted postable must catch what the legacy check missed")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,24 +116,22 @@ func TestNewUpdatableSavedViewFromLegacyView(t *testing.T) {
|
||||
|
||||
updatable := newUpdatableSavedViewFromLegacyView(legacy)
|
||||
|
||||
assert.Equal(t, "renamed view", updatable.Data.Spec.DisplayName)
|
||||
assert.Equal(t, "renamed view", updatable.Spec.DisplayName)
|
||||
assert.Equal(t, savedviewtypes.SourceTraces, updatable.Source)
|
||||
}
|
||||
|
||||
func TestNewLegacyViewFromSavedView(t *testing.T) {
|
||||
now := time.Now()
|
||||
savedView := &savedviewtypes.SavedView{
|
||||
Name: "my-view-abc123ef",
|
||||
Source: savedviewtypes.SourceLogs,
|
||||
Data: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: "my view",
|
||||
PanelType: savedviewtypes.PanelTypeGraph,
|
||||
Queries: testQueries(),
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
|
||||
Display: savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"},
|
||||
},
|
||||
Name: "my-view-abc123ef",
|
||||
Source: savedviewtypes.SourceLogs,
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: "my view",
|
||||
PanelType: savedviewtypes.PanelTypeGraph,
|
||||
Queries: testQueries(),
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
|
||||
Display: savedviewtypes.Display{MaxLines: 10, FontSize: "large", Format: "table", Color: "blue"},
|
||||
},
|
||||
}
|
||||
savedView.ID = valuer.GenerateUUID()
|
||||
@@ -129,7 +144,7 @@ func TestNewLegacyViewFromSavedView(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, savedView.ID, legacy.ID)
|
||||
assert.Equal(t, savedView.Data.Spec.DisplayName, legacy.Name)
|
||||
assert.Equal(t, savedView.Spec.DisplayName, legacy.Name)
|
||||
assert.Equal(t, savedView.CreatedAt, legacy.CreatedAt)
|
||||
assert.Equal(t, savedView.CreatedBy, legacy.CreatedBy)
|
||||
assert.Equal(t, savedView.UpdatedAt, legacy.UpdatedAt)
|
||||
@@ -137,20 +152,20 @@ func TestNewLegacyViewFromSavedView(t *testing.T) {
|
||||
assert.Equal(t, "logs", legacy.SourcePage)
|
||||
assert.Equal(t, v3.PanelTypeGraph, legacy.CompositeQuery.PanelType)
|
||||
assert.Equal(t, v3.QueryTypeBuilder, legacy.CompositeQuery.QueryType)
|
||||
assert.Equal(t, savedView.Data.Spec.Queries, legacy.CompositeQuery.Queries)
|
||||
assert.Equal(t, savedView.Spec.Queries, legacy.CompositeQuery.Queries)
|
||||
|
||||
var extra legacyExtraData
|
||||
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
|
||||
assert.Equal(t, "blue", extra.Color)
|
||||
assert.Equal(t, savedView.Data.Spec.SelectedFields, extra.SelectColumns)
|
||||
assert.Equal(t, savedView.Spec.SelectedFields, extra.SelectColumns)
|
||||
assert.Equal(t, "table", extra.Format)
|
||||
assert.Equal(t, 10, extra.MaxLines)
|
||||
assert.Equal(t, "large", extra.FontSize)
|
||||
}
|
||||
|
||||
func TestNewLegacyViewsFromSavedViews(t *testing.T) {
|
||||
a := &savedviewtypes.SavedView{Name: "a-slug", Source: savedviewtypes.SourceLogs, Data: savedviewtypes.SavedViewData{Spec: savedviewtypes.SavedViewSpec{DisplayName: "a", PanelType: savedviewtypes.PanelTypeGraph, Queries: testQueries()}}}
|
||||
b := &savedviewtypes.SavedView{Name: "b-slug", Source: savedviewtypes.SourceTraces, Data: savedviewtypes.SavedViewData{Spec: savedviewtypes.SavedViewSpec{DisplayName: "b", PanelType: savedviewtypes.PanelTypeTable, Queries: testQueries()}}}
|
||||
a := &savedviewtypes.SavedView{Name: "a-slug", Source: savedviewtypes.SourceLogs, Spec: savedviewtypes.SavedViewSpec{DisplayName: "a", PanelType: savedviewtypes.PanelTypeGraph, Queries: testQueries()}}
|
||||
b := &savedviewtypes.SavedView{Name: "b-slug", Source: savedviewtypes.SourceTraces, Spec: savedviewtypes.SavedViewSpec{DisplayName: "b", PanelType: savedviewtypes.PanelTypeTable, Queries: testQueries()}}
|
||||
|
||||
legacyViews, err := newLegacyViewsFromSavedViews([]*savedviewtypes.SavedView{a, b})
|
||||
require.NoError(t, err)
|
||||
@@ -167,17 +182,15 @@ func TestNewLegacyViewsFromSavedViews(t *testing.T) {
|
||||
// slug (Name) is deliberately NOT part of this contract -- v1 never sees it.
|
||||
func TestLegacyViewRoundTrip(t *testing.T) {
|
||||
original := &savedviewtypes.SavedView{
|
||||
Name: "round-trip-abc123ef",
|
||||
Source: savedviewtypes.SourceMetrics,
|
||||
Data: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: "round trip",
|
||||
PanelType: savedviewtypes.PanelTypeTable,
|
||||
Queries: testQueries(),
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
|
||||
Display: savedviewtypes.Display{MaxLines: 5, FontSize: "small", Format: "list", Color: "red"},
|
||||
},
|
||||
Name: "round-trip-abc123ef",
|
||||
Source: savedviewtypes.SourceMetrics,
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: "round trip",
|
||||
PanelType: savedviewtypes.PanelTypeTable,
|
||||
Queries: testQueries(),
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
|
||||
Display: savedviewtypes.Display{MaxLines: 5, FontSize: "small", Format: "list", Color: "red"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -188,10 +201,37 @@ func TestLegacyViewRoundTrip(t *testing.T) {
|
||||
|
||||
assert.Empty(t, roundTripped.Name)
|
||||
assert.True(t, roundTripped.GenerateName)
|
||||
assert.Equal(t, original.Data.Spec.DisplayName, roundTripped.Data.Spec.DisplayName)
|
||||
assert.Equal(t, original.Spec.DisplayName, roundTripped.Spec.DisplayName)
|
||||
assert.Equal(t, original.Source, roundTripped.Source)
|
||||
assert.Equal(t, original.Data.Spec.PanelType, roundTripped.Data.Spec.PanelType)
|
||||
assert.Equal(t, original.Data.Spec.Queries, roundTripped.Data.Spec.Queries)
|
||||
assert.Equal(t, original.Data.Spec.SelectedFields, roundTripped.Data.Spec.SelectedFields)
|
||||
assert.Equal(t, original.Data.Spec.Display, roundTripped.Data.Spec.Display)
|
||||
assert.Equal(t, original.Spec.PanelType, roundTripped.Spec.PanelType)
|
||||
assert.Equal(t, original.Spec.Queries, roundTripped.Spec.Queries)
|
||||
assert.Equal(t, original.Spec.SelectedFields, roundTripped.Spec.SelectedFields)
|
||||
assert.Equal(t, original.Spec.Display, roundTripped.Spec.Display)
|
||||
}
|
||||
|
||||
func TestLegacyViewRoundTrip_EmptySelectedFieldsAndDisplay(t *testing.T) {
|
||||
original := &savedviewtypes.SavedView{
|
||||
Name: "round-trip-empty-abc123ef",
|
||||
Source: savedviewtypes.SourceMetrics,
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: "round trip empty",
|
||||
PanelType: savedviewtypes.PanelTypeTable,
|
||||
Queries: testQueries(),
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
|
||||
Display: savedviewtypes.Display{},
|
||||
},
|
||||
}
|
||||
|
||||
legacy, err := newLegacyViewFromSavedView(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
var extra legacyExtraData
|
||||
require.NoError(t, json.Unmarshal([]byte(legacy.ExtraData), &extra))
|
||||
assert.Nil(t, extra.SelectColumns, "omitempty drops an empty selectColumns from extraData entirely")
|
||||
|
||||
roundTripped := newPostableSavedViewFromLegacyView(legacy)
|
||||
|
||||
assert.Empty(t, roundTripped.Spec.SelectedFields, "empty, not necessarily non-nil, on this leg of the round trip")
|
||||
assert.Equal(t, savedviewtypes.Display{}, roundTripped.Spec.Display)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ func NewModule(store savedviewtypes.Store) savedview.Module {
|
||||
}
|
||||
|
||||
func (module *module) GetViewsForFilters(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.SavedView, error) {
|
||||
return module.store.List(ctx, orgID, source, name)
|
||||
storables, err := module.store.List(ctx, orgID, source, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return savedviewtypes.NewSavedViewsFromStorableSavedViews(storables), nil
|
||||
}
|
||||
|
||||
func (module *module) CreateView(ctx context.Context, orgID string, view savedviewtypes.PostableSavedView) (valuer.UUID, error) {
|
||||
@@ -30,14 +34,19 @@ func (module *module) CreateView(ctx context.Context, orgID string, view savedvi
|
||||
|
||||
dbView := view.ToSavedView(orgID, claims.Email)
|
||||
|
||||
if err := module.store.Create(ctx, dbView); err != nil {
|
||||
if err := module.store.Create(ctx, savedviewtypes.NewStorableSavedView(dbView)); err != nil {
|
||||
return valuer.UUID{}, err
|
||||
}
|
||||
return dbView.ID, nil
|
||||
}
|
||||
|
||||
func (module *module) GetView(ctx context.Context, orgID string, uuid valuer.UUID) (*savedviewtypes.SavedView, error) {
|
||||
return module.store.Get(ctx, orgID, uuid)
|
||||
storable, err := module.store.Get(ctx, orgID, uuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storable.ToSavedView(), nil
|
||||
}
|
||||
|
||||
func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.UUID, view savedviewtypes.UpdatableSavedView) error {
|
||||
@@ -46,7 +55,8 @@ func (module *module) UpdateView(ctx context.Context, orgID string, uuid valuer.
|
||||
return errors.NewInternalf(errors.CodeInternal, "error in getting email from context")
|
||||
}
|
||||
|
||||
return module.store.Update(ctx, view.ToSavedView(uuid, orgID, claims.Email))
|
||||
dbView := view.ToSavedView(uuid, orgID, claims.Email)
|
||||
return module.store.Update(ctx, savedviewtypes.NewStorableSavedView(dbView))
|
||||
}
|
||||
|
||||
func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.UUID) error {
|
||||
@@ -54,10 +64,10 @@ func (module *module) DeleteView(ctx context.Context, orgID string, uuid valuer.
|
||||
}
|
||||
|
||||
func (module *module) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
|
||||
savedViews, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.Source{}, "")
|
||||
storables, err := module.store.List(ctx, orgID.StringValue(), savedviewtypes.Source{}, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return savedviewtypes.NewStatsFromSavedViews(savedViews), nil
|
||||
return savedviewtypes.NewStatsFromStorableSavedViews(storables), nil
|
||||
}
|
||||
|
||||
@@ -28,24 +28,22 @@ func newTestStore() (savedview.Module, *savedviewtypestest.StoreTest) {
|
||||
|
||||
func testPostableSavedView(name string, source savedviewtypes.Source) savedviewtypes.PostableSavedView {
|
||||
return savedviewtypes.PostableSavedView{
|
||||
Name: name,
|
||||
Source: source,
|
||||
Data: savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: name,
|
||||
PanelType: savedviewtypes.PanelTypeGraph,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
||||
},
|
||||
Name: name,
|
||||
Source: source,
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: name,
|
||||
PanelType: savedviewtypes.PanelTypeGraph,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
||||
},
|
||||
},
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
|
||||
},
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -53,8 +51,9 @@ func testPostableSavedView(name string, source savedviewtypes.Source) savedviewt
|
||||
func testUpdatableSavedView(displayName string, source savedviewtypes.Source) savedviewtypes.UpdatableSavedView {
|
||||
postable := testPostableSavedView(displayName, source)
|
||||
return savedviewtypes.UpdatableSavedView{
|
||||
Source: postable.Source,
|
||||
Data: postable.Data,
|
||||
Source: postable.Source,
|
||||
SchemaVersion: postable.SchemaVersion,
|
||||
Spec: postable.Spec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +92,7 @@ func TestModule_CreateAndGetView(t *testing.T) {
|
||||
assert.Equal(t, savedviewtypes.SourceLogs, got.Source)
|
||||
assert.Equal(t, "creator@signoz.io", got.CreatedBy)
|
||||
assert.Equal(t, "creator@signoz.io", got.UpdatedBy)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Data.Spec.PanelType)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeGraph, got.Spec.PanelType)
|
||||
|
||||
require.NoError(t, st.AssertExpectations())
|
||||
}
|
||||
@@ -138,21 +137,21 @@ func TestModule_UpdateView(t *testing.T) {
|
||||
existingName := existing.Name
|
||||
|
||||
updated := testUpdatableSavedView("renamed", savedviewtypes.SourceTraces)
|
||||
updated.Data.Spec.PanelType = savedviewtypes.PanelTypeTable
|
||||
updated.Spec.PanelType = savedviewtypes.PanelTypeTable
|
||||
|
||||
st.ExpectUpdate(orgID, id, 1)
|
||||
require.NoError(t, m.UpdateView(contextWithClaims(orgID, "updater@signoz.io"), orgID, id, updated))
|
||||
|
||||
stored := testSavedView(orgID, id, "updater@signoz.io", testPostableSavedView("renamed", savedviewtypes.SourceTraces))
|
||||
stored.Name = existingName
|
||||
stored.Data.Spec.PanelType = savedviewtypes.PanelTypeTable
|
||||
stored.Spec.PanelType = savedviewtypes.PanelTypeTable
|
||||
st.ExpectGet(orgID, id, stored)
|
||||
got, err := m.GetView(contextWithClaims(orgID, "creator@signoz.io"), orgID, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, existingName, got.Name, "name must not change on update")
|
||||
assert.Equal(t, "renamed", got.Data.Spec.DisplayName)
|
||||
assert.Equal(t, "renamed", got.Spec.DisplayName)
|
||||
assert.Equal(t, savedviewtypes.SourceTraces, got.Source)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Data.Spec.PanelType)
|
||||
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
|
||||
assert.Equal(t, "updater@signoz.io", got.UpdatedBy)
|
||||
|
||||
require.NoError(t, st.AssertExpectations())
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -18,32 +17,31 @@ func NewStore(sqlstore sqlstore.SQLStore) savedviewtypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
_, err := store.sqlstore.BunDB().NewInsert().Model(view).Exec(ctx)
|
||||
func (store *store) Create(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
|
||||
_, err := store.sqlstore.BunDB().NewInsert().Model(storable).Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", view.Name)
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "saved view with name %s already exists", storable.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.SavedView, error) {
|
||||
var view savedviewtypes.SavedView
|
||||
err := store.sqlstore.BunDB().NewSelect().Model(&view).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
|
||||
func (store *store) Get(ctx context.Context, orgID string, id valuer.UUID) (*savedviewtypes.StorableSavedView, error) {
|
||||
var storable savedviewtypes.StorableSavedView
|
||||
err := store.sqlstore.BunDB().NewSelect().Model(&storable).Where("org_id = ? AND id = ?", orgID, id.StringValue()).Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", id.StringValue())
|
||||
}
|
||||
|
||||
normalizeSelectedFields(&view)
|
||||
return &view, nil
|
||||
return &storable, nil
|
||||
}
|
||||
|
||||
func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView) error {
|
||||
func (store *store) Update(ctx context.Context, storable *savedviewtypes.StorableSavedView) error {
|
||||
res, err := store.sqlstore.BunDB().NewUpdate().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Model((*savedviewtypes.StorableSavedView)(nil)).
|
||||
Set("updated_at = ?, updated_by = ?, source = ?, data = ?",
|
||||
view.UpdatedAt, view.UpdatedBy, view.Source, view.Data).
|
||||
Where("id = ?", view.ID.StringValue()).
|
||||
Where("org_id = ?", view.OrgID).
|
||||
storable.UpdatedAt, storable.UpdatedBy, storable.Source, storable.Data).
|
||||
Where("id = ?", storable.ID.StringValue()).
|
||||
Where("org_id = ?", storable.OrgID).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in updating saved view")
|
||||
@@ -54,7 +52,7 @@ func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView)
|
||||
return errors.WrapInternalf(err, errors.CodeInternal, "error in verifying the updated saved view")
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", view.ID.StringValue())
|
||||
return errors.NewNotFoundf(savedviewtypes.ErrCodeSavedViewNotFound, "saved view %s not found", storable.ID.StringValue())
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -62,7 +60,7 @@ func (store *store) Update(ctx context.Context, view *savedviewtypes.SavedView)
|
||||
|
||||
func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) error {
|
||||
res, err := store.sqlstore.BunDB().NewDelete().
|
||||
Model(&savedviewtypes.SavedView{}).
|
||||
Model((*savedviewtypes.StorableSavedView)(nil)).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Where("org_id = ?", orgID).
|
||||
Exec(ctx)
|
||||
@@ -81,9 +79,9 @@ func (store *store) Delete(ctx context.Context, orgID string, id valuer.UUID) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) List(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.SavedView, error) {
|
||||
var views []*savedviewtypes.SavedView
|
||||
q := store.sqlstore.BunDB().NewSelect().Model(&views).
|
||||
func (store *store) List(ctx context.Context, orgID string, source savedviewtypes.Source, name string) ([]*savedviewtypes.StorableSavedView, error) {
|
||||
var storables []*savedviewtypes.StorableSavedView
|
||||
q := store.sqlstore.BunDB().NewSelect().Model(&storables).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name LIKE ?", "%"+name+"%")
|
||||
if !source.IsZero() {
|
||||
@@ -94,16 +92,5 @@ func (store *store) List(ctx context.Context, orgID string, source savedviewtype
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error in getting saved views")
|
||||
}
|
||||
|
||||
for _, view := range views {
|
||||
normalizeSelectedFields(view)
|
||||
}
|
||||
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// normalizeSelectedFields fixes up a scanned row's nil SelectedFields.
|
||||
func normalizeSelectedFields(view *savedviewtypes.SavedView) {
|
||||
if view.Data.Spec.SelectedFields == nil {
|
||||
view.Data.Spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
|
||||
}
|
||||
return storables, nil
|
||||
}
|
||||
|
||||
111
pkg/modules/savedview/implsavedview/store_test.go
Normal file
111
pkg/modules/savedview/implsavedview/store_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package implsavedview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newRealSQLiteStore spins up a real, file-backed sqlite database (not
|
||||
// sqlmock) with the actual saved_view table + the same UNIQUE(org_id, name)
|
||||
// index migration 109 creates in production, so tests here exercise the
|
||||
// genuine constraint violation and sqlitesqlstore.WrapAlreadyExistsErrf's
|
||||
// real error classification -- something a mocked "return this canned
|
||||
// error" test can't verify.
|
||||
func newRealSQLiteStore(t *testing.T) *store {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "saved_view_test.db")
|
||||
sqlStore, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{
|
||||
MaxOpenConns: 1,
|
||||
MaxConnLifetime: 0,
|
||||
},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: dbPath,
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = sqlStore.BunDB().NewCreateTable().
|
||||
Model((*savedviewtypes.StorableSavedView)(nil)).
|
||||
IfNotExists().
|
||||
Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = sqlStore.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_view_org_id_name ON saved_view (org_id, name)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &store{sqlstore: sqlStore}
|
||||
}
|
||||
|
||||
func testStorableSavedView(orgID string) *savedviewtypes.StorableSavedView {
|
||||
const name = "same-name"
|
||||
view := &savedviewtypes.SavedView{
|
||||
Name: name,
|
||||
Source: savedviewtypes.SourceLogs,
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion,
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: name,
|
||||
PanelType: savedviewtypes.PanelTypeGraph,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
view.ID = valuer.GenerateUUID()
|
||||
view.OrgID = orgID
|
||||
view.CreatedBy = "creator@signoz.io"
|
||||
view.UpdatedBy = "creator@signoz.io"
|
||||
now := time.Now()
|
||||
view.CreatedAt = now
|
||||
view.UpdatedAt = now
|
||||
return savedviewtypes.NewStorableSavedView(view)
|
||||
}
|
||||
|
||||
// TestStore_Create_DuplicateNameIsConflict proves the full, real path: a
|
||||
// genuine sqlite UNIQUE(org_id, name) violation is classified as
|
||||
// errors.TypeAlreadyExists, not left as an opaque internal error -- this is
|
||||
// what makes the 409 declared on CreateSavedView actually true, not just
|
||||
// documented in the OpenAPI schema.
|
||||
func TestStore_Create_DuplicateNameIsConflict(t *testing.T) {
|
||||
s := newRealSQLiteStore(t)
|
||||
orgID := valuer.GenerateUUID().StringValue()
|
||||
|
||||
require.NoError(t, s.Create(context.Background(), testStorableSavedView(orgID)))
|
||||
|
||||
err := s.Create(context.Background(), testStorableSavedView(orgID))
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Ast(err, errors.TypeAlreadyExists), "expected an already-exists error, got %v", err)
|
||||
}
|
||||
|
||||
// TestStore_Create_SameNameDifferentOrgSucceeds guards against an
|
||||
// overly-broad fix: uniqueness is scoped to (org_id, name), so the same name
|
||||
// under a different org must succeed.
|
||||
func TestStore_Create_SameNameDifferentOrgSucceeds(t *testing.T) {
|
||||
s := newRealSQLiteStore(t)
|
||||
|
||||
require.NoError(t, s.Create(context.Background(), testStorableSavedView(valuer.GenerateUUID().StringValue())))
|
||||
require.NoError(t, s.Create(context.Background(), testStorableSavedView(valuer.GenerateUUID().StringValue())))
|
||||
}
|
||||
@@ -237,6 +237,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddDashboardTuplesFactory(sqlstore),
|
||||
sqlmigration.NewRestructureSavedViewSpecFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddSavedViewTuplesFactory(sqlstore),
|
||||
sqlmigration.NewFixSavedViewSelectedFieldsFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type restructureSavedViewSpec struct {
|
||||
@@ -43,11 +45,11 @@ type legacySavedViewCompositeQuery struct {
|
||||
|
||||
// legacySavedViewExtraData mirrors the frontend defined extraData JSON shape.
|
||||
type legacySavedViewExtraData struct {
|
||||
Color string `json:"color,omitempty"`
|
||||
SelectColumns json.RawMessage `json:"selectColumns,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MaxLines int `json:"maxLines,omitempty"`
|
||||
FontSize string `json:"fontSize,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
SelectColumns []telemetrytypes.TelemetryFieldKey `json:"selectColumns,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
MaxLines int `json:"maxLines,omitempty"`
|
||||
FontSize string `json:"fontSize,omitempty"`
|
||||
}
|
||||
|
||||
type savedViewDisplay struct {
|
||||
@@ -58,11 +60,11 @@ type savedViewDisplay struct {
|
||||
}
|
||||
|
||||
type savedViewSpec struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
PanelType string `json:"panelType"`
|
||||
Queries json.RawMessage `json:"queries"`
|
||||
SelectedFields json.RawMessage `json:"selectedFields"`
|
||||
Display savedViewDisplay `json:"display"`
|
||||
DisplayName string `json:"displayName"`
|
||||
PanelType string `json:"panelType"`
|
||||
Queries json.RawMessage `json:"queries"`
|
||||
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields"`
|
||||
Display savedViewDisplay `json:"display"`
|
||||
}
|
||||
|
||||
type savedViewData struct {
|
||||
@@ -164,6 +166,15 @@ func (migration *restructureSavedViewSpec) Up(ctx context.Context, db *bun.DB) e
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
if err := tx.NewSelect().Model((*types.Organization)(nil)).Column("id").Scan(ctx, &orgIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
validOrgIDs := make(map[string]struct{}, len(orgIDs))
|
||||
for _, id := range orgIDs {
|
||||
validOrgIDs[id] = struct{}{}
|
||||
}
|
||||
|
||||
// drop table `saved_views`
|
||||
for _, sql := range migration.sqlschema.Operator().DropTable(savedViewsTable) {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
@@ -210,6 +221,13 @@ func (migration *restructureSavedViewSpec) Up(ctx context.Context, db *bun.DB) e
|
||||
continue // orphaned row from a pre-existing org_id backfill gap; nothing sane to attach it to
|
||||
}
|
||||
|
||||
// to avoid foreign key constraint issues
|
||||
if _, ok := validOrgIDs[old.OrgID]; !ok {
|
||||
skipped++
|
||||
migration.settings.Logger.WarnContext(ctx, "saved view references an org that no longer exists, skipping", slog.String("org_id", old.OrgID), slog.String("saved_view_id", old.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
var compositeQuery legacySavedViewCompositeQuery
|
||||
if err := json.Unmarshal([]byte(old.Data), &compositeQuery); err != nil {
|
||||
failed++
|
||||
@@ -221,7 +239,9 @@ func (migration *restructureSavedViewSpec) Up(ctx context.Context, db *bun.DB) e
|
||||
if old.ExtraData != "" {
|
||||
// best-effort: malformed/older extraData shapes never fail the migration,
|
||||
// they just leave selectedFields/display empty.
|
||||
_ = json.Unmarshal([]byte(old.ExtraData), &extraData)
|
||||
if err := json.Unmarshal([]byte(old.ExtraData), &extraData); err != nil {
|
||||
migration.settings.Logger.WarnContext(ctx, "failed to unmarshal saved view extra data, continuing with empty selectedFields/display", slog.String("org_id", old.OrgID), slog.String("saved_view_id", old.ID), slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
dataJSON, err := json.Marshal(savedViewData{
|
||||
|
||||
185
pkg/sqlmigration/111_fix_saved_view_selected_fields.go
Normal file
185
pkg/sqlmigration/111_fix_saved_view_selected_fields.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
type fixSavedViewSelectedFields struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
settings factory.ProviderSettings
|
||||
}
|
||||
|
||||
func NewFixSavedViewSelectedFieldsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("fix_saved_view_selected_fields"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &fixSavedViewSelectedFields{sqlstore: sqlstore, settings: ps}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *fixSavedViewSelectedFields) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// storableSavedViewData is the shape of the `saved_view` table this migration repairs.
|
||||
type storableSavedViewData struct {
|
||||
bun.BaseModel `bun:"table:saved_view"`
|
||||
|
||||
ID string `bun:"id,pk,type:text"`
|
||||
Data string `bun:"data,type:text"`
|
||||
}
|
||||
|
||||
// specFieldUnmarshalsCleanly reports whether value can be unmarshalled into
|
||||
// the real type of the given savedviewtypes.SavedViewSpec JSON key.
|
||||
func specFieldUnmarshalsCleanly(key string, value json.RawMessage) bool {
|
||||
switch key {
|
||||
case "displayName", "panelType":
|
||||
var s string
|
||||
return json.Unmarshal(value, &s) == nil
|
||||
case "queries":
|
||||
var q []qbtypes.QueryEnvelope
|
||||
return json.Unmarshal(value, &q) == nil
|
||||
case "selectedFields":
|
||||
var f []telemetrytypes.TelemetryFieldKey
|
||||
return json.Unmarshal(value, &f) == nil
|
||||
case "display":
|
||||
var d savedviewtypes.Display
|
||||
return json.Unmarshal(value, &d) == nil
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// specFieldZeroValueJSON is the JSON to substitute for a spec key that fails
|
||||
// to unmarshal into its real type.
|
||||
var specFieldZeroValueJSON = map[string]string{
|
||||
"displayName": `""`,
|
||||
"panelType": `""`,
|
||||
"queries": `[]`,
|
||||
"selectedFields": `[]`,
|
||||
"display": `{}`,
|
||||
}
|
||||
|
||||
// repairSavedViewData tries to make data unmarshal cleanly into
|
||||
// savedviewtypes.SavedViewData by blanking, one key at a time, whichever
|
||||
// top-level spec fields fail to unmarshal into their real type -- e.g. a
|
||||
// selectedFields shape the 109 migration forwarded verbatim from a
|
||||
// pre-telemetrytypes.TelemetryFieldKey install, or a queries shape that
|
||||
// predates the current discriminated-union QueryEnvelope. Every other key is
|
||||
// left byte-for-byte untouched. Returns ok=false if data/spec aren't even
|
||||
// JSON objects, or the result still doesn't unmarshal cleanly afterward.
|
||||
func repairSavedViewData(data string) (fixed string, blanked []string, ok bool) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
var spec map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw["spec"], &spec); err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
for key, value := range spec {
|
||||
if specFieldUnmarshalsCleanly(key, value) {
|
||||
continue
|
||||
}
|
||||
zero, known := specFieldZeroValueJSON[key]
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
spec[key] = json.RawMessage(zero)
|
||||
blanked = append(blanked, key)
|
||||
}
|
||||
|
||||
fixedSpec, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
raw["spec"] = fixedSpec
|
||||
|
||||
fixedData, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// verify the fix actually round-trips through the real type before writing it.
|
||||
if err := json.Unmarshal(fixedData, new(savedviewtypes.SavedViewData)); err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
return string(fixedData), blanked, true
|
||||
}
|
||||
|
||||
// placeholderSavedViewData is substituted whole when a row can't be repaired
|
||||
// field-by-field (data/spec aren't JSON objects at all, or repair still
|
||||
// doesn't unmarshal cleanly). It must itself always unmarshal cleanly, since
|
||||
// every Get/List reads saved_view.data straight into savedviewtypes.SavedViewData
|
||||
// -- leaving genuinely-unrepairable data in place would 500 on every future read.
|
||||
func placeholderSavedViewData(id string) string {
|
||||
data, err := json.Marshal(savedviewtypes.SavedViewData{
|
||||
SchemaVersion: savedviewtypes.SavedViewSchemaVersion.StringValue(),
|
||||
Spec: savedviewtypes.SavedViewSpec{
|
||||
DisplayName: fmt.Sprintf("corrupted saved view %s", id),
|
||||
PanelType: savedviewtypes.PanelTypeTable,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// marshalling a static, well-formed literal cannot fail.
|
||||
panic(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func (migration *fixSavedViewSelectedFields) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var rows []*storableSavedViewData
|
||||
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
var repaired, replaced int
|
||||
for _, row := range rows {
|
||||
// already scans cleanly as-is -- nothing to repair.
|
||||
if err := json.Unmarshal([]byte(row.Data), new(savedviewtypes.SavedViewData)); err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fixedData, blanked, ok := repairSavedViewData(row.Data)
|
||||
if !ok {
|
||||
fixedData = placeholderSavedViewData(row.ID)
|
||||
replaced++
|
||||
migration.settings.Logger.WarnContext(ctx, "saved view data could not be repaired field-by-field, replacing with a placeholder view", slog.String("saved_view_id", row.ID))
|
||||
} else {
|
||||
repaired++
|
||||
migration.settings.Logger.WarnContext(ctx, "repaired saved view data by blanking fields that failed to unmarshal", slog.String("saved_view_id", row.ID), slog.Any("fields_blanked", blanked))
|
||||
}
|
||||
|
||||
if _, err := tx.NewUpdate().Model((*storableSavedViewData)(nil)).Set("data = ?", fixedData).Where("id = ?", row.ID).Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
migration.settings.Logger.InfoContext(ctx, "checked saved views for unreadable data", slog.Int("total", len(rows)), slog.Int("repaired", repaired), slog.Int("replaced", replaced))
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *fixSavedViewSelectedFields) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
174
pkg/sqlmigration/111_fix_saved_view_selected_fields_test.go
Normal file
174
pkg/sqlmigration/111_fix_saved_view_selected_fields_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// healthyData is a well-formed row: every spec key unmarshals cleanly.
|
||||
const healthyData = `{"schemaVersion":"v2","spec":{"displayName":"My View","panelType":"table","queries":[{"type":"builder_query","spec":{"name":"A","signal":"logs","aggregations":[{"expression":"count()"}]}}],"selectedFields":[{"name":"service.name"}],"display":{"maxLines":0,"fontSize":"","format":"","color":""}}}`
|
||||
|
||||
func TestRepairSavedViewData(t *testing.T) {
|
||||
t.Run("healthy data is a no-op", func(t *testing.T) {
|
||||
fixed, blanked, ok := repairSavedViewData(healthyData)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, blanked)
|
||||
|
||||
var got savedviewtypes.SavedViewData
|
||||
require.NoError(t, json.Unmarshal([]byte(fixed), &got))
|
||||
assert.Equal(t, "My View", got.Spec.DisplayName)
|
||||
assert.Equal(t, []string{"service.name"}, telemetryFieldKeyName(t, got))
|
||||
})
|
||||
|
||||
t.Run("selectedFields in the pre-TelemetryFieldKey bare-string shape is blanked, other fields untouched", func(t *testing.T) {
|
||||
// this is the actual real-world corruption: migration 109 forwarded the
|
||||
// legacy frontend's extraData.selectColumns verbatim before it was typed
|
||||
// as []telemetrytypes.TelemetryFieldKey -- a bare list of strings like
|
||||
// this fails to unmarshal into a list of objects.
|
||||
data := `{"schemaVersion":"v2","spec":{"displayName":"Corrupted Fields","panelType":"table","queries":[{"type":"builder_query","spec":{"name":"A","signal":"logs","aggregations":[{"expression":"count()"}]}}],"selectedFields":["service.name","http.method"],"display":{"maxLines":0,"fontSize":"","format":"","color":""}}}`
|
||||
|
||||
fixed, blanked, ok := repairSavedViewData(data)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{"selectedFields"}, blanked)
|
||||
|
||||
var got savedviewtypes.SavedViewData
|
||||
require.NoError(t, json.Unmarshal([]byte(fixed), &got))
|
||||
assert.Equal(t, "Corrupted Fields", got.Spec.DisplayName, "unrelated fields must survive untouched")
|
||||
assert.Equal(t, "table", got.Spec.PanelType.StringValue())
|
||||
assert.Len(t, got.Spec.Queries, 1, "unrelated fields must survive untouched")
|
||||
assert.Empty(t, got.Spec.SelectedFields, "the corrupted field is blanked to an empty list, not left broken")
|
||||
})
|
||||
|
||||
t.Run("queries predating the discriminated-union QueryEnvelope shape is blanked, other fields untouched", func(t *testing.T) {
|
||||
data := `{"schemaVersion":"v2","spec":{"displayName":"Old Queries","panelType":"table","queries":[{"query":"select 1"}],"selectedFields":[{"name":"service.name"}],"display":{"maxLines":0,"fontSize":"","format":"","color":""}}}`
|
||||
|
||||
fixed, blanked, ok := repairSavedViewData(data)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, []string{"queries"}, blanked)
|
||||
|
||||
var got savedviewtypes.SavedViewData
|
||||
require.NoError(t, json.Unmarshal([]byte(fixed), &got))
|
||||
assert.Equal(t, "Old Queries", got.Spec.DisplayName)
|
||||
assert.Empty(t, got.Spec.Queries)
|
||||
assert.Len(t, got.Spec.SelectedFields, 1, "unrelated fields must survive untouched")
|
||||
})
|
||||
|
||||
t.Run("multiple corrupted fields are each blanked independently", func(t *testing.T) {
|
||||
data := `{"schemaVersion":"v2","spec":{"displayName":"Double Trouble","panelType":"table","queries":[{"query":"select 1"}],"selectedFields":["service.name"],"display":{"maxLines":0,"fontSize":"","format":"","color":""}}}`
|
||||
|
||||
fixed, blanked, ok := repairSavedViewData(data)
|
||||
require.True(t, ok)
|
||||
assert.ElementsMatch(t, []string{"queries", "selectedFields"}, blanked)
|
||||
|
||||
var got savedviewtypes.SavedViewData
|
||||
require.NoError(t, json.Unmarshal([]byte(fixed), &got))
|
||||
assert.Equal(t, "Double Trouble", got.Spec.DisplayName)
|
||||
assert.Empty(t, got.Spec.Queries)
|
||||
assert.Empty(t, got.Spec.SelectedFields)
|
||||
})
|
||||
|
||||
t.Run("data is not a JSON object at all", func(t *testing.T) {
|
||||
_, _, ok := repairSavedViewData(`not json`)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("data is valid JSON but not an object", func(t *testing.T) {
|
||||
_, _, ok := repairSavedViewData(`[1,2,3]`)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("spec is missing entirely", func(t *testing.T) {
|
||||
_, _, ok := repairSavedViewData(`{"schemaVersion":"v2"}`)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("spec is present but not an object", func(t *testing.T) {
|
||||
_, _, ok := repairSavedViewData(`{"schemaVersion":"v2","spec":"garbage"}`)
|
||||
assert.False(t, ok)
|
||||
})
|
||||
|
||||
t.Run("an unknown spec key is left untouched regardless of its shape", func(t *testing.T) {
|
||||
data := `{"schemaVersion":"v2","spec":{"displayName":"Has Extra Key","panelType":"table","queries":[{"type":"builder_query","spec":{"name":"A","signal":"logs","aggregations":[{"expression":"count()"}]}}],"selectedFields":[],"display":{},"someFutureField":{"whatever":123}}}`
|
||||
|
||||
fixed, blanked, ok := repairSavedViewData(data)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, blanked)
|
||||
assert.Contains(t, fixed, "someFutureField")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSpecFieldUnmarshalsCleanly(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{name: "valid displayName", key: "displayName", value: `"My View"`, want: true},
|
||||
{name: "displayName as a number fails", key: "displayName", value: `123`, want: false},
|
||||
{name: "valid panelType", key: "panelType", value: `"table"`, want: true},
|
||||
{name: "valid queries", key: "queries", value: `[{"type":"builder_query","spec":{"name":"A","signal":"logs","aggregations":[{"expression":"count()"}]}}]`, want: true},
|
||||
{name: "queries missing the type discriminator fails", key: "queries", value: `[{"query":"select 1"}]`, want: false},
|
||||
{name: "valid selectedFields", key: "selectedFields", value: `[{"name":"service.name"}]`, want: true},
|
||||
{name: "selectedFields as bare strings fails", key: "selectedFields", value: `["service.name"]`, want: false},
|
||||
{name: "valid display", key: "display", value: `{"maxLines":0,"fontSize":"","format":"","color":""}`, want: true},
|
||||
{name: "display as a bare string fails", key: "display", value: `"blue"`, want: false},
|
||||
{name: "unknown key always reports clean", key: "someFutureField", value: `{"anything":"goes"}`, want: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := specFieldUnmarshalsCleanly(c.key, json.RawMessage(c.value))
|
||||
assert.Equal(t, c.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderSavedViewData(t *testing.T) {
|
||||
placeholder := placeholderSavedViewData("019fe515-d981-7de7-a8c3-6137ae1200c2")
|
||||
|
||||
var got savedviewtypes.SavedViewData
|
||||
require.NoError(t, json.Unmarshal([]byte(placeholder), &got), "the placeholder itself must always unmarshal cleanly")
|
||||
|
||||
assert.Equal(t, savedviewtypes.SavedViewSchemaVersion.StringValue(), got.SchemaVersion)
|
||||
assert.Contains(t, got.Spec.DisplayName, "019fe515-d981-7de7-a8c3-6137ae1200c2")
|
||||
assert.Equal(t, savedviewtypes.PanelTypeTable, got.Spec.PanelType)
|
||||
|
||||
// verify the full read path a Get/List would take doesn't panic or error.
|
||||
storable := &savedviewtypes.StorableSavedView{Data: got}
|
||||
view := storable.ToSavedView()
|
||||
assert.NotNil(t, view.Spec.SelectedFields)
|
||||
}
|
||||
|
||||
// TestUnrepairableDataGetsReplacedNotLeftBroken exercises the exact decision
|
||||
// Up() makes per row: an unrepairable row must never be left in a state that
|
||||
// fails to unmarshal, since every future Get/List reads saved_view.data
|
||||
// straight into savedviewtypes.SavedViewData.
|
||||
func TestUnrepairableDataGetsReplacedNotLeftBroken(t *testing.T) {
|
||||
cases := []string{
|
||||
`not json at all`,
|
||||
`{"schemaVersion":"v2","spec":"garbage"}`,
|
||||
`{"schemaVersion":"v2"}`,
|
||||
}
|
||||
|
||||
for _, data := range cases {
|
||||
_, _, ok := repairSavedViewData(data)
|
||||
require.False(t, ok, "expected %q to be unrepairable", data)
|
||||
|
||||
fixed := placeholderSavedViewData("some-id")
|
||||
require.NoError(t, json.Unmarshal([]byte(fixed), new(savedviewtypes.SavedViewData)))
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryFieldKeyName(t *testing.T, data savedviewtypes.SavedViewData) []string {
|
||||
t.Helper()
|
||||
names := make([]string, 0, len(data.Spec.SelectedFields))
|
||||
for _, f := range data.Spec.SelectedFields {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -42,13 +42,22 @@ type ClusterRecord struct {
|
||||
type PostableClusters struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Filter *ClusterFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// ClusterFilter is the attribute filter plus optional secondary filters on the
|
||||
// derived pod display status(es) (see PodStatus; matches any listed, OR) and node
|
||||
// readiness (see NodeCondition; matches any listed, OR). Empty FilterByPodStatus / FilterByNodeReadiness = off.
|
||||
type ClusterFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
|
||||
FilterByNodeReadiness []NodeCondition `json:"filterByNodeReadiness"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableClusters contains acceptable values.
|
||||
func (req *PostableClusters) Validate() error {
|
||||
if req == nil {
|
||||
@@ -88,6 +97,19 @@ func (req *PostableClusters) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
for _, s := range req.Filter.FilterByPodStatus {
|
||||
if !s.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
|
||||
}
|
||||
}
|
||||
for _, c := range req.Filter.FilterByNodeReadiness {
|
||||
if !c.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by node readiness: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(ClustersValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
|
||||
@@ -75,13 +75,21 @@ type ContainerRecord struct {
|
||||
type PostableContainers struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Filter *ContainerFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// ContainerFilter is the attribute filter plus an optional secondary filter on
|
||||
// the derived container display status(es) (see ContainerStatus; matches any
|
||||
// listed, OR). Empty FilterByContainerStatus = off.
|
||||
type ContainerFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByContainerStatus []ContainerStatus `json:"filterByContainerStatus"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableContainers contains acceptable values.
|
||||
func (req *PostableContainers) Validate() error {
|
||||
if req == nil {
|
||||
@@ -121,6 +129,14 @@ func (req *PostableContainers) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
for _, c := range req.Filter.FilterByContainerStatus {
|
||||
if !c.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by container status: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(ContainersValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// ContainerStatus is the kubectl-style display status of a container, derived
|
||||
// from k8s.container.status.state (base) + k8s.container.status.reason (overlay).
|
||||
@@ -31,6 +35,12 @@ var (
|
||||
ContainerStatusNoData = ContainerStatus{valuer.NewString("no_data")}
|
||||
)
|
||||
|
||||
// IsFilterable reports whether c is a concrete, user-filterable
|
||||
// container status: any Enum() member except the no_data sentinel.
|
||||
func (c ContainerStatus) IsFilterable() bool {
|
||||
return c != ContainerStatusNoData && slices.Contains((ContainerStatus{}).Enum(), any(c))
|
||||
}
|
||||
|
||||
func (ContainerStatus) Enum() []any {
|
||||
return []any{
|
||||
ContainerStatusRunning,
|
||||
|
||||
@@ -36,13 +36,20 @@ type DaemonSetRecord struct {
|
||||
type PostableDaemonSets struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Filter *DaemonSetFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// DaemonSetFilter is the attribute filter plus an optional secondary filter on the
|
||||
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
|
||||
type DaemonSetFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableDaemonSets contains acceptable values.
|
||||
func (req *PostableDaemonSets) Validate() error {
|
||||
if req == nil {
|
||||
@@ -82,6 +89,14 @@ func (req *PostableDaemonSets) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
for _, s := range req.Filter.FilterByPodStatus {
|
||||
if !s.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(DaemonSetsValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
|
||||
@@ -34,13 +34,20 @@ type DeploymentRecord struct {
|
||||
type PostableDeployments struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Filter *DeploymentFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// DeploymentFilter is the attribute filter plus an optional secondary filter on the
|
||||
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
|
||||
type DeploymentFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableDeployments contains acceptable values.
|
||||
func (req *PostableDeployments) Validate() error {
|
||||
if req == nil {
|
||||
@@ -80,6 +87,14 @@ func (req *PostableDeployments) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
for _, s := range req.Filter.FilterByPodStatus {
|
||||
if !s.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(DeploymentsValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
|
||||
@@ -36,13 +36,20 @@ type JobRecord struct {
|
||||
type PostableJobs struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Filter *JobFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// JobFilter is the attribute filter plus an optional secondary filter on the
|
||||
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
|
||||
type JobFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableJobs contains acceptable values.
|
||||
func (req *PostableJobs) Validate() error {
|
||||
if req == nil {
|
||||
@@ -82,6 +89,14 @@ func (req *PostableJobs) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
for _, s := range req.Filter.FilterByPodStatus {
|
||||
if !s.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(JobsValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
|
||||
@@ -34,13 +34,20 @@ type NamespaceRecord struct {
|
||||
type PostableNamespaces struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Filter *NamespaceFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// NamespaceFilter is the attribute filter plus an optional secondary filter on the
|
||||
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
|
||||
type NamespaceFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableNamespaces contains acceptable values.
|
||||
func (req *PostableNamespaces) Validate() error {
|
||||
if req == nil {
|
||||
@@ -80,6 +87,14 @@ func (req *PostableNamespaces) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
for _, s := range req.Filter.FilterByPodStatus {
|
||||
if !s.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(NamespacesValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
|
||||
@@ -39,13 +39,22 @@ type NodeRecord struct {
|
||||
type PostableNodes struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Filter *NodeFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// NodeFilter is the attribute filter plus an optional secondary filter on the
|
||||
// derived pod display status(es) (see PodStatus; matches any listed, OR) and node
|
||||
// readiness (see NodeCondition; matches any listed, OR). Empty FilterByPodStatus / FilterByNodeReadiness = off.
|
||||
type NodeFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
|
||||
FilterByNodeReadiness []NodeCondition `json:"filterByNodeReadiness"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableNodes contains acceptable values.
|
||||
func (req *PostableNodes) Validate() error {
|
||||
if req == nil {
|
||||
@@ -85,6 +94,19 @@ func (req *PostableNodes) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
for _, s := range req.Filter.FilterByPodStatus {
|
||||
if !s.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
|
||||
}
|
||||
}
|
||||
for _, c := range req.Filter.FilterByNodeReadiness {
|
||||
if !c.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by node readiness: %s", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(NodesValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type NodeCondition struct {
|
||||
valuer.String
|
||||
@@ -20,6 +24,12 @@ func (NodeCondition) Enum() []any {
|
||||
}
|
||||
}
|
||||
|
||||
// IsFilterable reports whether c is a concrete, user-filterable
|
||||
// node readiness: any Enum() member except the no_data sentinel.
|
||||
func (c NodeCondition) IsFilterable() bool {
|
||||
return c != NodeConditionNoData && slices.Contains((NodeCondition{}).Enum(), any(c))
|
||||
}
|
||||
|
||||
// Numeric values emitted by the k8s.node.condition_ready metric
|
||||
// (source: OTel kubeletstats receiver).
|
||||
const (
|
||||
|
||||
@@ -63,13 +63,20 @@ type PodRecord struct {
|
||||
type PostablePods struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Filter *PodFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// PodFilter is the attribute filter plus an optional secondary filter on the
|
||||
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
|
||||
type PodFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
|
||||
}
|
||||
|
||||
// Validate ensures PostablePods contains acceptable values.
|
||||
func (req *PostablePods) Validate() error {
|
||||
if req == nil {
|
||||
@@ -109,6 +116,14 @@ func (req *PostablePods) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
for _, s := range req.Filter.FilterByPodStatus {
|
||||
if !s.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(PodsValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package inframonitoringtypes
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// PodStatus is the kubectl-style pod display status, derived from
|
||||
// k8s.pod.phase + k8s.pod.status_reason + k8s.container.status.reason
|
||||
@@ -66,6 +70,12 @@ func (PodStatus) Enum() []any {
|
||||
}
|
||||
}
|
||||
|
||||
// IsFilterable reports whether s is a concrete, user-filterable pod
|
||||
// status: any Enum() member except the no_data sentinel.
|
||||
func (s PodStatus) IsFilterable() bool {
|
||||
return s != PodStatusNoData && slices.Contains((PodStatus{}).Enum(), any(s))
|
||||
}
|
||||
|
||||
const PodNameAttrKey = "k8s.pod.name"
|
||||
|
||||
const (
|
||||
|
||||
@@ -34,13 +34,20 @@ type StatefulSetRecord struct {
|
||||
type PostableStatefulSets struct {
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter"`
|
||||
Filter *StatefulSetFilter `json:"filter"`
|
||||
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
}
|
||||
|
||||
// StatefulSetFilter is the attribute filter plus an optional secondary filter on the
|
||||
// derived pod display status(es) (see PodStatus); matches any listed (OR). Empty = off.
|
||||
type StatefulSetFilter struct {
|
||||
qbtypes.Filter `json:",inline"`
|
||||
FilterByPodStatus []PodStatus `json:"filterByPodStatus"`
|
||||
}
|
||||
|
||||
// Validate ensures PostableStatefulSets contains acceptable values.
|
||||
func (req *PostableStatefulSets) Validate() error {
|
||||
if req == nil {
|
||||
@@ -80,6 +87,14 @@ func (req *PostableStatefulSets) Validate() error {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
|
||||
}
|
||||
|
||||
if req.Filter != nil {
|
||||
for _, s := range req.Filter.FilterByPodStatus {
|
||||
if !s.IsFilterable() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid filter by pod status: %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.OrderBy != nil {
|
||||
if !slices.Contains(StatefulSetsValidOrderByKeys, req.OrderBy.Key.Name) {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
@@ -28,27 +29,73 @@ var (
|
||||
)
|
||||
|
||||
type SavedView struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
OrgID string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
Source Source `json:"source"`
|
||||
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
|
||||
Spec SavedViewSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
type StorableSavedView struct {
|
||||
bun.BaseModel `bun:"table:saved_view"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
OrgID string `json:"-" bun:"org_id,notnull"`
|
||||
Name string `json:"name" bun:"name,type:text,notnull"`
|
||||
Source Source `json:"source" bun:"source,type:text,notnull"`
|
||||
Data SavedViewData `json:"data" bun:"data,type:text,notnull"`
|
||||
OrgID string `bun:"org_id,notnull"`
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Source Source `bun:"source,type:text,notnull"`
|
||||
Data SavedViewData `bun:"data,type:text,notnull"`
|
||||
}
|
||||
|
||||
func (s *StorableSavedView) ToSavedView() *SavedView {
|
||||
spec := s.Data.Spec
|
||||
if spec.SelectedFields == nil {
|
||||
spec.SelectedFields = []telemetrytypes.TelemetryFieldKey{}
|
||||
}
|
||||
|
||||
return &SavedView{
|
||||
Identifiable: s.Identifiable,
|
||||
TimeAuditable: s.TimeAuditable,
|
||||
UserAuditable: s.UserAuditable,
|
||||
OrgID: s.OrgID,
|
||||
Name: s.Name,
|
||||
Source: s.Source,
|
||||
SchemaVersion: SchemaVersion{valuer.NewString(s.Data.SchemaVersion)},
|
||||
Spec: spec,
|
||||
}
|
||||
}
|
||||
|
||||
func NewStorableSavedView(view *SavedView) *StorableSavedView {
|
||||
return &StorableSavedView{
|
||||
Identifiable: view.Identifiable,
|
||||
TimeAuditable: view.TimeAuditable,
|
||||
UserAuditable: view.UserAuditable,
|
||||
OrgID: view.OrgID,
|
||||
Name: view.Name,
|
||||
Source: view.Source,
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: view.SchemaVersion.StringValue(),
|
||||
Spec: view.Spec,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type PostableSavedView struct {
|
||||
Name string `json:"name"`
|
||||
GenerateName bool `json:"generateName"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
Data SavedViewData `json:"data" required:"true"`
|
||||
Name string `json:"name"`
|
||||
GenerateName bool `json:"generateName"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
|
||||
Spec SavedViewSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
type UpdatableSavedView struct {
|
||||
Source Source `json:"source" required:"true"`
|
||||
Data SavedViewData `json:"data" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
SchemaVersion SchemaVersion `json:"schemaVersion" required:"true"`
|
||||
Spec SavedViewSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
type ListSavedViewsParams struct {
|
||||
@@ -83,7 +130,7 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
|
||||
|
||||
name := postable.Name
|
||||
if postable.GenerateName {
|
||||
name = generateSavedViewName(postable.Data.Spec.DisplayName)
|
||||
name = generateSavedViewName(postable.Spec.DisplayName)
|
||||
}
|
||||
|
||||
return &SavedView{
|
||||
@@ -93,7 +140,8 @@ func (postable PostableSavedView) ToSavedView(orgID string, createdBy string) *S
|
||||
OrgID: orgID,
|
||||
Name: name,
|
||||
Source: postable.Source,
|
||||
Data: postable.Data,
|
||||
SchemaVersion: postable.SchemaVersion,
|
||||
Spec: postable.Spec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +154,8 @@ func (updatable UpdatableSavedView) ToSavedView(id valuer.UUID, orgID string, up
|
||||
UserAuditable: types.UserAuditable{UpdatedBy: updatedBy},
|
||||
OrgID: orgID,
|
||||
Source: updatable.Source,
|
||||
Data: updatable.Data,
|
||||
SchemaVersion: updatable.SchemaVersion,
|
||||
Spec: updatable.Spec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +166,11 @@ func (p *PostableSavedView) Validate() error {
|
||||
if err := p.Source.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.SchemaVersion.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return p.Data.Validate()
|
||||
return p.Spec.Validate()
|
||||
}
|
||||
|
||||
func (p *PostableSavedView) validateName() error {
|
||||
@@ -135,8 +187,11 @@ func (u *UpdatableSavedView) Validate() error {
|
||||
if err := u.Source.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := u.SchemaVersion.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return u.Data.Validate()
|
||||
return u.Spec.Validate()
|
||||
}
|
||||
|
||||
func (p *ListSavedViewsParams) Validate() error {
|
||||
@@ -147,7 +202,17 @@ func (p *ListSavedViewsParams) Validate() error {
|
||||
return p.Source.Validate()
|
||||
}
|
||||
|
||||
func NewStatsFromSavedViews(savedViews []*SavedView) map[string]any {
|
||||
// NewSavedViewsFromStorableSavedViews converts scanned rows to their domain shape.
|
||||
func NewSavedViewsFromStorableSavedViews(storableSavedViews []*StorableSavedView) []*SavedView {
|
||||
savedViews := make([]*SavedView, len(storableSavedViews))
|
||||
for idx, storableSavedView := range storableSavedViews {
|
||||
savedViews[idx] = storableSavedView.ToSavedView()
|
||||
}
|
||||
|
||||
return savedViews
|
||||
}
|
||||
|
||||
func NewStatsFromStorableSavedViews(savedViews []*StorableSavedView) map[string]any {
|
||||
stats := make(map[string]any)
|
||||
for _, savedView := range savedViews {
|
||||
key := "savedview.source." + strings.ToLower(savedView.Source.StringValue()) + ".count"
|
||||
|
||||
@@ -4,29 +4,27 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
func validPostableSavedView() PostableSavedView {
|
||||
return PostableSavedView{
|
||||
Name: "my-view",
|
||||
Source: SourceLogs,
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
|
||||
},
|
||||
Name: "my-view",
|
||||
Source: SourceLogs,
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
|
||||
}
|
||||
}
|
||||
|
||||
func validUpdatableSavedView() UpdatableSavedView {
|
||||
return UpdatableSavedView{
|
||||
Source: SourceLogs,
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
|
||||
},
|
||||
Source: SourceLogs,
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +67,7 @@ func TestPostableSavedViewValidate(t *testing.T) {
|
||||
|
||||
t.Run("invalid saved view data is rejected", func(t *testing.T) {
|
||||
view := validPostableSavedView()
|
||||
view.Data.SchemaVersion = "v1"
|
||||
view.SchemaVersion = SchemaVersion{valuer.NewString("v1")}
|
||||
assert.Error(t, view.Validate())
|
||||
})
|
||||
|
||||
@@ -100,7 +98,7 @@ func TestPostableSavedViewValidate(t *testing.T) {
|
||||
|
||||
t.Run("empty displayName is rejected", func(t *testing.T) {
|
||||
view := validPostableSavedView()
|
||||
view.Data.Spec.DisplayName = ""
|
||||
view.Spec.DisplayName = ""
|
||||
assert.ErrorContains(t, view.Validate(), "displayName is required")
|
||||
})
|
||||
}
|
||||
@@ -119,7 +117,7 @@ func TestUpdatableSavedViewValidate(t *testing.T) {
|
||||
|
||||
t.Run("empty displayName is rejected", func(t *testing.T) {
|
||||
view := validUpdatableSavedView()
|
||||
view.Data.Spec.DisplayName = ""
|
||||
view.Spec.DisplayName = ""
|
||||
assert.ErrorContains(t, view.Validate(), "displayName is required")
|
||||
})
|
||||
}
|
||||
@@ -153,7 +151,8 @@ func TestNewSavedView(t *testing.T) {
|
||||
assert.Equal(t, "creator@signoz.io", savedView.UpdatedBy)
|
||||
assert.Equal(t, view.Name, savedView.Name)
|
||||
assert.Equal(t, view.Source, savedView.Source)
|
||||
assert.Equal(t, view.Data, savedView.Data)
|
||||
assert.Equal(t, view.SchemaVersion, savedView.SchemaVersion)
|
||||
assert.Equal(t, view.Spec, savedView.Spec)
|
||||
assert.False(t, savedView.CreatedAt.IsZero())
|
||||
assert.Equal(t, savedView.CreatedAt, savedView.UpdatedAt)
|
||||
}
|
||||
@@ -163,14 +162,14 @@ func TestNewSavedView_GeneratesNameWhenEmpty(t *testing.T) {
|
||||
view := validPostableSavedView()
|
||||
view.Name = ""
|
||||
view.GenerateName = true
|
||||
view.Data.Spec.DisplayName = "My View!"
|
||||
view.Spec.DisplayName = "My View!"
|
||||
|
||||
savedView := view.ToSavedView(orgID, "creator@signoz.io")
|
||||
|
||||
assert.NotEmpty(t, savedView.Name)
|
||||
assert.Empty(t, validation.IsDNS1123Label(savedView.Name), "generated name must be a valid DNS-1123 label")
|
||||
assert.True(t, strings.HasPrefix(savedView.Name, "my-view-"))
|
||||
assert.Equal(t, "My View!", savedView.Data.Spec.DisplayName)
|
||||
assert.Equal(t, "My View!", savedView.Spec.DisplayName)
|
||||
}
|
||||
|
||||
func TestGenerateSavedViewName(t *testing.T) {
|
||||
@@ -212,17 +211,76 @@ func TestGenerateSavedViewName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewStatsFromSavedViews(t *testing.T) {
|
||||
views := []*SavedView{
|
||||
func TestStorableSavedView_ToSavedView(t *testing.T) {
|
||||
t.Run("round trip preserves populated fields", func(t *testing.T) {
|
||||
view := &SavedView{
|
||||
Name: "my-view",
|
||||
Source: SourceLogs,
|
||||
SchemaVersion: SavedViewSchemaVersion,
|
||||
Spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeGraph,
|
||||
Queries: validQueries(),
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{{Name: "service.name"}},
|
||||
},
|
||||
}
|
||||
view.OrgID = valuer.GenerateUUID().StringValue()
|
||||
|
||||
roundTripped := NewStorableSavedView(view).ToSavedView()
|
||||
|
||||
assert.Equal(t, view.OrgID, roundTripped.OrgID)
|
||||
assert.Equal(t, view.Name, roundTripped.Name)
|
||||
assert.Equal(t, view.Source, roundTripped.Source)
|
||||
assert.Equal(t, view.SchemaVersion, roundTripped.SchemaVersion)
|
||||
assert.Equal(t, view.Spec, roundTripped.Spec)
|
||||
})
|
||||
|
||||
t.Run("nil selectedFields normalizes to an empty slice, not nil", func(t *testing.T) {
|
||||
storable := &StorableSavedView{
|
||||
Data: SavedViewData{
|
||||
SchemaVersion: SavedViewSchemaVersion.StringValue(),
|
||||
Spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeGraph,
|
||||
Queries: validQueries(),
|
||||
SelectedFields: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
view := storable.ToSavedView()
|
||||
|
||||
assert.NotNil(t, view.Spec.SelectedFields)
|
||||
assert.Empty(t, view.Spec.SelectedFields)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewStatsFromStorableSavedViews(t *testing.T) {
|
||||
storables := []*StorableSavedView{
|
||||
{Source: SourceLogs},
|
||||
{Source: SourceLogs},
|
||||
{Source: SourceTraces},
|
||||
}
|
||||
|
||||
stats := NewStatsFromSavedViews(views)
|
||||
stats := NewStatsFromStorableSavedViews(storables)
|
||||
|
||||
assert.Equal(t, int64(3), stats["savedview.count"])
|
||||
assert.Equal(t, int64(2), stats["savedview.source.logs.count"])
|
||||
assert.Equal(t, int64(1), stats["savedview.source.traces.count"])
|
||||
assert.NotContains(t, stats, "savedview.source.metrics.count")
|
||||
}
|
||||
|
||||
func TestNewSavedViewsFromStorableSavedViews(t *testing.T) {
|
||||
storables := []*StorableSavedView{
|
||||
{Name: "a", Source: SourceLogs, Data: SavedViewData{SchemaVersion: SavedViewSchemaVersion.StringValue(), Spec: SavedViewSpec{DisplayName: "a", PanelType: PanelTypeGraph, Queries: validQueries()}}},
|
||||
{Name: "b", Source: SourceTraces, Data: SavedViewData{SchemaVersion: SavedViewSchemaVersion.StringValue(), Spec: SavedViewSpec{DisplayName: "b", PanelType: PanelTypeTable, Queries: validQueries()}}},
|
||||
}
|
||||
|
||||
views := NewSavedViewsFromStorableSavedViews(storables)
|
||||
|
||||
require.Len(t, views, 2)
|
||||
assert.Equal(t, "a", views[0].Name)
|
||||
assert.Equal(t, SourceLogs, views[0].Source)
|
||||
assert.Equal(t, "b", views[1].Name)
|
||||
assert.Equal(t, SourceTraces, views[1].Source)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (t *StoreTest) Store() savedviewtypes.Store { return t.store }
|
||||
func (t *StoreTest) Mock() sqlmock.Sqlmock { return t.mock }
|
||||
|
||||
func savedViewRow(view *savedviewtypes.SavedView) []driver.Value {
|
||||
data, _ := json.Marshal(view.Data)
|
||||
data, _ := json.Marshal(savedviewtypes.NewStorableSavedView(view).Data)
|
||||
return []driver.Value{
|
||||
view.ID.StringValue(),
|
||||
view.CreatedAt,
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// SavedViewSchemaVersion is the only schemaVersion currently.
|
||||
const SavedViewSchemaVersion = "v2"
|
||||
var SavedViewSchemaVersion = SchemaVersion{valuer.NewString("v2")}
|
||||
|
||||
var (
|
||||
PanelTypeValue = PanelType{valuer.NewString("value")}
|
||||
@@ -30,9 +30,9 @@ type Display struct {
|
||||
type SavedViewSpec struct {
|
||||
DisplayName string `json:"displayName" required:"true"`
|
||||
PanelType PanelType `json:"panelType" required:"true"`
|
||||
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false"`
|
||||
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" required:"true" nullable:"false"`
|
||||
Display Display `json:"display" required:"true"`
|
||||
Queries []qbtypes.QueryEnvelope `json:"queries" required:"true" nullable:"false" minItems:"1"`
|
||||
SelectedFields []telemetrytypes.TelemetryFieldKey `json:"selectedFields" nullable:"false"`
|
||||
Display Display `json:"display"`
|
||||
}
|
||||
|
||||
// SavedViewData is what's persisted as saved view data.
|
||||
@@ -41,6 +41,11 @@ type SavedViewData struct {
|
||||
Spec SavedViewSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
// SchemaVersion has v2 as the only value currently.
|
||||
type SchemaVersion struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
// PanelType is the explore-page panel a saved view renders as.
|
||||
type PanelType struct {
|
||||
valuer.String
|
||||
@@ -65,6 +70,17 @@ func (p PanelType) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
func (SchemaVersion) Enum() []any {
|
||||
return []any{SavedViewSchemaVersion}
|
||||
}
|
||||
|
||||
func (s SchemaVersion) Validate() error {
|
||||
if s != SavedViewSchemaVersion {
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion.StringValue(), s.StringValue())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SavedViewSpec) Validate() error {
|
||||
if s.DisplayName == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "displayName is required")
|
||||
@@ -75,11 +91,3 @@ func (s *SavedViewSpec) Validate() error {
|
||||
|
||||
return (&qbtypes.CompositeQuery{Queries: s.Queries}).Validate()
|
||||
}
|
||||
|
||||
func (d *SavedViewData) Validate() error {
|
||||
if d.SchemaVersion != SavedViewSchemaVersion {
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "schemaVersion must be %q, got %q", SavedViewSchemaVersion, d.SchemaVersion)
|
||||
}
|
||||
|
||||
return d.Spec.Validate()
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package savedviewtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func validQueries() []qbtypes.QueryEnvelope {
|
||||
@@ -75,7 +78,7 @@ func TestSavedViewSpecValidate(t *testing.T) {
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "selected fields and display are not required",
|
||||
name: "selectedFields and display populated is still valid",
|
||||
spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeTable,
|
||||
@@ -85,6 +88,36 @@ func TestSavedViewSpecValidate(t *testing.T) {
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "nil selectedFields is valid -- neither field is actually required",
|
||||
spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeTable,
|
||||
Queries: validQueries(),
|
||||
SelectedFields: nil,
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty (non-nil) selectedFields is valid",
|
||||
spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeTable,
|
||||
Queries: validQueries(),
|
||||
SelectedFields: []telemetrytypes.TelemetryFieldKey{},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "zero-value display is valid",
|
||||
spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeTable,
|
||||
Queries: validQueries(),
|
||||
Display: Display{},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
@@ -99,37 +132,44 @@ func TestSavedViewSpecValidate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavedViewDataValidate(t *testing.T) {
|
||||
func TestSavedViewSpecJSONUnmarshal_OptionalFields(t *testing.T) {
|
||||
base := `"displayName":"My View","panelType":"table","queries":[{"type":"builder_query","spec":{"signal":"logs","aggregations":[{"expression":"count()"}]}}]`
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
data SavedViewData
|
||||
expectError bool
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{
|
||||
name: "valid data",
|
||||
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "wrong schema version is rejected",
|
||||
data: SavedViewData{SchemaVersion: "v1", Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty schema version is rejected",
|
||||
data: SavedViewData{Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph, Queries: validQueries()}},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid spec is rejected",
|
||||
data: SavedViewData{SchemaVersion: SavedViewSchemaVersion, Spec: SavedViewSpec{DisplayName: "My View", PanelType: PanelTypeGraph}},
|
||||
expectError: true,
|
||||
},
|
||||
{name: "selectedFields and display omitted entirely", json: `{` + base + `}`},
|
||||
{name: "selectedFields and display explicitly null", json: `{` + base + `,"selectedFields":null,"display":null}`},
|
||||
{name: "selectedFields empty array, display empty object", json: `{` + base + `,"selectedFields":[],"display":{}}`},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.data.Validate()
|
||||
var spec SavedViewSpec
|
||||
err := json.Unmarshal([]byte(c.json), &spec)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, spec.Validate())
|
||||
assert.Empty(t, spec.SelectedFields)
|
||||
assert.Equal(t, Display{}, spec.Display)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaVersionValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
schemaVersion SchemaVersion
|
||||
expectError bool
|
||||
}{
|
||||
{name: "valid schema version", schemaVersion: SavedViewSchemaVersion, expectError: false},
|
||||
{name: "wrong schema version is rejected", schemaVersion: SchemaVersion{valuer.NewString("v1")}, expectError: true},
|
||||
{name: "empty schema version is rejected", schemaVersion: SchemaVersion{}, expectError: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := c.schemaVersion.Validate()
|
||||
if c.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
Create(ctx context.Context, view *SavedView) error
|
||||
Get(ctx context.Context, orgID string, id valuer.UUID) (*SavedView, error)
|
||||
Update(ctx context.Context, view *SavedView) error
|
||||
Create(ctx context.Context, view *StorableSavedView) error
|
||||
Get(ctx context.Context, orgID string, id valuer.UUID) (*StorableSavedView, error)
|
||||
Update(ctx context.Context, view *StorableSavedView) error
|
||||
Delete(ctx context.Context, orgID string, id valuer.UUID) error
|
||||
List(ctx context.Context, orgID string, source Source, name string) ([]*SavedView, error)
|
||||
List(ctx context.Context, orgID string, source Source, name string) ([]*StorableSavedView, error)
|
||||
}
|
||||
|
||||
@@ -1,451 +0,0 @@
|
||||
import type { Browser } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createMetricAlertViaApi,
|
||||
createNoDataAlertViaApi,
|
||||
createTracesAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
readTimelineTotal,
|
||||
seedAlertHistoryLogs,
|
||||
seedAlertHistoryMetrics,
|
||||
seedAlertHistoryTraces,
|
||||
setRuleDisabledViaApi,
|
||||
waitForTimelineEntries,
|
||||
waitForTimelineStates,
|
||||
} from '../helpers/alerts';
|
||||
import { expect, test as base, withAdminPage } from './alert-rules';
|
||||
|
||||
// Worker-scoped alert-history fixtures. Extends `alert-rules`, so a spec that
|
||||
// imports `test` from here also gets `alertChannel` / `alertList` / `ownedRules`
|
||||
// — the details specs need a history seed *and* their own throwaway rules.
|
||||
//
|
||||
// Every history row has to come from the ruler actually evaluating a rule (there
|
||||
// is no seeder endpoint for `rule_state_history_v0`), so each fixture pays a
|
||||
// real ruler wait: ~20-35s for the logs fixtures, ~10s for metrics, ~105s for
|
||||
// the firing→resolved wave. Worker scope means one wait per worker instead of
|
||||
// one per test, and Playwright creates each fixture lazily — a spec that never
|
||||
// asks for `resolvedHistory` never pays its 105s.
|
||||
//
|
||||
// See `tests/e2e/specs/alerts/alerts-e2e-coverage.md` §3 for the recipes and the
|
||||
// empirically-measured timings each budget here is derived from.
|
||||
|
||||
/** Distinct `service.name` values SEED-A seeds ⇒ its timeline row count. */
|
||||
const SEED_A_SERVICES = 25;
|
||||
|
||||
/** SEED-F seeds fewer services and a 1m window so it resolves inside ~105s. */
|
||||
const SEED_F_SERVICES = 3;
|
||||
|
||||
/** SEED-E's group-by values ⇒ a 2-row history that fits on one page. */
|
||||
const SEED_E_HOSTS = ['host-0', 'host-1'];
|
||||
|
||||
/** SEED-H seeds just enough services to prove the traces link; keeps the wait short. */
|
||||
const SEED_H_SERVICES = 3;
|
||||
|
||||
/** Non-severity label on SEED-C, so the v1 header's labels row is non-empty. */
|
||||
export const SEED_C_TEAM_LABEL = 'e2e-platform';
|
||||
|
||||
export interface AlertHistorySeed {
|
||||
/** v2 (`schemaVersion: v2alpha1`) rule — the default history subject. */
|
||||
ruleId: string;
|
||||
/** Legacy v1 rule over the same logs. Its `threshold.name` is `warning`. */
|
||||
ruleIdV1: string;
|
||||
channelName: string;
|
||||
/** The `body CONTAINS` marker both rules match. */
|
||||
marker: string;
|
||||
/** The seeded `service.name` values, in creation order. */
|
||||
services: string[];
|
||||
/** Baseline `total` for {@link ruleId}, read after the rule was frozen. */
|
||||
total: number;
|
||||
/** Baseline `total` for {@link ruleIdV1}. */
|
||||
totalV1: number;
|
||||
}
|
||||
|
||||
export interface MetricsHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
metricName: string;
|
||||
hosts: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
>({
|
||||
/**
|
||||
* SEED-A (25-row firing history, v2) **plus** SEED-C (the same logs seen
|
||||
* through a legacy v1 rule). Both rules share one seeded log batch, so the
|
||||
* two ruler waves overlap and the fixture costs roughly one wait, not two.
|
||||
*/
|
||||
alertHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert history ${stamp}`;
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
let ruleIdV1 = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
|
||||
channelId = channel.id;
|
||||
|
||||
// Seed and create in the same breath: the rules only fire while the
|
||||
// records are inside the 5m eval window.
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: SEED_A_SERVICES,
|
||||
servicePrefix: `e2e-ah-svc`,
|
||||
});
|
||||
|
||||
ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v2-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v2',
|
||||
});
|
||||
ruleIdV1 = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v1-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v1',
|
||||
// The v1 header renders `labels` minus `severity`, so without a
|
||||
// second label its labels row is present but empty (AD-02).
|
||||
extraLabels: { team: SEED_C_TEAM_LABEL },
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: SEED_A_SERVICES });
|
||||
await waitForTimelineEntries(page, ruleIdV1, { min: SEED_A_SERVICES });
|
||||
|
||||
// Freeze both before the eval window rolls past the seeded records —
|
||||
// otherwise the resolve wave doubles `total` mid-suite.
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
await setRuleDisabledViaApi(page, ruleIdV1, true);
|
||||
|
||||
return {
|
||||
ruleId,
|
||||
ruleIdV1,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
totalV1: await readTimelineTotal(page, ruleIdV1),
|
||||
};
|
||||
});
|
||||
|
||||
if (seed.total !== SEED_A_SERVICES) {
|
||||
// A different total means the fixture is not what the scenarios were
|
||||
// written against — most likely the resolve wave landed before the
|
||||
// PATCH froze the rule. Fail loudly here rather than let every
|
||||
// downstream count assertion fail with a confusing off-by-N.
|
||||
throw new Error(
|
||||
`SEED-A expected ${SEED_A_SERVICES} timeline rows, got ${seed.total}`,
|
||||
);
|
||||
}
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId, ruleIdV1], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 240_000 },
|
||||
],
|
||||
|
||||
/**
|
||||
* SEED-E — a metrics rule over two hosts. Two things SEED-A can't give:
|
||||
* history rows with **no** related links (links are derived from the rule's
|
||||
* signal), and a 2-row history that fits on a single page.
|
||||
*/
|
||||
metricsHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
const metricName = `e2e_ah_probe_metric_${stamp}`;
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-metrics-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
|
||||
await seedAlertHistoryMetrics(page, {
|
||||
metricName,
|
||||
hosts: SEED_E_HOSTS,
|
||||
});
|
||||
|
||||
ruleId = await createMetricAlertViaApi(page, {
|
||||
name: `e2e-ah-metrics-rule-${stamp}`,
|
||||
metricName,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: SEED_E_HOSTS.length,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
metricName,
|
||||
hosts: SEED_E_HOSTS,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
};
|
||||
});
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 240_000 },
|
||||
],
|
||||
|
||||
/**
|
||||
* SEED-H — a traces rule over seeded spans. The only fixture whose history
|
||||
* rows carry `relatedTracesLink`: the backend derives the link from the
|
||||
* rule's signal and returns either a logs link or a traces link, never both,
|
||||
* so the "View Traces" popover entry is unreachable from SEED-A.
|
||||
*/
|
||||
tracesHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e-aht-span-${stamp}`;
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-traces-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
|
||||
const services = await seedAlertHistoryTraces(page, {
|
||||
marker,
|
||||
services: SEED_H_SERVICES,
|
||||
servicePrefix: 'e2e-aht-svc',
|
||||
});
|
||||
|
||||
ruleId = await createTracesAlertViaApi(page, {
|
||||
name: `e2e-ah-traces-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: SEED_H_SERVICES });
|
||||
// Same reason as SEED-A: freeze before the eval window rolls past the
|
||||
// seeded spans and the resolve wave doubles `total`.
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
};
|
||||
});
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 240_000 },
|
||||
],
|
||||
|
||||
/**
|
||||
* SEED-F — firing **and** resolved, without touching the seeder: a 1m eval
|
||||
* window means the seeded records fall out of it fast, so the rule resolves
|
||||
* on its own in ~105s. This is the only fixture that produces a non-zero
|
||||
* average resolution time and a 3-segment overall-status graph.
|
||||
*/
|
||||
resolvedHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert resolved ${stamp}`;
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-resolved-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: SEED_F_SERVICES,
|
||||
ageSeconds: 40,
|
||||
minAgeSeconds: 28,
|
||||
servicePrefix: 'e2e-ahr-svc',
|
||||
});
|
||||
|
||||
ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-resolved-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
evalWindow: '1m0s',
|
||||
});
|
||||
|
||||
const timeline = await waitForTimelineStates(page, ruleId, {
|
||||
states: {
|
||||
firing: SEED_F_SERVICES,
|
||||
inactive: SEED_F_SERVICES,
|
||||
},
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
|
||||
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
|
||||
};
|
||||
});
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 300_000 },
|
||||
],
|
||||
|
||||
/**
|
||||
* SEED-G — a `nodata` row, reached the same way
|
||||
* `integration/testdata/alerts/test_scenarios/no_data_rule_test` does:
|
||||
* `alertOnAbsent` on a query that matches nothing.
|
||||
*/
|
||||
noDataHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-nodata-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
|
||||
ruleId = await createNoDataAlertViaApi(page, {
|
||||
name: `e2e-ah-nodata-rule-${stamp}`,
|
||||
// Deliberately unseeded — the query must match nothing.
|
||||
marker: `e2e alert nodata ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: 1,
|
||||
state: 'nodata',
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return { ruleId, channelName: channel.name };
|
||||
});
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 300_000 },
|
||||
],
|
||||
|
||||
/**
|
||||
* A rule that will never have history: its query matches nothing and it is
|
||||
* disabled immediately. Covers "no history yet" (empty table, zero stats) and
|
||||
* "no key suggestions" without waiting on the ruler at all.
|
||||
*/
|
||||
emptyHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const stamp = Date.now();
|
||||
let channelId = '';
|
||||
let ruleId = '';
|
||||
|
||||
const seed = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-empty-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
|
||||
ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-empty-rule-${stamp}`,
|
||||
marker: `e2e alert never seeded ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return { ruleId, channelName: channel.name };
|
||||
});
|
||||
|
||||
await use(seed);
|
||||
|
||||
await cleanup(browser, { ruleIds: [ruleId], channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: 120_000 },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,214 +0,0 @@
|
||||
import type { Browser, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
type AlertSchema,
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createThresholdAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
type LogsAlertSeed,
|
||||
seedAlertRules,
|
||||
type ThresholdAlertSeed,
|
||||
} from '../helpers/alerts';
|
||||
import { newAdminContext } from '../helpers/auth';
|
||||
import { expect, test as base } from './auth';
|
||||
|
||||
// Alert *rule* fixtures — the API-only half of the alerts suite. Nothing here
|
||||
// waits on the ruler: a rule is created and that's it. History rows need real
|
||||
// evaluations, so those fixtures live in `fixtures/alert-history.ts`, which
|
||||
// extends this module — a spec importing from there gets both sets.
|
||||
//
|
||||
// Scopes, and why:
|
||||
// `alertChannel` — worker. Every rule payload has to reference a channel by
|
||||
// name, and one channel serves the whole worker.
|
||||
// `alertList` — worker. SEED-B, the read-only rule list the `tests/alerts/
|
||||
// list` specs page, search and sort through. Names and label values are
|
||||
// stamped per worker so parallel batches never count each other's rules.
|
||||
// `ownedRules` — test. Scenarios that rename/toggle/clone/delete a rule seed
|
||||
// their own and have it removed when they finish; mutating a shared seed
|
||||
// would break every scenario scheduled after it.
|
||||
|
||||
export interface AlertChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AlertListSeed {
|
||||
channelName: string;
|
||||
/** Rules are named `<namePrefix>-NN` — unique to this worker's batch. */
|
||||
namePrefix: string;
|
||||
/** Rules seeded ⇒ the `of N` total once the list is scoped to the prefix. */
|
||||
count: number;
|
||||
/** `team` label on the odd-indexed half of the batch, i.e. `count / 2` rules. */
|
||||
paymentsLabel: string;
|
||||
ruleIds: string[];
|
||||
}
|
||||
|
||||
export interface OwnedRules {
|
||||
/** Seed a metric threshold rule this test owns. */
|
||||
threshold(
|
||||
name: string,
|
||||
overrides?: Partial<Omit<ThresholdAlertSeed, 'name'>>,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Seed a logs rule this test owns. No telemetry is seeded for its marker, so
|
||||
* it never fires — enough for anything about the details shell.
|
||||
*
|
||||
* `schema: 'v1'` posts the legacy payload and is SEED-RV1; 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>;
|
||||
}
|
||||
|
||||
/** SEED-B size. 12 over a pinned page size of 10 ⇒ a short second page. */
|
||||
const LIST_SEED_COUNT = 12;
|
||||
|
||||
/**
|
||||
* Run `body` on a throwaway admin page. Worker hooks can't use the test-scoped
|
||||
* `authedPage`, and every API helper needs a page whose context carries the
|
||||
* admin storage state.
|
||||
*/
|
||||
export async function withAdminPage<T>(
|
||||
browser: Browser,
|
||||
body: (page: Page) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
return await body(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRules(browser: Browser, ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
await withAdminPage(browser, async (page) => {
|
||||
for (const id of ids) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteAlertViaApi(page, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const test = base.extend<
|
||||
{ ownedRules: OwnedRules },
|
||||
{ alertChannel: AlertChannel; alertList: AlertListSeed }
|
||||
>({
|
||||
alertChannel: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const channel = await withAdminPage(browser, (page) =>
|
||||
createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-alerts-ch-w${workerInfo.workerIndex}-${Date.now()}`,
|
||||
),
|
||||
);
|
||||
|
||||
await use(channel);
|
||||
|
||||
await withAdminPage(browser, (page) =>
|
||||
deleteChannelViaApi(page, channel.id),
|
||||
);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
alertList: [
|
||||
async ({ browser, alertChannel }, use, workerInfo) => {
|
||||
const stamp = `w${workerInfo.workerIndex}-${Date.now()}`;
|
||||
const namePrefix = `e2e-alert-list-${stamp}`;
|
||||
const teamSuffix = `-${stamp}`;
|
||||
|
||||
const ruleIds = await withAdminPage(browser, (page) =>
|
||||
seedAlertRules(page, {
|
||||
count: LIST_SEED_COUNT,
|
||||
channelName: alertChannel.name,
|
||||
namePrefix,
|
||||
teamSuffix,
|
||||
}),
|
||||
);
|
||||
|
||||
await use({
|
||||
channelName: alertChannel.name,
|
||||
namePrefix,
|
||||
count: LIST_SEED_COUNT,
|
||||
paymentsLabel: `payments${teamSuffix}`,
|
||||
ruleIds,
|
||||
});
|
||||
|
||||
await deleteRules(browser, ruleIds);
|
||||
},
|
||||
{ scope: 'worker', timeout: 120_000 },
|
||||
],
|
||||
|
||||
ownedRules: async ({ browser, alertChannel }, use) => {
|
||||
const ids = new Set<string>();
|
||||
|
||||
const seed = async (
|
||||
create: (page: Page) => Promise<string>,
|
||||
): Promise<string> => {
|
||||
const id = await withAdminPage(browser, create);
|
||||
ids.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
await use({
|
||||
threshold: (name, overrides = {}) =>
|
||||
seed((page) =>
|
||||
createThresholdAlertViaApi(page, {
|
||||
name,
|
||||
target: 42,
|
||||
channels: [alertChannel.name],
|
||||
labels: { severity: 'critical' },
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
|
||||
seed((page) =>
|
||||
createLogsAlertViaApi(page, {
|
||||
name,
|
||||
marker: marker ?? `e2e alert never seeded ${name}`,
|
||||
channels: [alertChannel.name],
|
||||
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));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Best-effort: `deleteAlertViaApi` tolerates a rule a scenario already
|
||||
// deleted through the UI.
|
||||
await deleteRules(browser, [...ids]);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,11 +1,81 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import {
|
||||
test as base,
|
||||
expect,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
|
||||
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
|
||||
// worker-scoped fixtures and suite hooks share one login with this fixture.
|
||||
export { ADMIN };
|
||||
export type { User };
|
||||
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
|
||||
export const ADMIN: User = {
|
||||
email: process.env.SIGNOZ_E2E_USERNAME!,
|
||||
password: process.env.SIGNOZ_E2E_PASSWORD!,
|
||||
};
|
||||
|
||||
// Per-worker storageState cache. One login per unique user per worker.
|
||||
// Promise-valued so concurrent requests share the same in-flight work.
|
||||
// Held in memory only — no .auth/ dir, no JSON on disk.
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await login(page, user);
|
||||
await pinSidenav(page);
|
||||
const state = await ctx.storageState();
|
||||
await ctx.close();
|
||||
return state;
|
||||
})();
|
||||
|
||||
storageByUser.set(user.email, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function login(page: Page, user: User): Promise<void> {
|
||||
if (!user.email || !user.password) {
|
||||
throw new Error(
|
||||
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
|
||||
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
|
||||
);
|
||||
}
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(user.email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
// Post-login lands somewhere different depending on whether the org is
|
||||
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
|
||||
// welcome). Wait for URL to move off /login — whichever page follows
|
||||
// is fine, each spec navigates to the feature under test anyway.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
}
|
||||
|
||||
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
|
||||
// Server-side pref, so set once per user at login.
|
||||
async function pinSidenav(page: Page): Promise<void> {
|
||||
const token = await page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: true },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<{
|
||||
/**
|
||||
@@ -25,7 +95,7 @@ export const test = base.extend<{
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
authedPage: async ({ browser, user }, use) => {
|
||||
const storageState = await storageStateFor(browser, user);
|
||||
const storageState = await storageFor(browser, user);
|
||||
const ctx = await browser.newContext({ storageState });
|
||||
const page = await ctx.newPage();
|
||||
// Opt-in CPU throttling to reproduce GitHub-Linux-runner conditions on
|
||||
|
||||
@@ -1,738 +0,0 @@
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
// Helpers for the alert *create* and *edit* forms — two different form
|
||||
// implementations behind one feature. Rule *seeding* lives in
|
||||
// `helpers/alerts.ts`; this module is about driving the UI, so nothing here talks
|
||||
// to the rules API except through the browser.
|
||||
//
|
||||
// The single most important thing to know before using any of this: v1 and v2
|
||||
// are not two skins over one form, they are two components with different
|
||||
// selectors, different save gates and different success feedback. Every helper
|
||||
// below is therefore named for the form it drives (`v1…` / `v2…`) unless it is
|
||||
// genuinely shared, and the shared set is small.
|
||||
|
||||
// ─── 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,
|
||||
];
|
||||
|
||||
// ─── 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}`);
|
||||
}
|
||||
|
||||
export 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();
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }) });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
// ─── v1 classic form ───────────────────────────────────────────────────────
|
||||
|
||||
// Every locator below addresses a testid added to the classic form for this suite.
|
||||
// Before those, each of these was an antd label or role lookup. If one stops
|
||||
// resolving, the testid was dropped from the component, not renamed here.
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
export function v1TestButton(page: Page): Locator {
|
||||
return page.getByTestId('alert-test-button');
|
||||
}
|
||||
|
||||
/** *Cancel* on create, *Discard* on edit (`FormAlertRules/index.tsx:991-992`). */
|
||||
export function v1CancelButton(page: Page): Locator {
|
||||
return page.getByTestId('alert-cancel-button');
|
||||
}
|
||||
|
||||
export function v1NameInput(page: Page): Locator {
|
||||
return page.getByTestId('alert-name-input-v1');
|
||||
}
|
||||
|
||||
export function v1DescriptionInput(page: Page): Locator {
|
||||
return page.getByTestId('alert-description-input');
|
||||
}
|
||||
|
||||
export function v1SeveritySelect(page: Page): Locator {
|
||||
return page.getByTestId('alert-severity-select');
|
||||
}
|
||||
|
||||
/** The four `RuleOptions` controls, in the order the condition sentence reads. */
|
||||
export function v1OperatorSelect(page: Page): Locator {
|
||||
return page.getByTestId('alert-threshold-op-select');
|
||||
}
|
||||
|
||||
export function v1MatchTypeSelect(page: Page): Locator {
|
||||
return page.getByTestId('alert-threshold-match-type-select-v1');
|
||||
}
|
||||
|
||||
export function v1EvalWindowSelect(page: Page): Locator {
|
||||
return page.getByTestId('alert-eval-window-select');
|
||||
}
|
||||
|
||||
/**
|
||||
* The threshold value. antd's `InputNumber` spreads unknown props straight onto
|
||||
* its inner `<input>` (rc-input-number), *not* onto the `.ant-input-number`
|
||||
* wrapper — so the testid is already the field and looking for an `input`
|
||||
* underneath it finds nothing.
|
||||
*/
|
||||
export function v1ThresholdInput(page: Page): Locator {
|
||||
return page.getByTestId('alert-threshold-target-input');
|
||||
}
|
||||
|
||||
export function v1BroadcastSwitch(page: Page): Locator {
|
||||
return page.getByTestId('alert-broadcast-to-all-channels');
|
||||
}
|
||||
|
||||
export function v1ChannelSelect(page: Page): Locator {
|
||||
return page.getByTestId('alert-channel-select');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a channel in the classic form. Deliberately **not** {@link v1SelectOption}:
|
||||
* that helper scrolls to nothing and clicks the option by label, which cannot work
|
||||
* on a virtualised list — see {@link pickChannelByName} for the measurement. The
|
||||
* other v1 selects (operator, match type, evaluation window, severity) have a
|
||||
* handful of options each and no search box, so they keep using `v1SelectOption`.
|
||||
*/
|
||||
export async function v1SelectChannel(
|
||||
page: Page,
|
||||
channelName: string,
|
||||
): Promise<void> {
|
||||
await pickChannelByName(page, v1ChannelSelect(page), channelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 gates every save behind a confirm dialog: the Save button only opens it
|
||||
* (`FormAlertRules/index.tsx:653-655`), and both the field validation and the
|
||||
* request live in `saveRule`, which the dialog's OK invokes (`:1007-1010`).
|
||||
* A spec that clicks Save and waits for a POST without this step will time out —
|
||||
* and one that expects a *validation error* without it will too.
|
||||
*/
|
||||
export function v1ConfirmDialog(page: Page): Locator {
|
||||
return page.getByTestId('alert-save-confirm-dialog');
|
||||
}
|
||||
|
||||
export async function v1ConfirmSave(page: Page): Promise<void> {
|
||||
await expect(v1ConfirmDialog(page)).toBeVisible();
|
||||
await v1ConfirmDialog(page).getByRole('button', { name: 'OK' }).click();
|
||||
}
|
||||
|
||||
/** Dismiss the confirm dialog without saving — the CV1-07 half that must not POST. */
|
||||
export async function v1CancelSave(page: Page): Promise<void> {
|
||||
await expect(v1ConfirmDialog(page)).toBeVisible();
|
||||
await v1ConfirmDialog(page).getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(v1ConfirmDialog(page)).toBeHidden();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an option in one of v1's antd selects. Scoped through {@link ownDropdown}
|
||||
* because the condition sentence puts four selects side by side, and matched on
|
||||
* the exact label because several share option text (*Above* / *Below* appear in
|
||||
* both the operator and the match-type lists in the anomaly variant).
|
||||
*/
|
||||
export async function v1SelectOption(
|
||||
page: Page,
|
||||
select: Locator,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
await select.click();
|
||||
const dropdown = await ownDropdown(page, select);
|
||||
await dropdown
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ has: page.getByText(label, { exact: true }) })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The channel select is `mode="multiple"` (`ChannelSelect/index.tsx:91`), so it
|
||||
// stays open after a pick and its list overlays the controls below — which the
|
||||
// next interaction would hit instead of its target. Escape closes it; on the
|
||||
// single selects it is a no-op.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(select).not.toHaveClass(/ant-select-open/);
|
||||
}
|
||||
|
||||
/** Switch the v1 query section to another query mode (`QuerySection.tsx`). */
|
||||
export async function v1SelectQueryMode(
|
||||
page: Page,
|
||||
mode: 'query-builder' | 'promql' | 'clickhouse',
|
||||
): Promise<void> {
|
||||
await page.getByTestId(`${mode}-tab`).click();
|
||||
}
|
||||
|
||||
// ─── 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: [] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,123 +1,34 @@
|
||||
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
/** Default user — admin from the pytest bootstrap (.env.local) or staging .env. */
|
||||
export const ADMIN: User = {
|
||||
email: process.env.SIGNOZ_E2E_USERNAME!,
|
||||
password: process.env.SIGNOZ_E2E_PASSWORD!,
|
||||
};
|
||||
import type { Browser, BrowserContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* `browser.newContext()` only inherits `use.baseURL` while a *test* is in
|
||||
* scope. Worker-scoped fixtures (and their teardown) run outside that, where a
|
||||
* relative `page.goto('/login')` fails with "Cannot navigate to invalid URL" —
|
||||
* so pass it explicitly whenever we know it. Left empty when the var is unset
|
||||
* so the config's staging default still applies inside a test.
|
||||
*/
|
||||
const contextDefaults: { baseURL?: string } = process.env.SIGNOZ_E2E_BASE_URL
|
||||
? { baseURL: process.env.SIGNOZ_E2E_BASE_URL }
|
||||
: {};
|
||||
|
||||
// Per-worker storageState cache. One UI login per unique user per worker
|
||||
// process, shared by everything in that worker: the `authedPage` fixture, the
|
||||
// worker-scoped seed fixtures, and their teardown. Promise-valued so concurrent
|
||||
// callers await the same in-flight login rather than racing several of their
|
||||
// own. Held in memory only — no .auth/ dir, no JSON on disk.
|
||||
//
|
||||
// This cache is why `newAdminContext` is cheap. It used to log in through the
|
||||
// UI on every call, and the alerts fixtures call it a dozen-plus times per
|
||||
// worker (channel, rule list, five history seeds, one per owned rule, plus a
|
||||
// teardown for each) — a couple of seconds each, paid over and over for a
|
||||
// session that never changes.
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
async function login(page: Page, user: User): Promise<void> {
|
||||
if (!user.email || !user.password) {
|
||||
throw new Error(
|
||||
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
|
||||
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
|
||||
);
|
||||
}
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(user.email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
// Post-login lands somewhere different depending on whether the org is
|
||||
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
|
||||
// welcome). Wait for URL to move off /login — whichever page follows
|
||||
// is fine, each spec navigates to the feature under test anyway.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
}
|
||||
|
||||
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
|
||||
// Server-side pref, so set once per user at login.
|
||||
async function pinSidenav(page: Page): Promise<void> {
|
||||
const token = await page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: true },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
const text = await res.text();
|
||||
// Two workers logging in at the same moment both insert the preference and
|
||||
// the loser gets a 500 on `uq_user_preference_name_user_id`. The write it
|
||||
// lost to set the same value, so the preference *is* pinned — treat the
|
||||
// duplicate as success rather than failing an unrelated test.
|
||||
if (text.includes('uq_user_preference_name_user_id')) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${text}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticated storage state for `user`, logging in once per worker. Callers
|
||||
* hand the result to `browser.newContext({ storageState })`.
|
||||
*/
|
||||
export function storageStateFor(
|
||||
browser: Browser,
|
||||
user: User = ADMIN,
|
||||
): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const ctx = await browser.newContext(contextDefaults);
|
||||
const page = await ctx.newPage();
|
||||
await login(page, user);
|
||||
await pinSidenav(page);
|
||||
const state = await ctx.storageState();
|
||||
await ctx.close();
|
||||
return state;
|
||||
})();
|
||||
|
||||
storageByUser.set(user.email, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an authenticated admin `BrowserContext`. Used by suite hooks
|
||||
* (`test.beforeAll` / `test.afterAll`) and worker-scoped fixtures, where the
|
||||
* test-scoped `authedPage` fixture from `fixtures/auth.ts` is not reachable.
|
||||
* Build a fresh authenticated `BrowserContext` via UI login. Used by suite
|
||||
* hooks (`test.beforeAll` / `test.afterAll`), where the test-scoped
|
||||
* `authedPage` fixture from `fixtures/auth.ts` is not reachable.
|
||||
*
|
||||
* Reuses this worker's cached session, so only the first call in a worker pays
|
||||
* for a login. The caller owns the context and must close it.
|
||||
* Each call performs one fresh login (~1s). The per-worker storageState
|
||||
* cache in `fixtures/auth.ts` is intentionally not shared here — keeping
|
||||
* this helper standalone avoids coupling suite hooks to the fixture's
|
||||
* private cache.
|
||||
*/
|
||||
export async function newAdminContext(
|
||||
browser: Browser,
|
||||
): Promise<BrowserContext> {
|
||||
return browser.newContext({
|
||||
...contextDefaults,
|
||||
storageState: await storageStateFor(browser, ADMIN),
|
||||
});
|
||||
const email = process.env.SIGNOZ_E2E_USERNAME;
|
||||
const password = process.env.SIGNOZ_E2E_PASSWORD;
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
'SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD must be set ' +
|
||||
'(pytest bootstrap writes them to .env.local).',
|
||||
);
|
||||
}
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
await page.close();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page, Request } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
// Shared helpers used across feature-specific helper modules (dashboards,
|
||||
// trace-details, …). Keep this to genuinely cross-feature utilities.
|
||||
@@ -18,108 +18,6 @@ export function seederUrl(): string {
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── Console / network noise ──────────────────────────────────────────────
|
||||
|
||||
// Requests the bootstrap stack always fails, on every page, for reasons that
|
||||
// have nothing to do with the feature under test. Keep this list tiny and give
|
||||
// every entry a reason — it is a deny-list of *environment* noise, never of real
|
||||
// application errors.
|
||||
const HARNESS_FAILING_REQUESTS = [
|
||||
// Zeus is a WireMock stub with no /api/v2/zeus/hosts mapping, so the app
|
||||
// shell's workspace-URL lookup 404s on every page load. It reaches the console
|
||||
// three ways: the resource-load error, the AxiosError, and the literal `any`
|
||||
// that `api/ErrorResponseHandler.ts`'s fallback branch logs.
|
||||
'/api/v2/zeus/hosts',
|
||||
// The app shell polls GitHub for the latest release. Unauthenticated calls
|
||||
// from CI/dev machines get rate-limited (403), which has nothing to do with
|
||||
// the page under test.
|
||||
'api.github.com',
|
||||
];
|
||||
|
||||
// The console side of {@link HARNESS_FAILING_REQUESTS}. Browsers log a
|
||||
// resource-load error without the URL, so these have to be matched on text —
|
||||
// which is why the URL list above is the precise half of the check.
|
||||
const HARNESS_CONSOLE_NOISE = [
|
||||
'Failed to load resource: the server responded with a status of 404 (Not Found)',
|
||||
'Failed to load resource: the server responded with a status of 403',
|
||||
'Request failed with status code 404',
|
||||
'client never received a response, or request never left',
|
||||
'any',
|
||||
];
|
||||
|
||||
export interface ConsoleWatch {
|
||||
/** Console `error` entries and uncaught page errors, harness noise removed. */
|
||||
errors: string[];
|
||||
/** `"<status> <method> <url>"` for every 4xx/5xx, harness noise removed. */
|
||||
failedResponses: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a page for console errors and failed requests. Call **before** the first
|
||||
* navigation; the returned object fills in as the page runs, so assert on it at
|
||||
* the end of the scenario.
|
||||
*
|
||||
* Console text alone is a weak signal (the harness's Zeus 404 produces three
|
||||
* generic-looking entries), so the failed-response list is the precise half:
|
||||
* text matching is deliberately loose while the URL check stays strict.
|
||||
*/
|
||||
export function watchConsole(
|
||||
page: Page,
|
||||
/**
|
||||
* Extra substrings to ignore. Use this — with a comment naming the defect —
|
||||
* for a *known application* bug that is out of the spec's scope, so the rest
|
||||
* of the console assertion keeps its value instead of being deleted.
|
||||
*/
|
||||
options: { ignore?: string[] } = {},
|
||||
): ConsoleWatch {
|
||||
const watch: ConsoleWatch = { errors: [], failedResponses: [] };
|
||||
const noise = [...HARNESS_CONSOLE_NOISE, ...(options.ignore ?? [])];
|
||||
const isNoise = (text: string): boolean =>
|
||||
noise.some((entry) => text.includes(entry));
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error' && !isNoise(msg.text())) {
|
||||
watch.errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
if (!isNoise(String(err))) {
|
||||
watch.errors.push(String(err));
|
||||
}
|
||||
});
|
||||
page.on('response', (res) => {
|
||||
if (res.status() < 400) {
|
||||
return;
|
||||
}
|
||||
const url = res.url();
|
||||
if (HARNESS_FAILING_REQUESTS.some((entry) => url.includes(entry))) {
|
||||
return;
|
||||
}
|
||||
watch.failedResponses.push(
|
||||
`${res.status()} ${res.request().method()} ${url}`,
|
||||
);
|
||||
});
|
||||
return watch;
|
||||
}
|
||||
|
||||
// ─── Network capture ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every request the page issues from now on. Call **before** the first
|
||||
* navigation — the returned array fills in as the page runs, so filter it at the
|
||||
* end of the scenario ("endpoint called exactly once", "no legacy route used").
|
||||
*/
|
||||
export function collectRequests(page: Page): Request[] {
|
||||
const requests: Request[] = [];
|
||||
page.on('request', (request) => requests.push(request));
|
||||
return requests;
|
||||
}
|
||||
|
||||
/** A request's URL, parsed — the readable way to reach `searchParams`. */
|
||||
export function requestUrl(request: Request): URL {
|
||||
return new URL(request.url());
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Read the app JWT from the context's stored auth state. No navigation needed:
|
||||
|
||||
@@ -5,12 +5,7 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --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:all": "playwright test",
|
||||
"test:staging": "SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:headed": "playwright test --headed",
|
||||
|
||||
@@ -1,37 +1,15 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// Precedence, lowest to highest:
|
||||
// .env — user-provided defaults (staging creds)
|
||||
// .env.local — written by tests/e2e/bootstrap/setup.py when the pytest
|
||||
// lifecycle brings the backend up locally, so it must win over
|
||||
// any stale .env value
|
||||
// the real environment — anything the caller exported on purpose, e.g.
|
||||
// `SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test` to run
|
||||
// against a locally served frontend, or the vars pytest injects
|
||||
// when it shells out to `pnpm test`.
|
||||
//
|
||||
// This is deliberately *not* `dotenv.config({ override: true })`: that flag
|
||||
// makes the file beat process.env, so an exported SIGNOZ_E2E_BASE_URL was
|
||||
// silently discarded and every run went to whatever .env.local pointed at.
|
||||
// Parsing by hand is the only way to get ".env.local beats .env" without also
|
||||
// getting ".env.local beats the caller".
|
||||
const exported = new Set(Object.keys(process.env));
|
||||
for (const file of ['.env', '.env.local']) {
|
||||
const filePath = path.resolve(__dirname, file);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
const parsed = dotenv.parse(fs.readFileSync(filePath));
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (!exported.has(key)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// .env holds user-provided defaults (staging creds).
|
||||
// .env.local is written by tests/e2e/bootstrap/setup.py when the pytest
|
||||
// lifecycle brings the backend up locally; override=true so local-backend
|
||||
// coordinates win over any stale .env values. Subprocess-injected env
|
||||
// (e.g. when pytest shells out to `pnpm test`) still takes priority —
|
||||
// dotenv doesn't touch vars that are already set in process.env.
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env.local'), override: true });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
@@ -55,17 +33,8 @@ export default defineConfig({
|
||||
// Retry on CI only
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// 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))),
|
||||
// Workers
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
|
||||
// The SPA hydrates slowly on CI, so the 5s expect default fires mid-load.
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
67
tests/e2e/tests/alerts/alerts.spec.ts
Normal file
67
tests/e2e/tests/alerts/alerts.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { expect, test } from '../../fixtures/auth';
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createThresholdAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
gotoAlertOverview,
|
||||
} from '../../helpers/alerts';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
|
||||
test('TC-01 alerts page — tabs render', async ({ authedPage: page }) => {
|
||||
await page.goto('/alerts');
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: /configuration/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe('alerts — threshold persists on edit-page load', () => {
|
||||
const TARGET = 245;
|
||||
let ruleId: string;
|
||||
let channelId: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const stamp = Date.now();
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-threshold-persistence-ch-${stamp}`,
|
||||
);
|
||||
channelId = channel.id;
|
||||
ruleId = await createThresholdAlertViaApi(page, {
|
||||
name: `e2e-threshold-persistence-${stamp}`,
|
||||
target: TARGET,
|
||||
channels: [channel.name],
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
if (ruleId) {
|
||||
await deleteAlertViaApi(page, ruleId);
|
||||
}
|
||||
if (channelId) {
|
||||
await deleteChannelViaApi(page, channelId);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 edit page shows the saved threshold value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
// The condition editor should show the persisted target once loaded.
|
||||
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
|
||||
String(TARGET),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
deleteChannelViaApi,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
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('NC-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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,137 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
AlertType,
|
||||
elementAtPointClassName,
|
||||
gotoCreateAlertV1,
|
||||
gotoCreateAlertV2,
|
||||
selectThresholdChannel,
|
||||
v1SaveButton,
|
||||
v2DiscardButton,
|
||||
v2SaveButton,
|
||||
} from '../../../helpers/alert-forms';
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
deleteChannelViaApi,
|
||||
gotoAlertDetails,
|
||||
gotoAlertOverview,
|
||||
} from '../../../helpers/alerts';
|
||||
import { watchConsole } from '../../../helpers/common';
|
||||
|
||||
// CE-* — errors and edges that are not specific to one form. CE-03 lives in
|
||||
// `edit/edge.spec.ts`; CE-05/CE-06 are v1-only validation and live with the v1
|
||||
// specs.
|
||||
|
||||
test.describe('Alert create — errors and edges', () => {
|
||||
test('CE-04 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('CE-07 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('CE-09 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');
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
ALERTS_NEW_PATH,
|
||||
AlertType,
|
||||
type AlertTypeValue,
|
||||
evaluationSettingsButton,
|
||||
gotoCreateAlertV2,
|
||||
RuleType,
|
||||
thresholdRows,
|
||||
ThresholdMatchType,
|
||||
ThresholdOperator,
|
||||
} from '../../../helpers/alert-forms';
|
||||
import { gotoAlertOverview } from '../../../helpers/alerts';
|
||||
|
||||
// CD-* — 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('CD-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('CD-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('CD-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('CD-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('CD-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('CD-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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
AlertListTab,
|
||||
AlertType,
|
||||
alertTypeCard,
|
||||
expectAlertTypeCardSet,
|
||||
createAlertUrl,
|
||||
gotoAlertTypeSelection,
|
||||
gotoCreateAlertV1,
|
||||
gotoCreateAlertV2,
|
||||
hasAnomalyAlertTypeCard,
|
||||
RuleType,
|
||||
STOCK_ALERT_TYPE_CARDS,
|
||||
v1SaveButton,
|
||||
} from '../../../helpers/alert-forms';
|
||||
|
||||
// CS-01 … CS-08 — 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('CS-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('CS-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('CS-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('CS-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('CS-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('CS-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('CS-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('CS-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();
|
||||
});
|
||||
});
|
||||
@@ -1,537 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
AlertType,
|
||||
alertTypeCard,
|
||||
gotoAlertTypeSelection,
|
||||
gotoCreateAlertV1,
|
||||
stubNoChannels,
|
||||
v1BroadcastSwitch,
|
||||
v1CancelButton,
|
||||
v1CancelSave,
|
||||
v1ChannelSelect,
|
||||
v1ConfirmDialog,
|
||||
v1ConfirmSave,
|
||||
v1DescriptionInput,
|
||||
v1EvalWindowSelect,
|
||||
v1MatchTypeSelect,
|
||||
v1NameInput,
|
||||
v1OperatorSelect,
|
||||
v1SelectChannel,
|
||||
v1SelectOption,
|
||||
v1SelectQueryMode,
|
||||
v1SeveritySelect,
|
||||
v1TestButton,
|
||||
v1ThresholdInput,
|
||||
v1SaveButton,
|
||||
} from '../../../helpers/alert-forms';
|
||||
|
||||
// CV1-* — the v1 classic create form, reached with
|
||||
// `showClassicCreateAlertsPage=true`. CE-05 and CE-06 live here too: both are
|
||||
// PromQL/ClickHouse validation, which only the classic form has.
|
||||
//
|
||||
// Three things about this form drive almost every row below, and all three differ
|
||||
// from v2:
|
||||
// 1. Save only *opens* a confirm dialog. Both the field validation and the
|
||||
// request run behind its OK, so a validation message is never visible until
|
||||
// the dialog has been confirmed.
|
||||
// 2. The Save/Test buttons are disabled by `isAlertNameMissing ||
|
||||
// !isChannelConfigurationValid || queryStatus === 'error'`, so a row that
|
||||
// wants to reach the dialog has to satisfy the name *and* the channels first.
|
||||
// 3. Validation failures surface as antd **notifications**, not inline errors.
|
||||
|
||||
const VALIDATION = {
|
||||
targetMissing: 'Please enter a threshold to proceed',
|
||||
promql: 'promql expression is required when query format is set to PromQL',
|
||||
clickhouse: 'query is required when query format is set to ClickHouse',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Alert type for every row that actually saves.
|
||||
*
|
||||
* A metrics-based rule is rejected by the **server** with
|
||||
* `400 invalid query 'A': metric name is required for aggregation #1`, because the
|
||||
* metrics default query has no metric selected and picking one is query-builder
|
||||
* territory. A logs-based alert's default query is valid with no seeded data, which
|
||||
* is the same reason every `CV2-*` row uses one. Rows that assert on *rendering*
|
||||
* stay metrics-based, and so does CE-05 — PromQL is offered for metrics only.
|
||||
*/
|
||||
const SAVEABLE = AlertType.LOGS;
|
||||
|
||||
/** Every v1 save and test goes to the same endpoint the v2 builder uses. */
|
||||
function isRuleCreate(url: string, method: string): boolean {
|
||||
return method === 'POST' && new URL(url).pathname === '/api/v2/rules';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the minimum a v1 rule needs before Save stops being disabled: a name, a
|
||||
* channel and a threshold.
|
||||
*/
|
||||
async function fillMinimalV1Rule(
|
||||
page: import('@playwright/test').Page,
|
||||
{ name, channelName }: { name: string; channelName: string },
|
||||
): Promise<void> {
|
||||
await v1NameInput(page).fill(name);
|
||||
await v1ThresholdInput(page).fill('5');
|
||||
await v1SelectChannel(page, channelName);
|
||||
await expect(v1SaveButton(page)).toBeEnabled();
|
||||
}
|
||||
|
||||
test.describe('Alert create — v1 classic form', () => {
|
||||
test('CV1-01 the classic form renders its steps and the create-mode labels', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Whether the detection-method step renders depends on `ANOMALY_DETECTION`, the
|
||||
// same flag CS-01 reads off the type-selection page. Read it there rather than
|
||||
// assuming, so this stays one unconditional assertion whichever way it falls.
|
||||
await gotoAlertTypeSelection(page);
|
||||
const anomalyEnabled =
|
||||
(await alertTypeCard(page, AlertType.ANOMALY).count()) > 0;
|
||||
|
||||
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
|
||||
|
||||
await expect(page.getByText('Metrics Based Alert')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Alert Setup Guide' }),
|
||||
).toBeVisible();
|
||||
|
||||
// `isNewRule` decides both action labels.
|
||||
await expect(v1SaveButton(page)).toHaveText(/Create Rule/);
|
||||
await expect(v1CancelButton(page)).toHaveText(/Cancel/);
|
||||
|
||||
await expect(page.getByText('Define the metric')).toBeVisible();
|
||||
await expect(page.getByText('Define Alert Conditions')).toBeVisible();
|
||||
await expect(page.getByText('Alert Configuration')).toBeVisible();
|
||||
|
||||
await expect(page.locator('.detection-method-container')).toHaveCount(
|
||||
anomalyEnabled ? 1 : 0,
|
||||
);
|
||||
});
|
||||
|
||||
test('CV1-02 the rendered severity is the default from the rule, not the select', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
|
||||
|
||||
// Two defaults disagree: `alertDefaults.labels.severity` is `warning` while the
|
||||
// select's own `defaultValue` prop says `critical`. The antd Form's
|
||||
// `initialValues` wins, so what a user sees — and what the payload carries — is
|
||||
// *warning*.
|
||||
await expect(v1SeveritySelect(page)).toContainText('Warning');
|
||||
});
|
||||
|
||||
test('CV1-03 one keystroke in the name field is enough to enable Save', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
|
||||
// Satisfy the other two gates first so the name is the only one left.
|
||||
await v1ThresholdInput(page).fill('5');
|
||||
await v1SelectChannel(page, alertChannel.name);
|
||||
await expect(v1SaveButton(page)).toBeDisabled();
|
||||
await expect(v1TestButton(page)).toBeDisabled();
|
||||
|
||||
// `isAlertNameMissing` is `!formInstance.getFieldValue('alert')` read during
|
||||
// render — not a subscription. It only ever looks fresh because `setAlertDef`
|
||||
// re-renders on every keystroke, so the realistic failure is the *first*
|
||||
// character. One character, no second keystroke, no blur.
|
||||
await v1NameInput(page).pressSequentially('a');
|
||||
await expect(v1SaveButton(page)).toBeEnabled();
|
||||
await expect(v1TestButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
test('CV1-04 Save stays disabled until the channel configuration resolves', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
await v1NameInput(page).fill(`e2e-cv1-04-${Date.now()}`);
|
||||
await v1ThresholdInput(page).fill('5');
|
||||
|
||||
// A new rule starts with the broadcast switch **off** and no preferred channels,
|
||||
// so `isChannelConfigurationValid` is false and there is no message anywhere —
|
||||
// just a dead button. That silence is what this row pins.
|
||||
await expect(v1BroadcastSwitch(page)).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false',
|
||||
);
|
||||
await expect(v1SaveButton(page)).toBeDisabled();
|
||||
|
||||
await v1SelectChannel(page, alertChannel.name);
|
||||
await expect(v1SaveButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
// TODO: enable once the `broadcastToAll` bug is fixed.
|
||||
//
|
||||
// 🐞 **"Alert all the configured channels" is broken end to end**, so this row
|
||||
// asserts the *intended* behaviour and fails today:
|
||||
//
|
||||
// 1. `BasicInfo`'s switch sets `alertDef.broadcastToAll` and unmounts the
|
||||
// channel select, so no channel can be picked while it is on.
|
||||
// 2. `preparePostData` blanks `preferredChannels` when `broadcastToAll` is set
|
||||
// (`FormAlertRules/index.tsx`).
|
||||
// 3. `toPostableRuleDTOFromAlertDef` (`types/api/alerts/convert.ts`) **never
|
||||
// copies `broadcastToAll`** into the DTO.
|
||||
//
|
||||
// The request therefore says "no channels, no broadcast" and the server answers
|
||||
// `400 at least one channel is required`, which surfaces as the generic error
|
||||
// modal. Observed live: `body.broadcastToAll === undefined`, status `400`,
|
||||
// `.error-modal__wrap` visible, still on `/alerts/new`.
|
||||
//
|
||||
// The fix is to send the field (or to delete the switch). When it lands, unskip.
|
||||
test.skip('CV1-05 broadcast-to-all saves the rule with the broadcast flag', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
expect(alertChannel.name).toBeTruthy();
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
await v1NameInput(page).fill(`e2e-cv1-05-${Date.now()}`);
|
||||
await v1ThresholdInput(page).fill('5');
|
||||
|
||||
await v1BroadcastSwitch(page).click();
|
||||
|
||||
// The select is unmounted, not disabled, so "pick a channel" stops being
|
||||
// possible rather than becoming optional — the switch has to carry the intent
|
||||
// on its own.
|
||||
await expect(v1ChannelSelect(page)).toHaveCount(0);
|
||||
await expect(v1SaveButton(page)).toBeEnabled();
|
||||
|
||||
await v1SaveButton(page).click();
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
|
||||
v1ConfirmSave(page),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
// The broadcast intent must survive the DTO conversion: an empty channel list
|
||||
// is only valid when the flag that replaces it is present.
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.broadcastToAll).toBe(true);
|
||||
expect(body.preferredChannels).toEqual([]);
|
||||
|
||||
expect(response.status(), await response.text()).toBe(201);
|
||||
await expect(page.locator('.error-modal__wrap')).toBeHidden();
|
||||
await expect(page.getByText('Rule created successfully')).toBeVisible();
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts');
|
||||
});
|
||||
|
||||
test('CV1-06 a cleared threshold is coerced to 0, so the required-threshold branch is dead', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
await v1NameInput(page).fill(`e2e-cv1-06-${Date.now()}`);
|
||||
await v1SelectChannel(page, alertChannel.name);
|
||||
|
||||
// `Please enter a threshold to proceed` cannot be reached from the UI at all:
|
||||
//
|
||||
// 1. The field renders `0`, not empty — on metrics, logs and traces alike.
|
||||
// 2. `RuleOptions`'s `onChange` writes `Number(value) || 0`, so clearing the
|
||||
// input stores 0 rather than nothing.
|
||||
// 3. `validateQBParams` guards `target !== 0 && !target`, i.e. it
|
||||
// *deliberately* treats 0 as a valid threshold.
|
||||
//
|
||||
// What follows asserts the reachable behaviour and pins each link in that chain.
|
||||
await expect(v1ThresholdInput(page)).toHaveValue('0');
|
||||
await v1ThresholdInput(page).fill('');
|
||||
await expect(v1SaveButton(page)).toBeEnabled();
|
||||
|
||||
await v1SaveButton(page).click();
|
||||
await expect(v1ConfirmDialog(page)).toBeVisible();
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
|
||||
v1ConfirmSave(page),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
// A rule saved with a threshold of 0 rather than a validation error.
|
||||
expect(response.status(), await response.text()).toBe(201);
|
||||
expect(response.request().postDataJSON().condition.target).toBe(0);
|
||||
await expect(page.getByText(VALIDATION.targetMissing)).toBeHidden();
|
||||
});
|
||||
|
||||
test('CV1-07 cancelling the confirm dialog does not save', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
await fillMinimalV1Rule(page, {
|
||||
name: `e2e-cv1-07-${Date.now()}`,
|
||||
channelName: alertChannel.name,
|
||||
});
|
||||
|
||||
let sawPost = false;
|
||||
page.on('request', (request) => {
|
||||
if (isRuleCreate(request.url(), request.method())) {
|
||||
sawPost = true;
|
||||
}
|
||||
});
|
||||
|
||||
await v1SaveButton(page).click();
|
||||
await expect(v1ConfirmDialog(page)).toContainText('Your alert built with');
|
||||
await v1CancelSave(page);
|
||||
|
||||
expect(sawPost).toBe(false);
|
||||
// Still on the form, nothing lost.
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts/new');
|
||||
await expect(v1SaveButton(page)).toBeEnabled();
|
||||
});
|
||||
|
||||
test('CV1-08 the happy path posts the v1 body shape to the shared endpoint', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
const name = `e2e-cv1-08-${Date.now()}`;
|
||||
await fillMinimalV1Rule(page, { name, channelName: alertChannel.name });
|
||||
|
||||
await v1SaveButton(page).click();
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
|
||||
v1ConfirmSave(page),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
// The server's message is folded into the assertion: a v1 payload rejection is
|
||||
// only diagnosable from its text.
|
||||
expect(response.status(), await response.text()).toBe(201);
|
||||
|
||||
// The endpoint is shared with v2 — there is no `/api/v1/rules` client in the
|
||||
// frontend — so the *body* is the only thing that distinguishes the two forms.
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.condition.target).toBe(5);
|
||||
expect(body.condition.op).toBe('1');
|
||||
// `4` is *in total*, not the `defaultMatchType` of `1` (*at least once*): the
|
||||
// per-signal defaults disagree with the shared one. `logAlertDefaults`,
|
||||
// `traceAlertDefaults` and `exceptionAlertDefaults` all hardcode `matchType: '4'`
|
||||
// while `alertDefaults` — metrics — uses `defaultMatchType`.
|
||||
expect(body.condition.matchType).toBe('4');
|
||||
expect(body.evalWindow).toBe('5m0s');
|
||||
expect(body.preferredChannels).toEqual([alertChannel.name]);
|
||||
expect(body.labels.severity).toBe('warning');
|
||||
// No v2 threshold envelope and no v2 schema marker anywhere in it.
|
||||
expect(body.schemaVersion).toBeUndefined();
|
||||
expect(body.condition.thresholds).toBeUndefined();
|
||||
|
||||
// antd notification, not a sonner toast — the other half of the v1/v2 split.
|
||||
await expect(page.getByText('Rule created successfully')).toBeVisible();
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts');
|
||||
});
|
||||
|
||||
test('CV1-09 CV1-10 description, labels and severity all land in the payload', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
await fillMinimalV1Rule(page, {
|
||||
name: `e2e-cv1-09-${Date.now()}`,
|
||||
channelName: alertChannel.name,
|
||||
});
|
||||
|
||||
await v1DescriptionInput(page).fill('raised by the e2e suite');
|
||||
|
||||
// The label editor is a two-phase input — key, ENTER, value, ENTER — sharing one
|
||||
// field, and it writes the whole label map back.
|
||||
const labels = page.getByTestId('alert-labels-input-v1');
|
||||
await labels.fill('team');
|
||||
await labels.press('Enter');
|
||||
await labels.fill('payments');
|
||||
await labels.press('Enter');
|
||||
|
||||
// Severity is just another label in the payload, which is why the two halves of
|
||||
// this row belong together: the editor above must not have dropped it.
|
||||
await v1SelectOption(page, v1SeveritySelect(page), 'Critical');
|
||||
|
||||
await v1SaveButton(page).click();
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
|
||||
v1ConfirmSave(page),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.annotations.description).toBe('raised by the e2e suite');
|
||||
expect(body.labels).toMatchObject({
|
||||
team: 'payments',
|
||||
severity: 'critical',
|
||||
});
|
||||
});
|
||||
|
||||
test('CV1-11 test notification skips the dialog and reports no matching data', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
await fillMinimalV1Rule(page, {
|
||||
name: `e2e-cv1-11-${Date.now()}`,
|
||||
channelName: alertChannel.name,
|
||||
});
|
||||
|
||||
// `onTestRuleHandler` validates inline — the confirm dialog is the *save* path
|
||||
// only, so a spec that waits for it here hangs.
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes('/api/v2/rules/test') && r.request().method() === 'POST',
|
||||
),
|
||||
v1TestButton(page).click(),
|
||||
]);
|
||||
expect(response.ok()).toBe(true);
|
||||
await expect(v1ConfirmDialog(page)).toHaveCount(0);
|
||||
|
||||
// A threshold of 5 on a rule nobody is feeding evaluates fine and matches nothing,
|
||||
// which is an *error* notification rather than a success one. 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|Success/),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('CV1-12 with no channels the form is a dead end', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// v2 at least offers routing policies; v1 has no equivalent escape.
|
||||
await stubNoChannels(page);
|
||||
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
|
||||
await v1NameInput(page).fill(`e2e-cv1-12-${Date.now()}`);
|
||||
await v1ThresholdInput(page).fill('5');
|
||||
|
||||
// `noChannels` disables the switch, so the one control that could satisfy
|
||||
// `isChannelConfigurationValid` without picking a channel is gone.
|
||||
await expect(v1BroadcastSwitch(page)).toBeDisabled();
|
||||
|
||||
// And the select that is still on screen offers nothing but a way out of the page.
|
||||
await v1ChannelSelect(page).click();
|
||||
await expect(
|
||||
page.getByText('Create a new channel', { exact: false }),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a notification channel' }),
|
||||
).toBeVisible();
|
||||
// ⇒ no reachable state saves this rule.
|
||||
await expect(v1SaveButton(page)).toBeDisabled();
|
||||
});
|
||||
|
||||
test('CV1-13 Cancel leaves the form without saving', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
await fillMinimalV1Rule(page, {
|
||||
name: `e2e-cv1-13-${Date.now()}`,
|
||||
channelName: alertChannel.name,
|
||||
});
|
||||
|
||||
let sawPost = false;
|
||||
page.on('request', (request) => {
|
||||
if (isRuleCreate(request.url(), request.method())) {
|
||||
sawPost = true;
|
||||
}
|
||||
});
|
||||
|
||||
// A plain click, unlike v2's Discard: v1 has no fixed footer for the side
|
||||
// navigation to cover (CE-09).
|
||||
await v1CancelButton(page).click();
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
expect(sawPost).toBe(false);
|
||||
});
|
||||
|
||||
test('CE-05 an empty PromQL expression is rejected behind the dialog', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
|
||||
await fillMinimalV1Rule(page, {
|
||||
name: `e2e-ce05-${Date.now()}`,
|
||||
channelName: alertChannel.name,
|
||||
});
|
||||
|
||||
// PromQL is offered for metrics-based alerts only; the logs/traces/exceptions
|
||||
// tab set has ClickHouse but no PromQL.
|
||||
await v1SelectQueryMode(page, 'promql');
|
||||
|
||||
let sawPost = false;
|
||||
page.on('request', (request) => {
|
||||
if (isRuleCreate(request.url(), request.method())) {
|
||||
sawPost = true;
|
||||
}
|
||||
});
|
||||
|
||||
await v1SaveButton(page).click();
|
||||
await v1ConfirmSave(page);
|
||||
|
||||
await expect(page.getByText(VALIDATION.promql)).toBeVisible();
|
||||
expect(sawPost).toBe(false);
|
||||
});
|
||||
|
||||
test('CE-06 an empty ClickHouse query is rejected behind the dialog', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
}) => {
|
||||
// Metrics-based, and not interchangeable with the logs form: `logAlertDefaults`
|
||||
// ships a **prefilled** ClickHouse query, so on a logs alert the expression is
|
||||
// never empty and `chquery_required` cannot fire. Only `alertDefaults` (metrics)
|
||||
// starts with `query: ''`.
|
||||
await gotoCreateAlertV1(page, { alertType: AlertType.METRICS });
|
||||
await fillMinimalV1Rule(page, {
|
||||
name: `e2e-ce06-${Date.now()}`,
|
||||
channelName: alertChannel.name,
|
||||
});
|
||||
|
||||
await v1SelectQueryMode(page, 'clickhouse');
|
||||
|
||||
let sawPost = false;
|
||||
page.on('request', (request) => {
|
||||
if (isRuleCreate(request.url(), request.method())) {
|
||||
sawPost = true;
|
||||
}
|
||||
});
|
||||
|
||||
await v1SaveButton(page).click();
|
||||
await v1ConfirmSave(page);
|
||||
|
||||
await expect(page.getByText(VALIDATION.clickhouse)).toBeVisible();
|
||||
expect(sawPost).toBe(false);
|
||||
});
|
||||
|
||||
test('CV1-14 the condition sentence keeps its selections', async ({
|
||||
authedPage: page,
|
||||
alertChannel,
|
||||
ownedRules,
|
||||
}) => {
|
||||
// The write side of EV1-02's prefill assertions — without it a broken
|
||||
// `RuleOptions` select would only be caught on the *edit* path.
|
||||
await gotoCreateAlertV1(page, { alertType: SAVEABLE });
|
||||
await fillMinimalV1Rule(page, {
|
||||
name: `e2e-cv1-14-${Date.now()}`,
|
||||
channelName: alertChannel.name,
|
||||
});
|
||||
|
||||
await v1SelectOption(page, v1OperatorSelect(page), 'below');
|
||||
await v1SelectOption(page, v1MatchTypeSelect(page), 'all the times');
|
||||
await v1SelectOption(page, v1EvalWindowSelect(page), '10 mins');
|
||||
|
||||
await v1SaveButton(page).click();
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) => isRuleCreate(r.url(), r.request().method())),
|
||||
v1ConfirmSave(page),
|
||||
]);
|
||||
await ownedRules.register(response);
|
||||
|
||||
// v1 stores these as the numeric enum strings the legacy validator wants — `2`
|
||||
// is *below* and `2` is *all the times*, on two different scales.
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.condition.op).toBe('2');
|
||||
expect(body.condition.matchType).toBe('2');
|
||||
expect(body.evalWindow).toBe('10m0s');
|
||||
});
|
||||
});
|
||||
@@ -1,564 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
addAlertLabel,
|
||||
advancedOptionToggle,
|
||||
AlertType,
|
||||
evaluationCadenceInput,
|
||||
expandAdvancedOptions,
|
||||
gotoCreateAlertV2,
|
||||
labelPill,
|
||||
dropdownOption,
|
||||
openDropdown,
|
||||
ownDropdown,
|
||||
selectEvaluationTimeframe,
|
||||
selectThresholdChannel,
|
||||
stubNoChannels,
|
||||
thresholdRows,
|
||||
ThresholdMatchType,
|
||||
ThresholdOperator,
|
||||
v2ClickDiscard,
|
||||
v2DiscardButton,
|
||||
v2SaveButton,
|
||||
v2SaveTooltip,
|
||||
v2TestButton,
|
||||
} from '../../../helpers/alert-forms';
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERT_OVERVIEW_PATH,
|
||||
ALERTS_LIST_PATH,
|
||||
gotoAlertDetails,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — actions', () => {
|
||||
test('AD-06 enable/disable toggle changes the rule state', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.logs({
|
||||
name: `e2e-ad-toggle-${Date.now()}`,
|
||||
schema: 'v2',
|
||||
});
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
|
||||
const toggle = page.getByTestId('alert-actions-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
res.request().method() === 'PATCH',
|
||||
),
|
||||
toggle.click(),
|
||||
]);
|
||||
|
||||
await expect(page.getByText('Alert has been disabled.')).toBeVisible();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
res.request().method() === 'PATCH',
|
||||
),
|
||||
toggle.click(),
|
||||
]);
|
||||
|
||||
await expect(page.getByText('Alert has been enabled.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AD-07 Duplicate creates a copy and navigates to overview', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ad-duplicate-${Date.now()}`;
|
||||
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
|
||||
const [createResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
/\/api\/v\d\/rules$/.test(new URL(res.url()).pathname) &&
|
||||
res.request().method() === 'POST',
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Duplicate' }).click(),
|
||||
]);
|
||||
await ownedRules.register(createResponse);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
|
||||
await expect(page).toHaveURL(/[?&]ruleId=/);
|
||||
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
|
||||
await expect(page.getByText(`${name} - Copy`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('AD-08 Delete removes the rule and returns to the list', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ad-delete-${Date.now()}`;
|
||||
const ruleId = await ownedRules.logs({ name, schema: 'v2' });
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
res.request().method() === 'DELETE',
|
||||
),
|
||||
page.getByRole('menuitem', { name: 'Delete' }).click(),
|
||||
]);
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${name}`);
|
||||
await expect(page.getByText(name, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import { ALERTS_LIST_PATH, gotoAlertHistory } from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — page chrome', () => {
|
||||
test('AD-09 copy-link button copies the current URL to clipboard', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
browserName,
|
||||
}) => {
|
||||
test.skip(
|
||||
browserName !== 'chromium',
|
||||
'clipboard-read permission is Chromium-only in Playwright',
|
||||
);
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const expected = page.url();
|
||||
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Copied')).toBeVisible();
|
||||
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copied).toBe(expected);
|
||||
});
|
||||
|
||||
test('AD-10 breadcrumb navigates back to the alert list', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const breadcrumb = page.locator('.ant-breadcrumb');
|
||||
await expect(breadcrumb).toContainText('Alert Rules');
|
||||
await expect(breadcrumb).toContainText(alertHistory.ruleId);
|
||||
|
||||
await breadcrumb.getByText('Alert Rules').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}$`));
|
||||
});
|
||||
|
||||
test('AD-13 document title updates to show the rule name', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await expect
|
||||
.poll(() => page.title(), { timeout: 15_000 })
|
||||
.toContain('e2e-ah-rule-v2');
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
expect,
|
||||
SEED_C_TEAM_LABEL,
|
||||
test,
|
||||
} from '../../../fixtures/alert-history';
|
||||
import { gotoAlertDetails } from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — header', () => {
|
||||
test('AD-01 v2 header shows editable name input without Rename menu item', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertDetails(page, alertHistory.ruleId);
|
||||
|
||||
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
|
||||
'data-schema-version',
|
||||
'v2alpha1',
|
||||
);
|
||||
|
||||
const nameInput = page.getByTestId('alert-name-input');
|
||||
await expect(nameInput).toBeVisible();
|
||||
await expect(nameInput).not.toHaveValue('');
|
||||
await expect(nameInput).toBeEditable();
|
||||
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Rename' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AD-02 v1 header shows static title with state, severity and labels', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertDetails(page, alertHistory.ruleIdV1);
|
||||
|
||||
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
|
||||
'data-schema-version',
|
||||
'v1',
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('alert-header-title')).toBeVisible();
|
||||
await expect(page.getByTestId('alert-header-state')).toBeVisible();
|
||||
await expect(page.getByTestId('alert-header-severity')).toContainText(
|
||||
'Warning',
|
||||
);
|
||||
await expect(page.getByTestId('alert-header-labels')).toContainText(
|
||||
SEED_C_TEAM_LABEL,
|
||||
);
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
await expect(page.getByRole('menuitem', { name: 'Rename' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
ALERT_OVERVIEW_PATH,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — not found', () => {
|
||||
test('AD-11 invalid ruleId shows AlertNotFound page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto(
|
||||
`${ALERT_HISTORY_PATH}?ruleId=01920000-0000-7000-8000-000000000000`,
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('AD-12 missing ruleId on overview shows AlertNotFound page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(ALERT_OVERVIEW_PATH);
|
||||
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERTS_LIST_PATH,
|
||||
gotoAlertDetails,
|
||||
gotoAlertHistory,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — rename', () => {
|
||||
test('AD-03 v1 rename via modal updates the rule name', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const original = `e2e-ad-rename-v1-${stamp}`;
|
||||
const renamed = `${original}-renamed`;
|
||||
const ruleId = await ownedRules.logs({ name: original, schema: 'v1' });
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await expect(page.getByTestId('alert-header-title')).toContainText(original);
|
||||
|
||||
await page.getByTestId('alert-actions-menu').click();
|
||||
await page.getByRole('menuitem', { name: 'Rename' }).click();
|
||||
|
||||
const modalInput = page.getByTestId('alert-name');
|
||||
await expect(modalInput).toBeVisible();
|
||||
await modalInput.fill(renamed);
|
||||
await page.getByRole('button', { name: 'Rename Alert' }).click();
|
||||
|
||||
await expect(page.getByText('Alert renamed successfully')).toBeVisible();
|
||||
await expect(page.getByTestId('alert-header-title')).toContainText(renamed);
|
||||
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
|
||||
await expect(page.getByText(renamed)).toBeVisible();
|
||||
});
|
||||
|
||||
test('AD-04 v2 inline rename saves via Overview footer button', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const stamp = Date.now();
|
||||
const original = `e2e-ad-rename-v2-${stamp}`;
|
||||
const renamed = `${original}-renamed`;
|
||||
const ruleId = await ownedRules.logs({ name: original, schema: 'v2' });
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
|
||||
const nameInput = page.getByTestId('alert-name-input');
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
await nameInput.fill(renamed);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/api/v2/rules/${ruleId}`) &&
|
||||
['PUT', 'POST', 'PATCH'].includes(res.request().method()),
|
||||
),
|
||||
page.getByRole('button', { name: 'Save Alert Rule' }).click(),
|
||||
]);
|
||||
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
|
||||
await expect(page.getByText(renamed)).toBeVisible();
|
||||
|
||||
await gotoAlertHistory(page, ruleId);
|
||||
const historyInput = page.getByTestId('alert-name-input');
|
||||
await historyInput.fill(`${renamed}-unsaved`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Save Alert Rule' }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.goto(`${ALERTS_LIST_PATH}?search=${renamed}`);
|
||||
await expect(page.getByText(renamed)).toBeVisible();
|
||||
await expect(page.getByText(`${renamed}-unsaved`)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
ALERT_OVERVIEW_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
gotoAlertDetails,
|
||||
gotoAlertHistory,
|
||||
} from '../../../helpers/alerts';
|
||||
|
||||
test.describe('Alert details — tabs', () => {
|
||||
test('AD-05 Overview/History tabs preserve ruleId and relativeTime', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertDetails(page, alertHistory.ruleId);
|
||||
|
||||
await page.getByTestId('alert-details-tab-history').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
|
||||
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`relativeTime=${DEFAULT_RELATIVE_TIME}`),
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId('alert-details-tab-history').getByText('Beta'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId('alert-details-tab-overview').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
|
||||
await expect(page).toHaveURL(new RegExp(`ruleId=${alertHistory.ruleId}`));
|
||||
});
|
||||
|
||||
test('AD-05b switching to History tab discards other history params', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
page: '2',
|
||||
order: 'desc',
|
||||
timelineFilter: 'FIRED',
|
||||
});
|
||||
|
||||
await page.getByTestId('alert-details-tab-overview').click();
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_OVERVIEW_PATH));
|
||||
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
|
||||
|
||||
await page.getByTestId('alert-details-tab-history').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(ALERT_HISTORY_PATH));
|
||||
const search = new URL(page.url()).searchParams;
|
||||
expect([...search.keys()].sort()).toEqual(['relativeTime', 'ruleId']);
|
||||
expect(search.get('ruleId')).toBe(alertHistory.ruleId);
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import { gotoAlertOverview } from '../../../helpers/alerts';
|
||||
|
||||
const TARGET = 245;
|
||||
|
||||
test.describe('Alert overview — threshold persistence', () => {
|
||||
test('TC-02 edit page displays the saved threshold value', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(
|
||||
`e2e-threshold-persistence-${Date.now()}`,
|
||||
{ target: TARGET },
|
||||
);
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
await expect(page.getByTestId('threshold-value-input')).toHaveValue(
|
||||
String(TARGET),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import { ALERT_EDIT_PATH } from '../../../helpers/alert-forms';
|
||||
import { ALERT_OVERVIEW_PATH } from '../../../helpers/alerts';
|
||||
|
||||
// CE-03 — what the edit routes do with an unknown ruleId.
|
||||
//
|
||||
// `pages/EditRules`'s own error branches are unreachable: `/alerts/edit` is a
|
||||
// legacy alias redirected to `/alerts/overview` before any route matches
|
||||
// (`AppRoutes/Private.tsx`), and the details shell validates the id before
|
||||
// rendering the Overview tab. So neither the "Rule Id is required" notification nor
|
||||
// the `edit-rules-container--error` card can ever render — this file asserts the
|
||||
// behaviour that replaces them.
|
||||
|
||||
const UNKNOWN_RULE_ID = '999999999';
|
||||
|
||||
test.describe('Alert edit — routing edges', () => {
|
||||
test('CE-03 an unknown ruleId shows AlertNotFound on both entry URLs', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
for (const entry of [
|
||||
`${ALERT_OVERVIEW_PATH}?ruleId=${UNKNOWN_RULE_ID}`,
|
||||
`${ALERT_EDIT_PATH}?ruleId=${UNKNOWN_RULE_ID}`,
|
||||
]) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.goto(entry);
|
||||
|
||||
// `AlertDetails.tsx` returns AlertNotFound before the provider or the editor
|
||||
// mount, so neither the details root nor the standalone route's error card may
|
||||
// appear.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(page.locator('.alert-not-found')).toBeVisible();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(page.getByTestId('alert-details-root')).toBeHidden();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(page.locator('.edit-rules-container--error')).toBeHidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('CE-03b /alerts/edit with no ruleId also lands on AlertNotFound', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(ALERT_EDIT_PATH);
|
||||
|
||||
// The alias redirect fires first, so `pages/EditRules`'s missing-id branch — an
|
||||
// error notification plus a navigation to `/alerts` — never runs.
|
||||
await page.waitForURL(/\/alerts\/overview/);
|
||||
await expect(page.locator('.alert-not-found')).toBeVisible();
|
||||
await expect(page.getByText('Rule Id is required')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -1,291 +0,0 @@
|
||||
import { expect, test, type OwnedRules } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
ALERT_EDIT_PATH,
|
||||
v1CancelButton,
|
||||
v1ChannelSelect,
|
||||
v1ConfirmSave,
|
||||
v1DescriptionInput,
|
||||
v1EvalWindowSelect,
|
||||
v1MatchTypeSelect,
|
||||
v1NameInput,
|
||||
v1OperatorSelect,
|
||||
v1SaveButton,
|
||||
v1SeveritySelect,
|
||||
v1ThresholdInput,
|
||||
v1BroadcastSwitch,
|
||||
selectedTags,
|
||||
} from '../../../helpers/alert-forms';
|
||||
import { authToken, watchConsole } from '../../../helpers/common';
|
||||
import { gotoAlertDetails } from '../../../helpers/alerts';
|
||||
|
||||
// EV1-* — editing a rule whose `schemaVersion` is *not* `v2alpha1`.
|
||||
//
|
||||
// The classic form is what renders, inside the same details shell the v2 builder
|
||||
// uses: `container/EditRules/index.tsx` picks the form from the rule, so there is no
|
||||
// way to open a v1 rule in the v2 builder (and EV1-08 asserts the reverse — editing
|
||||
// never migrates the schema).
|
||||
//
|
||||
// `gotoAlertOverview` cannot be used here: it waits for `threshold-value-input`,
|
||||
// which only the v2 builder renders.
|
||||
|
||||
/**
|
||||
* SEED-RV1 — every asserted field deliberately differs from `alertDefaults`, so a
|
||||
* passing prefill assertion cannot be satisfied by the create form's own defaults.
|
||||
*/
|
||||
const SEED_RV1 = {
|
||||
target: 73,
|
||||
/** `2` is *below*, against the create default of `1` (*above*). */
|
||||
op: '2',
|
||||
/** `2` is *all the times*, against the create default of `1` (*at least once*). */
|
||||
matchType: '2',
|
||||
evalWindow: '15m0s',
|
||||
severity: 'error',
|
||||
} as const;
|
||||
|
||||
/** The description every seeded rule carries (`ANNOTATIONS` in `helpers/alerts.ts`). */
|
||||
const SEEDED_DESCRIPTION =
|
||||
'This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})';
|
||||
|
||||
type LogsSeedOverrides = Omit<Parameters<OwnedRules['logs']>[0], 'name'>;
|
||||
|
||||
function seedRv1(
|
||||
ownedRules: OwnedRules,
|
||||
name: string,
|
||||
extra: Partial<LogsSeedOverrides> = {},
|
||||
): Promise<string> {
|
||||
return ownedRules.logs({
|
||||
schema: 'v1',
|
||||
target: SEED_RV1.target,
|
||||
op: SEED_RV1.op,
|
||||
matchType: SEED_RV1.matchType,
|
||||
evalWindow: SEED_RV1.evalWindow,
|
||||
severity: SEED_RV1.severity,
|
||||
...extra,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a rule straight from the API — EV1-08 needs the stored schema version. */
|
||||
async function readRule(
|
||||
page: import('@playwright/test').Page,
|
||||
ruleId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.get(`/api/v2/rules/${ruleId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.ok()).toBe(true);
|
||||
const json = (await res.json()) as { data: Record<string, unknown> };
|
||||
return json.data;
|
||||
}
|
||||
|
||||
test.describe('Alert edit — v1 rule', () => {
|
||||
test('EV1-01 the classic form renders in edit mode inside the details shell', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await seedRv1(ownedRules, `e2e-ev1-shell-${Date.now()}`);
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
|
||||
const root = page.getByTestId('alert-details-root');
|
||||
// The shell marks the schema on the root node, and only v2 rules get the
|
||||
// `alert-details-v2` class.
|
||||
await expect(root).toHaveAttribute('data-schema-version', 'v1');
|
||||
await expect(root).not.toHaveClass(/alert-details-v2/);
|
||||
|
||||
// The classic form, not the builder: v1's own name field exists and the v2
|
||||
// header's does not.
|
||||
await expect(v1NameInput(page)).toBeVisible();
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
|
||||
|
||||
await expect(
|
||||
page.locator('.form-alert-rules-container.edit-mode'),
|
||||
).toBeVisible();
|
||||
await expect(v1SaveButton(page)).toHaveText(/Save Rule/);
|
||||
await expect(v1CancelButton(page)).toHaveText(/Discard/);
|
||||
});
|
||||
|
||||
test('EV1-02 every seeded field prefills the form', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ev1-prefill-${Date.now()}`;
|
||||
const ruleId = await seedRv1(ownedRules, name, {
|
||||
extraLabels: { team: 'payments' },
|
||||
});
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
|
||||
await expect(v1NameInput(page)).toHaveValue(name);
|
||||
await expect(v1DescriptionInput(page)).toHaveValue(SEEDED_DESCRIPTION);
|
||||
await expect(v1SeveritySelect(page)).toContainText('Error');
|
||||
|
||||
// The four `RuleOptions` controls — the values CV1-14 writes, read back from the
|
||||
// other side.
|
||||
await expect(v1ThresholdInput(page)).toHaveValue(String(SEED_RV1.target));
|
||||
await expect(v1OperatorSelect(page)).toContainText('below');
|
||||
await expect(v1MatchTypeSelect(page)).toContainText('all the times');
|
||||
await expect(v1EvalWindowSelect(page)).toContainText('15 mins');
|
||||
|
||||
await expect(page.getByText('team: payments')).toBeVisible();
|
||||
});
|
||||
|
||||
test('EV1-03 preferredChannels decide which channel control is prefilled', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
alertChannel,
|
||||
}) => {
|
||||
const ruleId = await seedRv1(ownedRules, `e2e-ev1-channels-${Date.now()}`);
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
|
||||
// `BasicInfo.tsx` reads `preferredChannels` *once*, on mount: a rule that names
|
||||
// channels gets the switch **off** and the select filled. The tag is counted
|
||||
// rather than name-matched, since the select truncates at 10 characters.
|
||||
await expect(v1BroadcastSwitch(page)).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false',
|
||||
);
|
||||
await expect(v1ChannelSelect(page)).toBeVisible();
|
||||
await expect(selectedTags(v1ChannelSelect(page))).toHaveCount(1);
|
||||
expect(alertChannel.name).toBeTruthy();
|
||||
});
|
||||
|
||||
test('EV1-04 the happy-path update PUTs the v1 body and keeps unrelated params', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ev1-update-${Date.now()}`;
|
||||
const ruleId = await seedRv1(ownedRules, name);
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await v1ThresholdInput(page).fill('81');
|
||||
|
||||
await v1SaveButton(page).click();
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' &&
|
||||
r.url().includes(`/api/v2/rules/${ruleId}`),
|
||||
),
|
||||
v1ConfirmSave(page),
|
||||
]);
|
||||
|
||||
// **`PUT /api/v2/rules/{id}`**, not `/api/v1/rules/{id}`: there is no v1 rules
|
||||
// client in the frontend at all, so both forms share the endpoint and differ only
|
||||
// in the body.
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.condition.target).toBe(81);
|
||||
expect(body.condition.op).toBe(SEED_RV1.op);
|
||||
expect(body.evalWindow).toBe(SEED_RV1.evalWindow);
|
||||
expect(body.schemaVersion).toBeUndefined();
|
||||
|
||||
await expect(page.getByText('Rule edited successfully')).toBeVisible();
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts');
|
||||
// `saveRule` strips exactly four params on the way out.
|
||||
const params = new URL(page.url()).searchParams;
|
||||
expect(params.get('ruleId')).toBeNull();
|
||||
expect(params.get('compositeQuery')).toBeNull();
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await expect(v1ThresholdInput(page)).toHaveValue('81');
|
||||
});
|
||||
|
||||
test('EV1-05 Discard leaves without a PUT and without changing the rule', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await seedRv1(ownedRules, `e2e-ev1-discard-${Date.now()}`);
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
|
||||
let sawPut = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'PUT' && request.url().includes('/rules/')) {
|
||||
sawPut = true;
|
||||
}
|
||||
});
|
||||
|
||||
await v1ThresholdInput(page).fill('999');
|
||||
await v1CancelButton(page).click();
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
|
||||
expect(sawPut).toBe(false);
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await expect(v1ThresholdInput(page)).toHaveValue(String(SEED_RV1.target));
|
||||
});
|
||||
|
||||
test('EV1-06 the header title and the form name field agree', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ev1-header-${Date.now()}`;
|
||||
const ruleId = await seedRv1(ownedRules, name);
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
|
||||
// v1's `AlertHeader` renders the name as static text while the form renders it as
|
||||
// an input; two sources for one value, so they are asserted together. Renaming
|
||||
// through the header's modal is AD-03's scenario — this row pins the precondition
|
||||
// that makes it meaningful.
|
||||
await expect(page.getByTestId('alert-details-root')).toContainText(name);
|
||||
await expect(v1NameInput(page)).toHaveValue(name);
|
||||
});
|
||||
|
||||
test('EV1-07 /alerts/edit redirects for a v1 rule exactly as it does for v2', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await seedRv1(ownedRules, `e2e-ev1-alias-${Date.now()}`);
|
||||
|
||||
// The alias is resolved by `AppRoutes/Private.tsx` before route matching, so
|
||||
// `pages/EditRules` never renders standalone and there is no v1/v2 asymmetry.
|
||||
const watch = watchConsole(page);
|
||||
await page.goto(`${ALERT_EDIT_PATH}?ruleId=${ruleId}`);
|
||||
|
||||
await page.waitForURL(/\/alerts\/overview/);
|
||||
expect(new URL(page.url()).searchParams.get('ruleId')).toBe(ruleId);
|
||||
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
|
||||
'data-schema-version',
|
||||
'v1',
|
||||
);
|
||||
await expect(v1SaveButton(page)).toBeVisible();
|
||||
expect(watch.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('EV1-08 editing a v1 rule never migrates it to the v2 schema', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await seedRv1(ownedRules, `e2e-ev1-noupgrade-${Date.now()}`);
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await v1ThresholdInput(page).fill('91');
|
||||
await v1SaveButton(page).click();
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' &&
|
||||
r.url().includes(`/api/v2/rules/${ruleId}`),
|
||||
),
|
||||
v1ConfirmSave(page),
|
||||
]);
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
|
||||
// The classic form posts a v1 body and `pages/EditRules` picks the editor from
|
||||
// the *stored* schema, so a saved v1 rule stays v1. Asserted from the API as well
|
||||
// as the DOM — the UI could pick the right form off a cached response.
|
||||
const rule = await readRule(page, ruleId);
|
||||
expect(rule.schemaVersion).not.toBe('v2alpha1');
|
||||
|
||||
await gotoAlertDetails(page, ruleId);
|
||||
await expect(page.getByTestId('alert-details-root')).toHaveAttribute(
|
||||
'data-schema-version',
|
||||
'v1',
|
||||
);
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,420 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-rules';
|
||||
import {
|
||||
ALERT_EDIT_PATH,
|
||||
EVALUATION_WINDOW_PRESETS,
|
||||
evaluationCadenceInput,
|
||||
evaluationCadenceUnitSelect,
|
||||
evaluationSettingsButton,
|
||||
evaluationWindowOption,
|
||||
expandAdvancedOptions,
|
||||
openEvaluationSettings,
|
||||
thresholdRows,
|
||||
ThresholdMatchType,
|
||||
ThresholdOperator,
|
||||
v2ClickDiscard,
|
||||
v2SaveButton,
|
||||
} from '../../../helpers/alert-forms';
|
||||
import { gotoAlertOverview } from '../../../helpers/alerts';
|
||||
import { watchConsole } from '../../../helpers/common';
|
||||
|
||||
// EV2-* — editing a rule whose `schemaVersion` is `v2alpha1`.
|
||||
//
|
||||
// Unless a scenario says otherwise these run through `/alerts/overview?ruleId=`,
|
||||
// which is the route the rules list's Edit action actually uses. EV2-12 is the
|
||||
// exception and the reason the distinction matters: the same editor reached
|
||||
// through `/alerts/edit` has no `CreateAlertProvider` above it.
|
||||
|
||||
/**
|
||||
* SEED-RV2 — a v2 rule whose every asserted field differs from the create-form
|
||||
* default, so a passing prefill assertion cannot be satisfied by the defaults.
|
||||
*/
|
||||
const SEED_RV2 = {
|
||||
target: 42,
|
||||
warningTarget: 21,
|
||||
evalWindow: '10m0s',
|
||||
/** Deliberately *not* one of the rolling presets — see EV2-05. */
|
||||
customEvalWindow: '7m0s',
|
||||
frequency: '5m',
|
||||
renotifyInterval: '2h',
|
||||
absentFor: 7,
|
||||
} as const;
|
||||
|
||||
/** PUT for one specific rule. v1 and v2 share this endpoint. */
|
||||
function isRuleUpdate(url: string, method: string, ruleId: string): boolean {
|
||||
return method === 'PUT' && url.includes(`/api/v2/rules/${ruleId}`);
|
||||
}
|
||||
|
||||
test.describe('Alert edit — v2 rule', () => {
|
||||
test('EV2-01 the v2 editor renders inside the details shell', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(`e2e-ev2-shell-${Date.now()}`, {
|
||||
target: SEED_RV2.target,
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
const root = page.getByTestId('alert-details-root');
|
||||
await expect(root).toHaveClass(/alert-details-v2/);
|
||||
await expect(root).toHaveAttribute('data-schema-version', 'v2alpha1');
|
||||
|
||||
// `CreateAlertHeader` hides the whole tab bar in edit mode, which removes both
|
||||
// the "New Alert Rule" chip and the classic-experience escape hatch. The escape
|
||||
// hatch matters: switching experiences mid-edit would silently drop the loaded
|
||||
// rule.
|
||||
await expect(page.getByTestId('alert-name-input')).toBeVisible();
|
||||
await expect(page.getByText('New Alert Rule')).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Switch to Classic Experience' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('EV2-02 name and labels prefill from the rule', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ev2-prefill-${Date.now()}`;
|
||||
const ruleId = await ownedRules.threshold(name, {
|
||||
target: SEED_RV2.target,
|
||||
labels: { severity: 'critical', team: 'payments' },
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
|
||||
await expect(page.getByTestId('label-pill-severity-critical')).toBeVisible();
|
||||
await expect(page.getByTestId('label-pill-team-payments')).toBeVisible();
|
||||
});
|
||||
|
||||
test('EV2-03 both thresholds prefill, and the sentence reads spec[0]', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
alertChannel,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(
|
||||
`e2e-ev2-thresholds-${Date.now()}`,
|
||||
{
|
||||
thresholds: [
|
||||
{
|
||||
name: 'critical',
|
||||
target: SEED_RV2.target,
|
||||
op: ThresholdOperator.BELOW.value,
|
||||
matchType: ThresholdMatchType.ALL_THE_TIME.value,
|
||||
channels: [alertChannel.name],
|
||||
},
|
||||
{
|
||||
name: 'warning',
|
||||
target: SEED_RV2.warningTarget,
|
||||
op: ThresholdOperator.BELOW.value,
|
||||
matchType: ThresholdMatchType.ALL_THE_TIME.value,
|
||||
channels: [alertChannel.name],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
await expect(thresholdRows(page)).toHaveCount(2);
|
||||
await expect(page.getByTestId('threshold-name-input').nth(0)).toHaveValue(
|
||||
'critical',
|
||||
);
|
||||
await expect(page.getByTestId('threshold-name-input').nth(1)).toHaveValue(
|
||||
'warning',
|
||||
);
|
||||
await expect(page.getByTestId('threshold-value-input').nth(0)).toHaveValue(
|
||||
String(SEED_RV2.target),
|
||||
);
|
||||
await expect(page.getByTestId('threshold-value-input').nth(1)).toHaveValue(
|
||||
String(SEED_RV2.warningTarget),
|
||||
);
|
||||
|
||||
// The condition sentence is rule-wide in the UI but per-threshold in the schema,
|
||||
// and the mapper reads it back from `spec[0]` only. Both seeded thresholds share
|
||||
// op/matchType so this row stays about prefill.
|
||||
await expect(
|
||||
page.getByTestId('alert-threshold-operator-select'),
|
||||
).toContainText(ThresholdOperator.BELOW.label);
|
||||
await expect(
|
||||
page.getByTestId('alert-threshold-match-type-select'),
|
||||
).toContainText(ThresholdMatchType.ALL_THE_TIME.label);
|
||||
});
|
||||
|
||||
test('EV2-04 the recovery threshold control never renders', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(`e2e-ev2-recovery-${Date.now()}`, {
|
||||
target: SEED_RV2.target,
|
||||
recoveryTarget: 10,
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
// `showRecoveryThreshold` starts false and its only setter is commented out, so a
|
||||
// seeded `recoveryTarget` has nowhere to land. If either locator ever appears the
|
||||
// feature was finished, and CV2-13/EV2-04 need rewriting rather than deleting.
|
||||
await expect(page.getByTestId('recovery-threshold-value-input')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId('remove-recovery-threshold-button'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('EV2-05 the evaluation window prefills, and a non-preset value collapses to custom', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const presetRule = await ownedRules.threshold(
|
||||
`e2e-ev2-window-${Date.now()}`,
|
||||
{ target: SEED_RV2.target, evalWindow: SEED_RV2.evalWindow },
|
||||
);
|
||||
|
||||
await gotoAlertOverview(page, presetRule);
|
||||
|
||||
// The trigger button carries both halves of the window: the timeframe label and
|
||||
// the window *type*, which comes from the seeded `evaluation.kind`.
|
||||
await expect(evaluationSettingsButton(page)).toContainText(
|
||||
EVALUATION_WINDOW_PRESETS[SEED_RV2.evalWindow],
|
||||
);
|
||||
await expect(evaluationSettingsButton(page)).toContainText('Rolling');
|
||||
|
||||
const customRule = await ownedRules.threshold(
|
||||
`e2e-ev2-window-custom-${Date.now()}`,
|
||||
{ target: SEED_RV2.target, evalWindow: SEED_RV2.customEvalWindow },
|
||||
);
|
||||
|
||||
await gotoAlertOverview(page, customRule);
|
||||
|
||||
// `getRollingWindowTimeframe` only recognises the seven presets; anything else
|
||||
// becomes `custom`, and the label is then built from the parsed number + unit. So
|
||||
// a rule created outside the UI with an odd window keeps its value — it just
|
||||
// renders through the custom branch.
|
||||
await expect(evaluationSettingsButton(page)).toContainText('Last 7 Minutes');
|
||||
await openEvaluationSettings(page);
|
||||
await expect(evaluationWindowOption(page, 'timeframe', 'custom')).toHaveClass(
|
||||
/active/,
|
||||
);
|
||||
});
|
||||
|
||||
test('EV2-06 repeat notifications prefill from the seeded renotify block', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(`e2e-ev2-renotify-${Date.now()}`, {
|
||||
target: SEED_RV2.target,
|
||||
renotify: {
|
||||
enabled: true,
|
||||
interval: SEED_RV2.renotifyInterval,
|
||||
alertStates: ['firing'],
|
||||
},
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
// Every control in the block is `disabled={!reNotification.enabled}`, so "is it
|
||||
// enabled" is a stronger read of the toggle than the Switch's own state.
|
||||
const interval = page.getByTestId('repeat-notifications-time-input');
|
||||
await expect(interval).toBeEnabled();
|
||||
// `parseGoTime` splits the Go duration into value + unit.
|
||||
await expect(interval).toHaveValue('2');
|
||||
await expect(
|
||||
page.getByTestId('repeat-notifications-unit-select'),
|
||||
).toContainText('Hours');
|
||||
await expect(
|
||||
page.getByTestId('repeat-notifications-conditions-select'),
|
||||
).toContainText('Firing');
|
||||
});
|
||||
|
||||
test('EV2-07 alertOnAbsent prefills the advanced options', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(`e2e-ev2-absent-${Date.now()}`, {
|
||||
target: SEED_RV2.target,
|
||||
alertOnAbsent: { absentFor: SEED_RV2.absentFor },
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
await expandAdvancedOptions(page);
|
||||
|
||||
const tolerance = page.getByTestId(
|
||||
'send-notification-if-data-is-missing-input',
|
||||
);
|
||||
await expect(tolerance).toBeVisible();
|
||||
await expect(tolerance).toHaveValue(String(SEED_RV2.absentFor));
|
||||
|
||||
// The sibling option was not seeded, so its input stays behind the
|
||||
// `display: none` its container applies when the toggle is off — asserted so a
|
||||
// prefill that turned *every* advanced option on would still fail this row.
|
||||
await expect(
|
||||
page.getByTestId('enforce-minimum-datapoints-input'),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('EV2-08 the evaluation cadence always reads back in default mode', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(`e2e-ev2-cadence-${Date.now()}`, {
|
||||
target: SEED_RV2.target,
|
||||
frequency: SEED_RV2.frequency,
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
await expandAdvancedOptions(page);
|
||||
|
||||
await expect(evaluationCadenceInput(page)).toHaveValue('5');
|
||||
await expect(evaluationCadenceUnitSelect(page)).toContainText('Minutes');
|
||||
|
||||
// `getAdvancedOptionsStateFromAlertDef` hardcodes `mode: 'default'`, and
|
||||
// `EditCustomSchedule` mounts only when the mode is *not* default — so a schedule
|
||||
// saved by any other client reopens as a plain interval and the next save
|
||||
// persists the flattened value. The custom and rrule modes are unreachable from
|
||||
// the UI (the "Add custom schedule" button is commented out) and unrepresentable
|
||||
// in the payload, so the flattening itself is what this row asserts; the day the
|
||||
// custom editor can mount, it fails.
|
||||
await expect(page.locator('.edit-custom-schedule')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('EV2-09 changing a threshold PUTs the rule and the change survives a reload', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ev2-update-${Date.now()}`;
|
||||
const ruleId = await ownedRules.threshold(name, {
|
||||
target: SEED_RV2.target,
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
await page.getByTestId('threshold-value-input').first().fill('99');
|
||||
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) =>
|
||||
isRuleUpdate(r.url(), r.request().method(), ruleId),
|
||||
),
|
||||
v2SaveButton(page).click(),
|
||||
]);
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.condition.thresholds.spec[0].target).toBe(99);
|
||||
// The mapper hardcodes the schema version on every save, so an edit of a v2 rule
|
||||
// stays v2.
|
||||
expect(body.schemaVersion).toBe('v2alpha1');
|
||||
expect(body.alert).toBe(name);
|
||||
|
||||
await expect(page.getByText('Alert rule updated successfully')).toBeVisible();
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts');
|
||||
|
||||
// Re-read rather than trust the toast: the footer invalidates the rule and list
|
||||
// caches, and a stale cache would show the old value here.
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
await expect(page.getByTestId('threshold-value-input').first()).toHaveValue(
|
||||
'99',
|
||||
);
|
||||
});
|
||||
|
||||
test('EV2-10 the footer save is what persists a rename made on the Overview tab', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ev2-rename-${Date.now()}`;
|
||||
const renamed = `${name}-renamed`;
|
||||
const ruleId = await ownedRules.threshold(name, {
|
||||
target: SEED_RV2.target,
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
await page.getByTestId('alert-name-input').fill(renamed);
|
||||
|
||||
// AD-03/AD-04 cover renaming through the details header's RenameModal, which
|
||||
// PATCHes on its own. This row is the other path: the editable header field is
|
||||
// local state until the *Footer* saves it, so the assertion is on the PUT body.
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse((r) =>
|
||||
isRuleUpdate(r.url(), r.request().method(), ruleId),
|
||||
),
|
||||
v2SaveButton(page).click(),
|
||||
]);
|
||||
|
||||
expect(response.request().postDataJSON().alert).toBe(renamed);
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveValue(renamed);
|
||||
});
|
||||
|
||||
test('EV2-11 Discard leaves without a PUT and without touching the rule', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const name = `e2e-ev2-discard-${Date.now()}`;
|
||||
const ruleId = await ownedRules.threshold(name, {
|
||||
target: SEED_RV2.target,
|
||||
});
|
||||
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
|
||||
let sawPut = false;
|
||||
page.on('request', (request) => {
|
||||
if (request.method() === 'PUT' && request.url().includes('/rules/')) {
|
||||
sawPut = true;
|
||||
}
|
||||
});
|
||||
|
||||
await page.getByTestId('threshold-value-input').first().fill('999');
|
||||
// dispatchEvent, not click — the side navigation covers the button (CE-09).
|
||||
await v2ClickDiscard(page);
|
||||
await page.waitForURL(/\/alerts(\?|$)/);
|
||||
|
||||
expect(sawPut).toBe(false);
|
||||
|
||||
// `discardAlertRule` also forces the *context's* alertType back to metrics, which
|
||||
// is harmless on the way out only if the stored rule is untouched.
|
||||
await gotoAlertOverview(page, ruleId);
|
||||
await expect(page.getByTestId('threshold-value-input').first()).toHaveValue(
|
||||
String(SEED_RV2.target),
|
||||
);
|
||||
await expect(page.getByTestId('alert-name-input')).toHaveValue(name);
|
||||
});
|
||||
|
||||
test('EV2-12 /alerts/edit is a legacy alias that redirects into the details shell', async ({
|
||||
authedPage: page,
|
||||
ownedRules,
|
||||
}) => {
|
||||
const ruleId = await ownedRules.threshold(
|
||||
`e2e-ev2-standalone-${Date.now()}`,
|
||||
{ target: SEED_RV2.target },
|
||||
);
|
||||
|
||||
// Reading the components suggests this route should crash: `EditAlertV2` renders
|
||||
// Footer/AlertCondition/NotificationSettings, all of which call
|
||||
// `useCreateAlertState()`, and the only provider lives in `AlertDetails.tsx`. It
|
||||
// does not crash, because `/alerts/edit` never renders `pages/EditRules` at all —
|
||||
// `AppRoutes/Private.tsx` redirects the legacy alias to `/alerts/overview`,
|
||||
// merging the search params, before route matching.
|
||||
//
|
||||
// Worth keeping despite being a redirect: the alias is linked from the AI
|
||||
// assistant and Metrics Explorer, so a regression in `oldNewRoutesMapping` would
|
||||
// break real product links.
|
||||
const watch = watchConsole(page);
|
||||
await page.goto(`${ALERT_EDIT_PATH}?ruleId=${ruleId}`);
|
||||
|
||||
await page.waitForURL(/\/alerts\/overview/);
|
||||
expect(new URL(page.url()).pathname).toBe('/alerts/overview');
|
||||
expect(new URL(page.url()).searchParams.get('ruleId')).toBe(ruleId);
|
||||
|
||||
// The editor mounted under the shell's provider, so the missing-provider error
|
||||
// never fires. Asserted explicitly: if someone removes the redirect entry, this
|
||||
// is the message that would appear.
|
||||
await expect(thresholdRows(page)).toHaveCount(1);
|
||||
expect(
|
||||
watch.errors.filter((message) =>
|
||||
message.includes('useCreateAlertState must be used within'),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,223 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
DEFAULT_RELATIVE_TIME,
|
||||
encodeTimelineCursor,
|
||||
gotoAlertHistory,
|
||||
HISTORY_ENDPOINTS,
|
||||
isHistoryRequest,
|
||||
sortTimelineDescending,
|
||||
statsCard,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
timelineFooterRange,
|
||||
timelineRows,
|
||||
} from '../../../helpers/alerts';
|
||||
import {
|
||||
collectRequests,
|
||||
requestUrl,
|
||||
watchConsole,
|
||||
} from '../../../helpers/common';
|
||||
|
||||
test.describe('Alert history — cross-cutting', () => {
|
||||
test('AX-01 full deep-link with all params is honoured in one load', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const service = alertHistory.services[8];
|
||||
await page.goto(
|
||||
`${ALERT_HISTORY_PATH}?ruleId=${alertHistory.ruleId}` +
|
||||
`&relativeTime=${DEFAULT_RELATIVE_TIME}` +
|
||||
`&timelineFilter=FIRED&page=1&order=desc` +
|
||||
`&alertHistoryExpression=${encodeURIComponent(`service.name = '${service}'`)}` +
|
||||
`&viewAllTopContributors=true`,
|
||||
);
|
||||
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-filter-fired')).toHaveClass(
|
||||
/selected/,
|
||||
);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AX-02 page reload preserves all history params', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
timelineFilter: 'FIRED',
|
||||
page: '2',
|
||||
order: 'desc',
|
||||
});
|
||||
const before = new URL(page.url()).searchParams;
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
|
||||
const after = new URL(page.url()).searchParams;
|
||||
for (const [key, value] of before) {
|
||||
expect(after.get(key), `param ${key} survived the reload`).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
test('AX-03 browser back/forward restores correct table state', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
|
||||
await page.getByTestId('timeline-filter-fired').click();
|
||||
await expect(page).toHaveURL(/[?&]timelineFilter=FIRED/);
|
||||
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await expect(timelineRows(page)).toHaveCount(
|
||||
alertHistory.total - TIMELINE_PAGE_SIZE,
|
||||
);
|
||||
|
||||
await page.goBack();
|
||||
await expect(page).not.toHaveURL(/[?&]page=2/);
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
|
||||
await page.goForward();
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await expect(timelineRows(page)).toHaveCount(
|
||||
alertHistory.total - TIMELINE_PAGE_SIZE,
|
||||
);
|
||||
});
|
||||
|
||||
test('AX-04 no unhandled console errors across full history session', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const watch = watchConsole(page);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await page.getByTestId('timeline-filter-fired').click();
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await sortTimelineDescending(page);
|
||||
await expect(page).toHaveURL(/[?&]order=desc/);
|
||||
await page.getByTestId('top-contributors-view-all').click();
|
||||
await expect(page.getByTestId('top-contributors-drawer')).toBeVisible();
|
||||
|
||||
expect(watch.errors).toEqual([]);
|
||||
expect(watch.failedResponses).toEqual([]);
|
||||
});
|
||||
|
||||
test('AX-05 no request storm on mount (exactly one call per endpoint)', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const requests = collectRequests(page);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
await expect(
|
||||
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
|
||||
).toBeVisible();
|
||||
|
||||
for (const endpoint of HISTORY_ENDPOINTS) {
|
||||
const calls = requests.filter((req) => isHistoryRequest(req, endpoint));
|
||||
expect(calls, `${endpoint} called exactly once on mount`).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('AX-06 v1 and v2 schema rules both render history correctly', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
for (const [variant, ruleId, total] of [
|
||||
['v2', alertHistory.ruleId, alertHistory.total],
|
||||
['v1', alertHistory.ruleIdV1, alertHistory.totalV1],
|
||||
] as const) {
|
||||
await gotoAlertHistory(page, ruleId);
|
||||
await expect(
|
||||
page.getByTestId('alert-details-root'),
|
||||
`${variant} rule renders the details shell`,
|
||||
).toHaveAttribute(
|
||||
'data-schema-version',
|
||||
variant === 'v2' ? 'v2alpha1' : 'v1',
|
||||
);
|
||||
await expect(timelineFooterRange(page)).toContainText(`of ${total}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('AX-07 no legacy v1 history API calls during full session', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const requests = collectRequests(page);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
await page.getByTestId('timeline-filter-fired').click();
|
||||
await page.getByTestId('timeline-next-page').click();
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
await sortTimelineDescending(page);
|
||||
await expect(page).toHaveURL(/[?&]order=desc/);
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '6h' });
|
||||
|
||||
const legacy = requests.filter((req) =>
|
||||
/\/api\/v1\/rules\/[^/]+\/history\//.test(req.url()),
|
||||
);
|
||||
expect(
|
||||
legacy.map((req) => req.url()),
|
||||
'no legacy POST /api/v1/rules/*/history/* calls',
|
||||
).toEqual([]);
|
||||
|
||||
for (const endpoint of HISTORY_ENDPOINTS) {
|
||||
expect(
|
||||
requests.filter((req) => isHistoryRequest(req, endpoint)).length,
|
||||
`v2 ${endpoint} endpoint was used`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('AX-08 history API endpoints carry expected params', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const requests = collectRequests(page);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
timelineFilter: 'FIRED',
|
||||
page: '2',
|
||||
});
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
|
||||
const timeline = requests
|
||||
.filter((req) => isHistoryRequest(req, 'timeline'))
|
||||
.pop();
|
||||
expect(timeline).toBeDefined();
|
||||
const timelineParams = requestUrl(timeline!).searchParams;
|
||||
for (const key of ['start', 'end', 'limit', 'order', 'cursor', 'state']) {
|
||||
expect(timelineParams.get(key), `timeline carries ${key}`).toBeTruthy();
|
||||
}
|
||||
expect(timelineParams.get('limit')).toBe(String(TIMELINE_PAGE_SIZE));
|
||||
expect(timelineParams.get('cursor')).toBe(encodeTimelineCursor(2));
|
||||
|
||||
for (const endpoint of [
|
||||
'stats',
|
||||
'overall_status',
|
||||
'top_contributors',
|
||||
] as const) {
|
||||
const request = requests
|
||||
.filter((req) => isHistoryRequest(req, endpoint))
|
||||
.pop();
|
||||
expect(request, `${endpoint} was requested`).toBeDefined();
|
||||
const params = requestUrl(request!).searchParams;
|
||||
expect(params.get('start'), `${endpoint} carries start`).toBeTruthy();
|
||||
expect(params.get('end'), `${endpoint} carries end`).toBeTruthy();
|
||||
}
|
||||
|
||||
const keys = requests
|
||||
.filter((req) => /\/history\/filter_keys/.test(req.url()))
|
||||
.pop();
|
||||
expect(keys, 'filter_keys was requested').toBeDefined();
|
||||
const keyParams = requestUrl(keys!).searchParams;
|
||||
expect(keyParams.get('startUnixMilli')).toBeTruthy();
|
||||
expect(keyParams.get('endUnixMilli')).toBeTruthy();
|
||||
expect(keyParams.get('start')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
ALERT_HISTORY_PATH,
|
||||
createLogsAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
expectFirstPage,
|
||||
gotoAlertHistory,
|
||||
runFilterExpression,
|
||||
setRuleDisabledViaApi,
|
||||
statsCard,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
timelineFooterRange,
|
||||
timelineRows,
|
||||
waitForHistoryResponse,
|
||||
} from '../../../helpers/alerts';
|
||||
import { collectRequests } from '../../../helpers/common';
|
||||
import { typeExpression } from '../../../helpers/query-builder';
|
||||
|
||||
test.describe('Alert history — error and empty states', () => {
|
||||
test('AE-01 invalid filter expression shows syntax error and recovers on fix', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const responsePromise = waitForHistoryResponse(page, 'timeline', {
|
||||
filterExpression: 'service.name =',
|
||||
});
|
||||
await runFilterExpression(page, 'service.name =');
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const error = page.getByTestId('timeline-error');
|
||||
await expect(error).toBeVisible();
|
||||
await expect(error).toContainText(/syntax error/i);
|
||||
|
||||
const fixed = `service.name = '${alertHistory.services[7]}'`;
|
||||
const fixResponsePromise = waitForHistoryResponse(page, 'timeline', {
|
||||
status: 200,
|
||||
filterExpression: fixed,
|
||||
});
|
||||
await runFilterExpression(page, fixed);
|
||||
await fixResponsePromise;
|
||||
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AE-02 empty filter_keys response still mounts editor (no suggestions)', async ({
|
||||
authedPage: page,
|
||||
emptyHistory,
|
||||
}) => {
|
||||
const keysPromise = page.waitForResponse((res) =>
|
||||
/\/history\/filter_keys/.test(res.url()),
|
||||
);
|
||||
await gotoAlertHistory(page, emptyHistory.ruleId);
|
||||
const keysResponse = await keysPromise;
|
||||
|
||||
const body = (await keysResponse.json()) as {
|
||||
data: { keys?: Record<string, unknown> } | null;
|
||||
};
|
||||
expect(Object.keys(body.data?.keys ?? {})).toHaveLength(0);
|
||||
|
||||
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
|
||||
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
|
||||
await typeExpression(page, 'anything.at.all');
|
||||
await expect(
|
||||
page.locator('.query-where-clause-editor .cm-content'),
|
||||
).toContainText('anything.at.all');
|
||||
});
|
||||
|
||||
test('AE-02b bogus ruleId never reaches history APIs (shows AlertNotFound)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const requests = collectRequests(page);
|
||||
|
||||
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=not-a-real-rule-id`);
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
|
||||
expect(requests.filter((req) => /\/history\//.test(req.url()))).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('AE-03 rule with no history renders empty state (not error)', async ({
|
||||
authedPage: page,
|
||||
emptyHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, emptyHistory.ruleId);
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(0);
|
||||
await expect(
|
||||
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
|
||||
).toHaveText('None Triggered.');
|
||||
await expect(
|
||||
statsCard(page, 'Avg. Resolution Time').getByTestId('stats-card-value'),
|
||||
).toHaveText('No Resolutions.');
|
||||
await expect(page.getByTestId('top-contributors-row')).toHaveCount(0);
|
||||
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AE-04 time range with no data renders empty state', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const end = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const start = end - 30 * 60 * 1000;
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
startTime: String(start),
|
||||
endTime: String(end),
|
||||
});
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(0);
|
||||
await expect(
|
||||
statsCard(page, 'Total Triggered').getByTestId('stats-card-value'),
|
||||
).toHaveText('None Triggered.');
|
||||
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AE-05 time-range change resets pagination to first page', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
|
||||
await expect(page).toHaveURL(/[?&]page=2/);
|
||||
|
||||
await page.locator('.filters input').first().click();
|
||||
await page.getByText('Last 6 hours', { exact: true }).click();
|
||||
|
||||
await expectFirstPage(page);
|
||||
});
|
||||
|
||||
test('AE-06 absurd time range (90d) still renders', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { relativeTime: '90d' });
|
||||
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
await expect(timelineRows(page).first()).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-graph-title')).toContainText(
|
||||
`${alertHistory.total} triggers in`,
|
||||
);
|
||||
});
|
||||
|
||||
test('AE-07 disabled rule history is still readable', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.total}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('AE-08 deleted rule shows AlertNotFound on revisit', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const doomedId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-doomed-${Date.now()}`,
|
||||
marker: alertHistory.marker,
|
||||
channels: [alertHistory.channelName],
|
||||
});
|
||||
await setRuleDisabledViaApi(page, doomedId, true);
|
||||
|
||||
await gotoAlertHistory(page, doomedId);
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
|
||||
await deleteAlertViaApi(page, doomedId);
|
||||
|
||||
await page.goto(`${ALERT_HISTORY_PATH}?ruleId=${doomedId}`);
|
||||
await expect(
|
||||
page.getByText("Uh-oh! We couldn't find the given alert rule."),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,286 +0,0 @@
|
||||
import { expect, test } from '../../../fixtures/alert-history';
|
||||
import {
|
||||
expectFirstPage,
|
||||
gotoAlertHistory,
|
||||
isHistoryRequest,
|
||||
runFilterExpression,
|
||||
TIMELINE_PAGE_SIZE,
|
||||
timelineFooterRange,
|
||||
timelineRows,
|
||||
waitForHistoryResponse,
|
||||
} from '../../../helpers/alerts';
|
||||
import { requestUrl } from '../../../helpers/common';
|
||||
import { typeExpression } from '../../../helpers/query-builder';
|
||||
|
||||
test.describe('Alert history — expression filter', () => {
|
||||
test('AF-06 key suggestions load on page load', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
const keysPromise = page.waitForResponse((res) =>
|
||||
/\/history\/filter_keys/.test(res.url()),
|
||||
);
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const keysResponse = await keysPromise;
|
||||
|
||||
const body = (await keysResponse.json()) as {
|
||||
data: { keys: Record<string, { name: string }[]> } | null;
|
||||
};
|
||||
expect(Object.keys(body.data?.keys ?? {}).sort()).toEqual([
|
||||
'service.name',
|
||||
'severity',
|
||||
'threshold.name',
|
||||
]);
|
||||
const params = new URL(keysResponse.url()).searchParams;
|
||||
expect(params.get('startUnixMilli')).toBeTruthy();
|
||||
expect(params.get('endUnixMilli')).toBeTruthy();
|
||||
|
||||
await expect(page.getByTestId('timeline-filter-search')).toBeVisible();
|
||||
await expect(page.getByTestId('timeline-filter-skeleton')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AF-07 value suggestions fetch from filter_values endpoint', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const valuesPromise = page.waitForResponse((res) =>
|
||||
/\/history\/filter_values/.test(res.url()),
|
||||
);
|
||||
await typeExpression(page, "service.name = '");
|
||||
const valuesResponse = await valuesPromise;
|
||||
|
||||
const params = new URL(valuesResponse.url()).searchParams;
|
||||
expect(params.get('name')).toBe('service.name');
|
||||
|
||||
const body = (await valuesResponse.json()) as {
|
||||
data: { values?: { stringValues?: string[] }; complete?: boolean } | null;
|
||||
};
|
||||
expect(body.data?.values?.stringValues).toEqual(
|
||||
[...alertHistory.services].sort(),
|
||||
);
|
||||
expect(body.data?.complete).toBe(true);
|
||||
});
|
||||
|
||||
test('AF-08 value suggestions filter client-side as user types', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await typeExpression(page, "service.name = 'svc-1");
|
||||
|
||||
const options = page.locator('.cm-tooltip-autocomplete li');
|
||||
await expect(options.first()).toBeVisible();
|
||||
const texts = await options.allInnerTexts();
|
||||
expect(texts.length).toBeGreaterThan(0);
|
||||
expect(texts.length).toBeLessThan(alertHistory.services.length);
|
||||
for (const text of texts) {
|
||||
expect(text).toContain('svc-1');
|
||||
}
|
||||
});
|
||||
|
||||
test('AF-09 running equality expression filters the table', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const service = alertHistory.services[3];
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await runFilterExpression(page, `service.name = '${service}'`);
|
||||
const request = await requestPromise;
|
||||
|
||||
expect(requestUrl(request).searchParams.get('filterExpression')).toBe(
|
||||
`service.name = '${service}'`,
|
||||
);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
|
||||
});
|
||||
|
||||
test('AF-10 running expression resets pagination to first page', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, { page: '2' });
|
||||
|
||||
const responsePromise = waitForHistoryResponse(page, 'timeline');
|
||||
await runFilterExpression(
|
||||
page,
|
||||
`service.name = '${alertHistory.services[0]}'`,
|
||||
);
|
||||
await responsePromise;
|
||||
|
||||
await expect(page).not.toHaveURL(/[?&]page=2/);
|
||||
await expectFirstPage(page);
|
||||
});
|
||||
|
||||
test('AF-11 Run button re-fetches unchanged expression', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const expression = `service.name = '${alertHistory.services[1]}'`;
|
||||
const firstResponsePromise = waitForHistoryResponse(page, 'timeline');
|
||||
await runFilterExpression(page, expression);
|
||||
await firstResponsePromise;
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await page.getByRole('button', { name: /run query/i }).click();
|
||||
await requestPromise;
|
||||
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AF-12 in-flight query can be cancelled', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
await page.route(
|
||||
(url) => /\/history\/timeline/.test(url.pathname),
|
||||
async (route) => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 3_000);
|
||||
});
|
||||
await route.continue();
|
||||
},
|
||||
);
|
||||
|
||||
await typeExpression(page, `service.name = '${alertHistory.services[2]}'`);
|
||||
await page.getByRole('button', { name: /run query/i }).click();
|
||||
|
||||
const cancel = page.getByRole('button', { name: 'Cancel' });
|
||||
await expect(cancel).toBeVisible();
|
||||
await cancel.click();
|
||||
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
||||
await expect(page.getByRole('button', { name: 'Cancel' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /run query/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('AF-13 threshold.name and severity keys filter correctly', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
let responsePromise = waitForHistoryResponse(page, 'timeline');
|
||||
await runFilterExpression(page, `threshold.name = 'critical'`);
|
||||
await responsePromise;
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.total}`,
|
||||
);
|
||||
|
||||
responsePromise = waitForHistoryResponse(page, 'timeline');
|
||||
await runFilterExpression(page, `severity = 'critical'`);
|
||||
await responsePromise;
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.total}`,
|
||||
);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleIdV1);
|
||||
responsePromise = waitForHistoryResponse(page, 'timeline');
|
||||
await runFilterExpression(page, `threshold.name = 'warning'`);
|
||||
await responsePromise;
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.totalV1}`,
|
||||
);
|
||||
|
||||
responsePromise = waitForHistoryResponse(page, 'timeline');
|
||||
await runFilterExpression(page, `threshold.name = 'critical'`);
|
||||
await responsePromise;
|
||||
await expect(timelineRows(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AF-14 unknown key returns 200 with zero rows (not 500)', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
|
||||
const responsePromise = waitForHistoryResponse(page, 'timeline');
|
||||
await runFilterExpression(page, `nonexistent.key = 'x'`);
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
await expect(timelineRows(page)).toHaveCount(0);
|
||||
await expect(page.getByTestId('timeline-error')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('AF-15 expression is lost on Overview→History round-trip (known bug)', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const expression = `service.name = '${alertHistory.services[4]}'`;
|
||||
const responsePromise = waitForHistoryResponse(page, 'timeline');
|
||||
await runFilterExpression(page, expression);
|
||||
await responsePromise;
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
await expect(page).toHaveURL(/[?&]alertHistoryExpression=/);
|
||||
|
||||
await page.getByTestId('alert-details-tab-overview').click();
|
||||
await page.getByTestId('alert-details-tab-history').click();
|
||||
|
||||
await expect(page.getByTestId('timeline-table')).toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.has('alertHistoryExpression')).toBe(
|
||||
false,
|
||||
);
|
||||
await expect(timelineRows(page)).toHaveCount(TIMELINE_PAGE_SIZE);
|
||||
});
|
||||
|
||||
test('AF-16 expression and state filter compose in request', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId, {
|
||||
timelineFilter: 'FIRED',
|
||||
});
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
const service = alertHistory.services[5];
|
||||
await runFilterExpression(page, `service.name = '${service}'`);
|
||||
const request = await requestPromise;
|
||||
|
||||
const params = requestUrl(request).searchParams;
|
||||
expect(params.get('state')).toBe('firing');
|
||||
expect(params.get('filterExpression')).toBe(`service.name = '${service}'`);
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('AF-17 clearing expression restores full unfiltered list', async ({
|
||||
authedPage: page,
|
||||
alertHistory,
|
||||
}) => {
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const firstResponsePromise = waitForHistoryResponse(page, 'timeline');
|
||||
await runFilterExpression(
|
||||
page,
|
||||
`service.name = '${alertHistory.services[6]}'`,
|
||||
);
|
||||
await firstResponsePromise;
|
||||
await expect(timelineRows(page)).toHaveCount(1);
|
||||
|
||||
const requestPromise = page.waitForRequest((req) =>
|
||||
isHistoryRequest(req, 'timeline'),
|
||||
);
|
||||
await runFilterExpression(page, '');
|
||||
const request = await requestPromise;
|
||||
|
||||
expect(requestUrl(request).searchParams.get('filterExpression')).toBeNull();
|
||||
await expect(timelineFooterRange(page)).toContainText(
|
||||
`of ${alertHistory.total}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user