mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[SLO] Create SLO Edit Form - Custom KQL (#147843)
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Closes https://github.com/elastic/kibana/issues/147753
This commit is contained in:
parent
46d689220b
commit
c2c9cabfb7
41 changed files with 1661 additions and 146 deletions
|
@ -51,16 +51,16 @@ const getSLOParamsSchema = t.type({
|
|||
});
|
||||
|
||||
const sortDirectionSchema = t.union([t.literal('asc'), t.literal('desc')]);
|
||||
const sortBySchema = t.union([t.literal('name'), t.literal('indicator_type')]);
|
||||
const sortBySchema = t.union([t.literal('name'), t.literal('indicatorType')]);
|
||||
|
||||
const findSLOParamsSchema = t.partial({
|
||||
query: t.partial({
|
||||
name: t.string,
|
||||
indicator_types: indicatorTypesArraySchema,
|
||||
indicatorTypes: indicatorTypesArraySchema,
|
||||
page: t.string,
|
||||
per_page: t.string,
|
||||
sort_by: sortBySchema,
|
||||
sort_direction: sortDirectionSchema,
|
||||
perPage: t.string,
|
||||
sortBy: sortBySchema,
|
||||
sortDirection: sortDirectionSchema,
|
||||
}),
|
||||
});
|
||||
|
||||
|
@ -123,6 +123,8 @@ type UpdateSLOResponse = t.OutputOf<typeof updateSLOResponseSchema>;
|
|||
type FindSLOParams = t.TypeOf<typeof findSLOParamsSchema.props.query>;
|
||||
type FindSLOResponse = t.OutputOf<typeof findSLOResponseSchema>;
|
||||
|
||||
type BudgetingMethod = t.TypeOf<typeof budgetingMethodSchema>;
|
||||
|
||||
export {
|
||||
createSLOParamsSchema,
|
||||
deleteSLOParamsSchema,
|
||||
|
@ -136,6 +138,7 @@ export {
|
|||
updateSLOResponseSchema,
|
||||
};
|
||||
export type {
|
||||
BudgetingMethod,
|
||||
CreateSLOParams,
|
||||
CreateSLOResponse,
|
||||
FindSLOParams,
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import * as t from 'io-ts';
|
||||
import { allOrAnyString, dateRangeSchema } from './common';
|
||||
|
||||
const apmTransactionDurationIndicatorTypeSchema = t.literal('sli.apm.transaction_duration');
|
||||
const apmTransactionDurationIndicatorTypeSchema = t.literal('sli.apm.transactionDuration');
|
||||
const apmTransactionDurationIndicatorSchema = t.type({
|
||||
type: apmTransactionDurationIndicatorTypeSchema,
|
||||
params: t.intersection([
|
||||
|
@ -26,7 +26,7 @@ const apmTransactionDurationIndicatorSchema = t.type({
|
|||
]),
|
||||
});
|
||||
|
||||
const apmTransactionErrorRateIndicatorTypeSchema = t.literal('sli.apm.transaction_error_rate');
|
||||
const apmTransactionErrorRateIndicatorTypeSchema = t.literal('sli.apm.transactionErrorRate');
|
||||
const apmTransactionErrorRateIndicatorSchema = t.type({
|
||||
type: apmTransactionErrorRateIndicatorTypeSchema,
|
||||
params: t.intersection([
|
||||
|
@ -69,7 +69,7 @@ const indicatorTypesSchema = t.union([
|
|||
]);
|
||||
|
||||
// Validate that a string is a comma separated list of indicator types,
|
||||
// e.g. sli.kql.custom,sli.apm.transaction_duration
|
||||
// e.g. sli.kql.custom,sli.apm.transactionDuration
|
||||
// Transform to an array of indicator type
|
||||
const indicatorTypesArraySchema = new t.Type<string[], string, unknown>(
|
||||
'indicatorTypesArray',
|
||||
|
|
|
@ -12,8 +12,8 @@ import { durationType } from './duration';
|
|||
import { indicatorSchema } from './indicators';
|
||||
import { timeWindowSchema } from './time_window';
|
||||
|
||||
const occurrencesBudgetingMethodSchema = t.literal<string>('occurrences');
|
||||
const timeslicesBudgetingMethodSchema = t.literal<string>('timeslices');
|
||||
const occurrencesBudgetingMethodSchema = t.literal('occurrences');
|
||||
const timeslicesBudgetingMethodSchema = t.literal('timeslices');
|
||||
|
||||
const budgetingMethodSchema = t.union([
|
||||
occurrencesBudgetingMethodSchema,
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
# SLO
|
||||
# SLO
|
||||
|
||||
Add the feature flag: `xpack.observability.unsafe.slo.enabled: true` in your Kibana config to enable the various SLO APIs.
|
||||
|
||||
## Supported SLI
|
||||
|
||||
We currently support the following SLI:
|
||||
|
||||
- APM Transaction Error Rate (Availability)
|
||||
- APM Transaction Duration (Latency)
|
||||
- Custom KQL
|
||||
|
@ -19,13 +20,13 @@ The **custom KQL** SLI requires an index pattern, an optional filter query, a nu
|
|||
|
||||
We support **calendar aligned** and **rolling** time windows. Any duration greater than 1 day can be used: days, weeks, months, quarters, years.
|
||||
|
||||
**Rolling time window:** Requires a duration, e.g. `1w` for one week, and `is_rolling: true`. SLOs defined with such time window, will only considere the SLI data from the last duration period as a moving window.
|
||||
**Rolling time window:** Requires a duration, e.g. `1w` for one week, and `isRolling: true`. SLOs defined with such time window, will only considere the SLI data from the last duration period as a moving window.
|
||||
|
||||
**Calendar aligned time window:** Requires a duration, e.g. `1M` for one month, and a `calendar.start_time` date in ISO 8601 in UTC, which marks the beginning of the first period. From start time and the duration, the system will compute the different time windows. For example, starting the calendar on the **01/01/2022** with a monthly duration, if today is the **24/10/2022**, the window associated is: `[2022-10-01T00:00:00Z, 2022-11-01T00:00:00Z]`
|
||||
**Calendar aligned time window:** Requires a duration, e.g. `1M` for one month, and a `calendar.startTime` date in ISO 8601 in UTC, which marks the beginning of the first period. From start time and the duration, the system will compute the different time windows. For example, starting the calendar on the **01/01/2022** with a monthly duration, if today is the **24/10/2022**, the window associated is: `[2022-10-01T00:00:00Z, 2022-11-01T00:00:00Z]`
|
||||
|
||||
### Budgeting method
|
||||
|
||||
An SLO can be configured with an **occurrences** or **timeslices** budgeting method.
|
||||
An SLO can be configured with an **occurrences** or **timeslices** budgeting method.
|
||||
|
||||
An **occurrences** budgeting method uses the number of **good** and **total** events during the time window.
|
||||
|
||||
|
@ -33,16 +34,17 @@ A **timeslices** budgeting method uses the number of **good slices** and **total
|
|||
|
||||
For example, defining a **timeslices** budgeting method with a `95%` slice threshold and `5m` slice window means that a 1 week SLO is split in 2,016 slices (`7*24*60 / 5`); for a 99% SLO target there will be approximately 20 minutes of available error budget. Each bucket is either good or bad depending on the ratio of good over total events during that bucket, compared to the slice threshold of 95%.
|
||||
|
||||
### Objective
|
||||
### Objective
|
||||
|
||||
The target objective is the value the SLO needs to meet during the time window.
|
||||
If a **timeslices** budgeting method is used, we also need to define the **timeslice_target** which can be different than the overall SLO target.
|
||||
If a **timeslices** budgeting method is used, we also need to define the **timesliceTarget** which can be different than the overall SLO target.
|
||||
|
||||
### Optional settings
|
||||
|
||||
The default settings should be sufficient for most users, but if needed, the following properties can be overwritten:
|
||||
- timestamp_field: The date time field to use from the source index
|
||||
- sync_delay: The ingest delay in the source data
|
||||
|
||||
- timestampField: The date time field to use from the source index
|
||||
- syncDelay: The ingest delay in the source data
|
||||
- frequency: How often do we query the source data
|
||||
|
||||
## Example
|
||||
|
@ -62,25 +64,26 @@ curl --request POST \
|
|||
"name": "My SLO Name",
|
||||
"description": "My SLO Description",
|
||||
"indicator": {
|
||||
"type": "sli.apm.transaction_error_rate",
|
||||
"type": "sli.apm.transactionErrorRate",
|
||||
"params": {
|
||||
"environment": "production",
|
||||
"service": "o11y-app",
|
||||
"transaction_type": "request",
|
||||
"transaction_name": "GET /api",
|
||||
"good_status_codes": ["2xx", "3xx", "4xx"]
|
||||
"transactionType": "request",
|
||||
"transactionName": "GET /api",
|
||||
"goodStatusCodes": ["2xx", "3xx", "4xx"]
|
||||
}
|
||||
},
|
||||
"time_window": {
|
||||
"timeWindow": {
|
||||
"duration": "30d",
|
||||
"is_rolling": true
|
||||
"isRolling": true
|
||||
},
|
||||
"budgeting_method": "occurrences",
|
||||
"budgetingMethod": "occurrences",
|
||||
"objective": {
|
||||
"target": 0.99
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
@ -96,27 +99,28 @@ curl --request POST \
|
|||
"name": "My SLO Name",
|
||||
"description": "My SLO Description",
|
||||
"indicator": {
|
||||
"type": "sli.apm.transaction_error_rate",
|
||||
"type": "sli.apm.transactionErrorRate",
|
||||
"params": {
|
||||
"environment": "production",
|
||||
"service": "o11y-app",
|
||||
"transaction_type": "request",
|
||||
"transaction_name": "GET /api",
|
||||
"good_status_codes": ["2xx", "3xx", "4xx"]
|
||||
"transactionType": "request",
|
||||
"transactionName": "GET /api",
|
||||
"goodStatusCodes": ["2xx", "3xx", "4xx"]
|
||||
}
|
||||
},
|
||||
"time_window": {
|
||||
"timeWindow": {
|
||||
"duration": "1q",
|
||||
"calendar": {
|
||||
"start_time": "2022-06-01T00:00:00.000Z"
|
||||
"startTime": "2022-06-01T00:00:00.000Z"
|
||||
}
|
||||
},
|
||||
"budgeting_method": "occurrences",
|
||||
"budgetingMethod": "occurrences",
|
||||
"objective": {
|
||||
"target": 0.95
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
@ -132,27 +136,28 @@ curl --request POST \
|
|||
"name": "My SLO Name",
|
||||
"description": "My SLO Description",
|
||||
"indicator": {
|
||||
"type": "sli.apm.transaction_error_rate",
|
||||
"type": "sli.apm.transactionErrorRate",
|
||||
"params": {
|
||||
"environment": "production",
|
||||
"service": "o11y-app",
|
||||
"transaction_type": "request",
|
||||
"transaction_name": "GET /api",
|
||||
"good_status_codes": ["2xx", "3xx", "4xx"]
|
||||
"transactionType": "request",
|
||||
"transactionName": "GET /api",
|
||||
"goodStatusCodes": ["2xx", "3xx", "4xx"]
|
||||
}
|
||||
},
|
||||
"time_window": {
|
||||
"timeWindow": {
|
||||
"duration": "1w",
|
||||
"is_rolling": true
|
||||
"isRolling": true
|
||||
},
|
||||
"budgeting_method": "timeslices",
|
||||
"budgetingMethod": "timeslices",
|
||||
"objective": {
|
||||
"target": 0.90,
|
||||
"timeslice_target": 0.86,
|
||||
"timeslice_window": "5m"
|
||||
"timesliceTarget": 0.86,
|
||||
"timesliceWindow": "5m"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Latency
|
||||
|
@ -170,25 +175,26 @@ curl --request POST \
|
|||
"name": "My SLO Name",
|
||||
"description": "My SLO Description",
|
||||
"indicator": {
|
||||
"type": "sli.apm.transaction_duration",
|
||||
"type": "sli.apm.transactionDuration",
|
||||
"params": {
|
||||
"environment": "production",
|
||||
"service": "o11y-app",
|
||||
"transaction_type": "request",
|
||||
"transaction_name": "GET /api",
|
||||
"transactionType": "request",
|
||||
"transactionName": "GET /api",
|
||||
"threshold.us": 500000
|
||||
}
|
||||
},
|
||||
"time_window": {
|
||||
"timeWindow": {
|
||||
"duration": "7d",
|
||||
"is_rolling": true
|
||||
"isRolling": true
|
||||
},
|
||||
"budgeting_method": "occurrences",
|
||||
"budgetingMethod": "occurrences",
|
||||
"objective": {
|
||||
"target": 0.99
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
@ -204,29 +210,29 @@ curl --request POST \
|
|||
"name": "My SLO Name",
|
||||
"description": "My SLO Description",
|
||||
"indicator": {
|
||||
"type": "sli.apm.transaction_duration",
|
||||
"type": "sli.apm.transactionDuration",
|
||||
"params": {
|
||||
"environment": "production",
|
||||
"service": "o11y-app",
|
||||
"transaction_type": "request",
|
||||
"transaction_name": "GET /api",
|
||||
"transactionType": "request",
|
||||
"transactionName": "GET /api",
|
||||
"threshold.us": 500000
|
||||
}
|
||||
},
|
||||
"time_window": {
|
||||
"timeWindow": {
|
||||
"duration": "7d",
|
||||
"is_rolling": true
|
||||
"isRolling": true
|
||||
},
|
||||
"budgeting_method": "timeslices",
|
||||
"budgetingMethod": "timeslices",
|
||||
"objective": {
|
||||
"target": 0.95,
|
||||
"timeslice_target": 0.90,
|
||||
"timeslice_window": "1m"
|
||||
"timesliceTarget": 0.90,
|
||||
"timesliceWindow": "1m"
|
||||
}
|
||||
}'
|
||||
```
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>99.9% of GET /api under 500ms weekly aligned (5m timeslices)</summary>
|
||||
|
@ -241,35 +247,34 @@ curl --request POST \
|
|||
"name": "My SLO Name",
|
||||
"description": "My SLO Description",
|
||||
"indicator": {
|
||||
"type": "sli.apm.transaction_duration",
|
||||
"type": "sli.apm.transactionDuration",
|
||||
"params": {
|
||||
"environment": "production",
|
||||
"service": "o11y-app",
|
||||
"transaction_type": "request",
|
||||
"transaction_name": "GET /api",
|
||||
"transactionType": "request",
|
||||
"transactionName": "GET /api",
|
||||
"threshold.us": 500000
|
||||
}
|
||||
},
|
||||
"time_window": {
|
||||
"timeWindow": {
|
||||
"duration": "7d",
|
||||
"calendar": {
|
||||
"start_time": "2022-01-01T00:00:00.000Z"
|
||||
"calendar": {
|
||||
"startTime": "2022-01-01T00:00:00.000Z"
|
||||
}
|
||||
},
|
||||
"budgeting_method": "timeslices",
|
||||
"budgetingMethod": "timeslices",
|
||||
"objective": {
|
||||
"target": 0.999,
|
||||
"timeslice_target": 0.95,
|
||||
"timeslice_window": "5m"
|
||||
"timesliceTarget": 0.95,
|
||||
"timesliceWindow": "5m"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
### Custom
|
||||
|
||||
|
||||
<details>
|
||||
<summary>98.5% of 'logs lantency < 300ms' for 'groupId: group-0' over the last 7 days</summary>
|
||||
|
||||
|
@ -291,14 +296,15 @@ curl --request POST \
|
|||
"filter": "labels.groupId: group-0"
|
||||
}
|
||||
},
|
||||
"time_window": {
|
||||
"timeWindow": {
|
||||
"duration": "7d",
|
||||
"is_rolling": true
|
||||
"isRolling": true
|
||||
},
|
||||
"budgeting_method": "occurrences",
|
||||
"budgetingMethod": "occurrences",
|
||||
"objective": {
|
||||
"target": 0.985
|
||||
}
|
||||
}'
|
||||
```
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
|
|
@ -18,6 +18,8 @@ export const paths = {
|
|||
ruleDetails: (ruleId?: string | null) =>
|
||||
ruleId ? `${RULES_PAGE_LINK}/${encodeURI(ruleId)}` : RULES_PAGE_LINK,
|
||||
slos: SLOS_PAGE_LINK,
|
||||
sloCreate: `${SLOS_PAGE_LINK}/create`,
|
||||
sloEdit: (sloId: string) => `${SLOS_PAGE_LINK}/edit/${encodeURI(sloId)}`,
|
||||
sloDetails: (sloId: string) => `${SLOS_PAGE_LINK}/${encodeURI(sloId)}`,
|
||||
},
|
||||
management: {
|
||||
|
|
|
@ -25,7 +25,7 @@ const baseSlo: Omit<SLOWithSummaryResponse, 'id'> = {
|
|||
index: 'some-index',
|
||||
filter: 'baz: foo and bar > 2',
|
||||
good: 'http_status: 2xx',
|
||||
total: '',
|
||||
total: 'a query',
|
||||
},
|
||||
},
|
||||
timeWindow: {
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { Index, UseFetchIndicesResponse } from '../use_fetch_indices';
|
||||
|
||||
export const useFetchIndices = (): UseFetchIndicesResponse => {
|
||||
return {
|
||||
loading: false,
|
||||
error: false,
|
||||
indices: Array.from({ length: 5 }, (_, i) => ({
|
||||
name: `.index${i}`,
|
||||
})) as Index[],
|
||||
};
|
||||
};
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { CreateSLOParams, CreateSLOResponse } from '@kbn/slo-schema';
|
||||
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
|
||||
interface UseCreateSlo {
|
||||
loading: boolean;
|
||||
success: boolean;
|
||||
error: string | undefined;
|
||||
createSlo: (slo: CreateSLOParams) => void;
|
||||
}
|
||||
|
||||
export function useCreateSlo(): UseCreateSlo {
|
||||
const { http } = useKibana().services;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
const createSlo = useCallback(
|
||||
async (slo: CreateSLOParams) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
const body = JSON.stringify(slo);
|
||||
|
||||
try {
|
||||
await http.post<CreateSLOResponse>(`/api/observability/slos`, { body });
|
||||
setSuccess(true);
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
}
|
||||
},
|
||||
[http]
|
||||
);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
success,
|
||||
createSlo,
|
||||
};
|
||||
}
|
|
@ -10,12 +10,12 @@ import { useCallback, useMemo } from 'react';
|
|||
import { GetSLOResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
import { useDataFetcher } from '../use_data_fetcher';
|
||||
|
||||
interface UseFetchSloDetailsResponse {
|
||||
export interface UseFetchSloDetailsResponse {
|
||||
loading: boolean;
|
||||
slo: SLOWithSummaryResponse | undefined;
|
||||
}
|
||||
|
||||
function useFetchSloDetails(sloId?: string): UseFetchSloDetailsResponse {
|
||||
export function useFetchSloDetails(sloId?: string): UseFetchSloDetailsResponse {
|
||||
const params = useMemo(() => ({ sloId }), [sloId]);
|
||||
const shouldExecuteApiCall = useCallback(
|
||||
(apiCallParams: { sloId?: string }) => params.sloId === apiCallParams.sloId,
|
||||
|
@ -57,6 +57,3 @@ const fetchSlo = async (
|
|||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export type { UseFetchSloDetailsResponse };
|
||||
export { useFetchSloDetails };
|
||||
|
|
|
@ -37,12 +37,8 @@ export function useFetchSloList({
|
|||
refetch,
|
||||
sortBy,
|
||||
indicatorTypes,
|
||||
}: {
|
||||
}: SLOListParams & {
|
||||
refetch: boolean;
|
||||
name?: string;
|
||||
page?: number;
|
||||
sortBy?: string;
|
||||
indicatorTypes?: string[];
|
||||
}): UseFetchSloListResponse {
|
||||
const [sloList, setSloList] = useState(EMPTY_LIST);
|
||||
|
||||
|
@ -84,9 +80,9 @@ const fetchSloList = async (
|
|||
query: {
|
||||
...(params.page && { page: params.page }),
|
||||
...(params.name && { name: params.name }),
|
||||
...(params.sortBy && { sort_by: params.sortBy }),
|
||||
...(params.sortBy && { sortBy: params.sortBy }),
|
||||
...(params.indicatorTypes &&
|
||||
params.indicatorTypes.length > 0 && { indicator_types: params.indicatorTypes.join(',') }),
|
||||
params.indicatorTypes.length > 0 && { indicatorTypes: params.indicatorTypes.join(',') }),
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { HttpSetup } from '@kbn/core/public';
|
||||
import { useRef } from 'react';
|
||||
import { useDataFetcher } from './use_data_fetcher';
|
||||
|
||||
export interface UseFetchIndicesResponse {
|
||||
indices: Index[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export interface Index {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function useFetchIndices(): UseFetchIndicesResponse {
|
||||
const hasFetched = useRef<boolean>(false);
|
||||
|
||||
const {
|
||||
data: indices,
|
||||
loading,
|
||||
error,
|
||||
} = useDataFetcher({
|
||||
paramsForApiCall: {},
|
||||
initialDataState: undefined,
|
||||
executeApiCall: async (
|
||||
_: any,
|
||||
abortController: AbortController,
|
||||
http: HttpSetup
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const response = await http.get<Index[]>(`/api/index_management/indices`, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (response !== undefined) {
|
||||
hasFetched.current = true;
|
||||
return response;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore error for retrieving slos
|
||||
}
|
||||
|
||||
return;
|
||||
},
|
||||
shouldExecuteApiCall: () => (hasFetched.current === false ? true : false),
|
||||
});
|
||||
|
||||
return { indices, loading, error };
|
||||
}
|
|
@ -18,7 +18,7 @@ import { usePluginContext } from '../../hooks/use_plugin_context';
|
|||
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
import PageNotFound from '../404';
|
||||
import { isSloFeatureEnabled } from '../slos/helpers';
|
||||
import { isSloFeatureEnabled } from '../slos/helpers/is_slo_feature_enabled';
|
||||
import { SLOS_BREADCRUMB_TEXT } from '../slos/translations';
|
||||
import { SloDetailsPathParams } from './types';
|
||||
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ComponentStory } from '@storybook/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
|
||||
import { SloEditForm as Component, Props } from './slo_edit_form';
|
||||
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
|
||||
|
||||
export default {
|
||||
component: Component,
|
||||
title: 'app/SLO/EditPage/SloEditForm',
|
||||
decorators: [KibanaReactStorybookDecorator],
|
||||
};
|
||||
|
||||
const Template: ComponentStory<typeof Component> = (props: Props) => {
|
||||
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Component {...props} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultProps = {};
|
||||
|
||||
export const SloEditForm = Template.bind({});
|
||||
SloEditForm.args = defaultProps;
|
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiAvatar,
|
||||
EuiButton,
|
||||
EuiFormLabel,
|
||||
EuiPanel,
|
||||
EuiSelect,
|
||||
EuiSpacer,
|
||||
EuiTimeline,
|
||||
EuiTimelineItem,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import { euiThemeVars } from '@kbn/ui-theme';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
import type { SLOWithSummaryResponse } from '@kbn/slo-schema';
|
||||
|
||||
import { useKibana } from '../../../utils/kibana_react';
|
||||
import { useCreateSlo } from '../../../hooks/slo/use_create_slo';
|
||||
import { useCheckFormPartialValidities } from '../helpers/use_check_form_partial_validities';
|
||||
import { SloEditFormDefinitionCustomKql } from './slo_edit_form_definition_custom_kql';
|
||||
import { SloEditFormDescription } from './slo_edit_form_description';
|
||||
import { SloEditFormObjectives } from './slo_edit_form_objectives';
|
||||
import {
|
||||
processValues,
|
||||
transformGetSloToCreateSloParams,
|
||||
} from '../helpers/process_slo_form_values';
|
||||
import { paths } from '../../../config';
|
||||
import { SLI_OPTIONS, SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
|
||||
|
||||
export interface Props {
|
||||
slo: SLOWithSummaryResponse | undefined;
|
||||
}
|
||||
|
||||
const maxWidth = 775;
|
||||
|
||||
export function SloEditForm({ slo }: Props) {
|
||||
const {
|
||||
application: { navigateToUrl },
|
||||
http: { basePath },
|
||||
notifications: { toasts },
|
||||
} = useKibana().services;
|
||||
|
||||
const { control, watch, getFieldState, getValues, formState, trigger } = useForm({
|
||||
defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES,
|
||||
values: transformGetSloToCreateSloParams(slo),
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const { isDefinitionValid, isDescriptionValid, isObjectiveValid } = useCheckFormPartialValidities(
|
||||
{ getFieldState, formState }
|
||||
);
|
||||
|
||||
const {
|
||||
loading: loadingCreatingSlo,
|
||||
success: successCreatingSlo,
|
||||
error: errorCreatingSlo,
|
||||
createSlo,
|
||||
} = useCreateSlo();
|
||||
|
||||
const handleCreateSlo = () => {
|
||||
const values = getValues();
|
||||
const processedValues = processValues(values);
|
||||
createSlo(processedValues);
|
||||
};
|
||||
|
||||
if (successCreatingSlo) {
|
||||
toasts.addSuccess(
|
||||
i18n.translate('xpack.observability.slos.sloEdit.creation.success', {
|
||||
defaultMessage: 'Successfully created {name}',
|
||||
values: { name: getValues().name },
|
||||
})
|
||||
);
|
||||
navigateToUrl(basePath.prepend(paths.observability.slos));
|
||||
}
|
||||
|
||||
if (errorCreatingSlo) {
|
||||
toasts.addError(new Error(errorCreatingSlo), {
|
||||
title: i18n.translate('xpack.observability.slos.sloEdit.creation.error', {
|
||||
defaultMessage: 'Something went wrong',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiTimeline data-test-subj="sloForm">
|
||||
<EuiTimelineItem
|
||||
verticalAlign="top"
|
||||
icon={
|
||||
<EuiAvatar
|
||||
name={isDefinitionValid ? 'Check' : '1'}
|
||||
iconType={isDefinitionValid ? 'check' : ''}
|
||||
color={isDefinitionValid ? euiThemeVars.euiColorSuccess : euiThemeVars.euiColorPrimary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.definition.title', {
|
||||
defaultMessage: 'Define SLI',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.definition.sliType', {
|
||||
defaultMessage: 'SLI type',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
|
||||
<Controller
|
||||
name="indicator.type"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<EuiSelect
|
||||
data-test-subj="sloFormIndicatorTypeSelect"
|
||||
{...field}
|
||||
options={SLI_OPTIONS}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<EuiSpacer size="xxl" />
|
||||
|
||||
{watch('indicator.type') === 'sli.kql.custom' ? (
|
||||
<SloEditFormDefinitionCustomKql control={control} trigger={trigger} />
|
||||
) : null}
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
</EuiPanel>
|
||||
</EuiTimelineItem>
|
||||
|
||||
<EuiTimelineItem
|
||||
verticalAlign="top"
|
||||
icon={
|
||||
<EuiAvatar
|
||||
name={isObjectiveValid ? 'Check' : '2'}
|
||||
iconType={isObjectiveValid ? 'check' : ''}
|
||||
color={isObjectiveValid ? euiThemeVars.euiColorSuccess : euiThemeVars.euiColorPrimary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.objectives.title', {
|
||||
defaultMessage: 'Set objectives',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
|
||||
<SloEditFormObjectives control={control} watch={watch} />
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
</EuiPanel>
|
||||
</EuiTimelineItem>
|
||||
|
||||
<EuiTimelineItem
|
||||
verticalAlign="top"
|
||||
icon={
|
||||
<EuiAvatar
|
||||
name={isDescriptionValid ? 'Check' : '3'}
|
||||
iconType={isDescriptionValid ? 'check' : ''}
|
||||
color={isDescriptionValid ? euiThemeVars.euiColorSuccess : euiThemeVars.euiColorPrimary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiPanel hasBorder={false} hasShadow={false} paddingSize="none" style={{ maxWidth }}>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.description.title', {
|
||||
defaultMessage: 'Describe SLO',
|
||||
})}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
|
||||
<SloEditFormDescription control={control} />
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
|
||||
<EuiButton
|
||||
fill
|
||||
color="primary"
|
||||
data-test-subj="sloFormSubmitButton"
|
||||
onClick={handleCreateSlo}
|
||||
disabled={!formState.isValid}
|
||||
isLoading={loadingCreatingSlo && !errorCreatingSlo}
|
||||
>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.createSloButton', {
|
||||
defaultMessage: 'Create SLO',
|
||||
})}
|
||||
</EuiButton>
|
||||
|
||||
<EuiSpacer size="xl" />
|
||||
</EuiPanel>
|
||||
</EuiTimelineItem>
|
||||
</EuiTimeline>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ComponentStory } from '@storybook/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
|
||||
import {
|
||||
SloEditFormDefinitionCustomKql as Component,
|
||||
Props,
|
||||
} from './slo_edit_form_definition_custom_kql';
|
||||
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
|
||||
|
||||
export default {
|
||||
component: Component,
|
||||
title: 'app/SLO/EditPage/SloEditFormDefinitionCustomKql',
|
||||
decorators: [KibanaReactStorybookDecorator],
|
||||
};
|
||||
|
||||
const Template: ComponentStory<typeof Component> = (props: Props) => {
|
||||
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Component {...props} control={methods.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultProps = {};
|
||||
|
||||
export const SloEditFormDefinitionCustomKql = Template.bind({});
|
||||
SloEditFormDefinitionCustomKql.args = defaultProps;
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFormLabel, EuiSuggest } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Control, Controller, UseFormTrigger } from 'react-hook-form';
|
||||
import type { CreateSLOParams } from '@kbn/slo-schema';
|
||||
|
||||
import { useFetchIndices } from '../../../hooks/use_fetch_indices';
|
||||
|
||||
export interface Props {
|
||||
control: Control<CreateSLOParams>;
|
||||
trigger: UseFormTrigger<CreateSLOParams>;
|
||||
}
|
||||
|
||||
export function SloEditFormDefinitionCustomKql({ control, trigger }: Props) {
|
||||
const { loading, indices = [] } = useFetchIndices();
|
||||
|
||||
const indicesNames = indices.map(({ name }) => ({
|
||||
type: { iconType: '', color: '' },
|
||||
label: name,
|
||||
description: '',
|
||||
}));
|
||||
|
||||
// Indices are loading in asynchrously, so trigger field validation
|
||||
// once results are returned from API
|
||||
useEffect(() => {
|
||||
if (!loading && indices.length) {
|
||||
trigger();
|
||||
}
|
||||
}, [indices.length, loading, trigger]);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="l">
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.sloDefinition.customKql.index', {
|
||||
defaultMessage: 'Index',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
|
||||
<Controller
|
||||
name="indicator.params.index"
|
||||
control={control}
|
||||
rules={{
|
||||
required: true,
|
||||
validate: (value) => Boolean(indices.find((index) => index.name === value)),
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<EuiSuggest
|
||||
fullWidth
|
||||
isClearable
|
||||
aria-label="Indices"
|
||||
data-test-subj="sloFormCustomKqlIndexInput"
|
||||
status={loading ? 'loading' : field.value ? 'unchanged' : 'unchanged'}
|
||||
onItemClick={({ label }) => {
|
||||
field.onChange(label);
|
||||
}}
|
||||
isInvalid={!Boolean(indicesNames.find((index) => index.label === field.value))}
|
||||
placeholder={i18n.translate(
|
||||
'xpack.observability.slos.sloEdit.sloDefinition.customKql.index.selectIndex',
|
||||
{
|
||||
defaultMessage: 'Select an index',
|
||||
}
|
||||
)}
|
||||
suggestions={indicesNames}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.sloDefinition.customKql.queryFilter', {
|
||||
defaultMessage: 'Query filter',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
<Controller
|
||||
name="indicator.params.filter"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<EuiSuggest
|
||||
append={<EuiButtonEmpty>KQL</EuiButtonEmpty>}
|
||||
status="unchanged"
|
||||
aria-label="Filter query"
|
||||
data-test-subj="sloFormCustomKqlFilterQueryInput"
|
||||
placeholder={i18n.translate(
|
||||
'xpack.observability.slos.sloEdit.sloDefinition.customKql.customFilter',
|
||||
{
|
||||
defaultMessage: 'Custom filter to apply on the index',
|
||||
}
|
||||
)}
|
||||
suggestions={[]}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.sloDefinition.customKql.goodQuery', {
|
||||
defaultMessage: 'Good query',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
<Controller
|
||||
name="indicator.params.good"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<EuiSuggest
|
||||
append={<EuiButtonEmpty>KQL</EuiButtonEmpty>}
|
||||
status="unchanged"
|
||||
aria-label="Good filter"
|
||||
data-test-subj="sloFormCustomKqlGoodQueryInput"
|
||||
placeholder={i18n.translate(
|
||||
'xpack.observability.slos.sloEdit.sloDefinition.customKql.goodQueryPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Define the good events',
|
||||
}
|
||||
)}
|
||||
suggestions={[]}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.sloDefinition.customKql.totalQuery', {
|
||||
defaultMessage: 'Total query',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
<Controller
|
||||
name="indicator.params.total"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<EuiSuggest
|
||||
append={<EuiButtonEmpty>KQL</EuiButtonEmpty>}
|
||||
status="unchanged"
|
||||
aria-label="Total filter"
|
||||
data-test-subj="sloFormCustomKqlTotalQueryInput"
|
||||
placeholder={i18n.translate(
|
||||
'xpack.observability.slos.sloEdit.sloDefinition.customKql.totalQueryPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Define the total events',
|
||||
}
|
||||
)}
|
||||
suggestions={[]}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ComponentStory } from '@storybook/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
|
||||
import { SloEditFormDescription as Component, Props } from './slo_edit_form_description';
|
||||
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
|
||||
|
||||
export default {
|
||||
component: Component,
|
||||
title: 'app/SLO/EditPage/SloEditFormDescription',
|
||||
decorators: [KibanaReactStorybookDecorator],
|
||||
};
|
||||
|
||||
const Template: ComponentStory<typeof Component> = (props: Props) => {
|
||||
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Component {...props} control={methods.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultProps = {};
|
||||
|
||||
export const SloEditFormDescription = Template.bind({});
|
||||
SloEditFormDescription.args = defaultProps;
|
|
@ -0,0 +1,87 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormLabel,
|
||||
EuiTextArea,
|
||||
useGeneratedHtmlId,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
import type { CreateSLOParams } from '@kbn/slo-schema';
|
||||
|
||||
export interface Props {
|
||||
control: Control<CreateSLOParams>;
|
||||
}
|
||||
|
||||
export function SloEditFormDescription({ control }: Props) {
|
||||
const sloNameId = useGeneratedHtmlId({ prefix: 'sloName' });
|
||||
const descriptionId = useGeneratedHtmlId({ prefix: 'sloDescription' });
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="l">
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.description.sloName', {
|
||||
defaultMessage: 'SLO Name',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
id={sloNameId}
|
||||
data-test-subj="sloFormNameInput"
|
||||
placeholder={i18n.translate(
|
||||
'xpack.observability.slos.sloEdit.description.sloNamePlaceholder',
|
||||
{
|
||||
defaultMessage: 'Name for the SLO',
|
||||
}
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.description.sloDescription', {
|
||||
defaultMessage: 'Description',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<EuiTextArea
|
||||
fullWidth
|
||||
id={descriptionId}
|
||||
data-test-subj="sloFormDescriptionTextArea"
|
||||
placeholder={i18n.translate(
|
||||
'xpack.observability.slos.sloEdit.description.sloDescriptionPlaceholder',
|
||||
{
|
||||
defaultMessage: 'A short description of the SLO',
|
||||
}
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ComponentStory } from '@storybook/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
|
||||
import { SloEditFormObjectives as Component, Props } from './slo_edit_form_objectives';
|
||||
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
|
||||
|
||||
export default {
|
||||
component: Component,
|
||||
title: 'app/SLO/EditPage/SloEditFormObjectives',
|
||||
decorators: [KibanaReactStorybookDecorator],
|
||||
};
|
||||
|
||||
const Template: ComponentStory<typeof Component> = (props: Props) => {
|
||||
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Component {...props} control={methods.control} watch={methods.watch} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultProps = {};
|
||||
|
||||
export const SloEditFormObjectives = Template.bind({});
|
||||
SloEditFormObjectives.args = defaultProps;
|
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
EuiFieldNumber,
|
||||
EuiFlexGrid,
|
||||
EuiFlexItem,
|
||||
EuiFormLabel,
|
||||
EuiSelect,
|
||||
EuiSpacer,
|
||||
useGeneratedHtmlId,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Control, Controller, UseFormWatch } from 'react-hook-form';
|
||||
import type { BudgetingMethod, CreateSLOParams } from '@kbn/slo-schema';
|
||||
|
||||
import { SloEditFormObjectivesTimeslices } from './slo_edit_form_objectives_timeslices';
|
||||
|
||||
export const BUDGETING_METHOD_OPTIONS: Array<{ value: BudgetingMethod; text: string }> = [
|
||||
{ value: 'occurrences', text: 'Occurences' },
|
||||
{ value: 'timeslices', text: 'Timeslices' },
|
||||
];
|
||||
|
||||
export const TIMEWINDOW_OPTIONS = [30, 7].map((number) => ({
|
||||
value: `${number}d`,
|
||||
text: i18n.translate('xpack.observability.slos.sloEdit.objectives.days', {
|
||||
defaultMessage: '{number} days',
|
||||
values: { number },
|
||||
}),
|
||||
}));
|
||||
|
||||
export interface Props {
|
||||
control: Control<CreateSLOParams>;
|
||||
watch: UseFormWatch<CreateSLOParams>;
|
||||
}
|
||||
|
||||
export function SloEditFormObjectives({ control, watch }: Props) {
|
||||
const budgetingSelect = useGeneratedHtmlId({ prefix: 'budgetingSelect' });
|
||||
const timeWindowSelect = useGeneratedHtmlId({ prefix: 'timeWindowSelect' });
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGrid columns={3}>
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.objectives.budgetingMethod', {
|
||||
defaultMessage: 'Budgeting method',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
|
||||
<Controller
|
||||
name="budgetingMethod"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<EuiSelect
|
||||
id={budgetingSelect}
|
||||
data-test-subj="sloFormBudgetingMethodSelect"
|
||||
options={BUDGETING_METHOD_OPTIONS}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.objectives.timeWindow', {
|
||||
defaultMessage: 'Time window',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
|
||||
<Controller
|
||||
name="timeWindow.duration"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<EuiSelect
|
||||
id={timeWindowSelect}
|
||||
data-test-subj="sloFormTimeWindowDurationSelect"
|
||||
options={TIMEWINDOW_OPTIONS}
|
||||
{...field}
|
||||
value={String(field.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.objectives.targetSlo', {
|
||||
defaultMessage: 'Target / SLO (%)',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
|
||||
<Controller
|
||||
name="objective.target"
|
||||
control={control}
|
||||
rules={{
|
||||
required: true,
|
||||
min: 0.001,
|
||||
max: 99.999,
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<EuiFieldNumber
|
||||
data-test-subj="sloFormObjectiveTargetInput"
|
||||
{...field}
|
||||
min={0.001}
|
||||
max={99.999}
|
||||
step={0.001}
|
||||
onChange={(event) => field.onChange(Number(event.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGrid>
|
||||
|
||||
{watch('budgetingMethod') === 'timeslices' ? (
|
||||
<>
|
||||
<EuiSpacer size="xl" />
|
||||
<SloEditFormObjectivesTimeslices control={control} />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ComponentStory } from '@storybook/react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
|
||||
import {
|
||||
SloEditFormObjectivesTimeslices as Component,
|
||||
Props,
|
||||
} from './slo_edit_form_objectives_timeslices';
|
||||
import { SLO_EDIT_FORM_DEFAULT_VALUES } from '../constants';
|
||||
|
||||
export default {
|
||||
component: Component,
|
||||
title: 'app/SLO/EditPage/SloEditFormObjectivesTimeslices',
|
||||
decorators: [KibanaReactStorybookDecorator],
|
||||
};
|
||||
|
||||
const Template: ComponentStory<typeof Component> = (props: Props) => {
|
||||
const methods = useForm({ defaultValues: SLO_EDIT_FORM_DEFAULT_VALUES });
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Component {...props} control={methods.control} />
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const defaultProps = {};
|
||||
|
||||
export const SloEditFormObjectivesTimeslices = Template.bind({});
|
||||
SloEditFormObjectivesTimeslices.args = defaultProps;
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFieldNumber, EuiFlexGrid, EuiFlexItem, EuiFormLabel } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { Control, Controller } from 'react-hook-form';
|
||||
import type { CreateSLOParams } from '@kbn/slo-schema';
|
||||
|
||||
export interface Props {
|
||||
control: Control<CreateSLOParams>;
|
||||
}
|
||||
|
||||
export function SloEditFormObjectivesTimeslices({ control }: Props) {
|
||||
return (
|
||||
<EuiFlexGrid columns={3}>
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.objectives.timeSliceTarget', {
|
||||
defaultMessage: 'Timeslice target (%)',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
<Controller
|
||||
name="objective.timesliceTarget"
|
||||
control={control}
|
||||
defaultValue={95}
|
||||
rules={{
|
||||
required: true,
|
||||
min: 0.001,
|
||||
max: 99.999,
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<EuiFieldNumber
|
||||
{...field}
|
||||
data-test-subj="sloFormObjectiveTimesliceTargetInput"
|
||||
min={0.001}
|
||||
max={99.999}
|
||||
step={0.001}
|
||||
onChange={(event) => field.onChange(Number(event.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem>
|
||||
<EuiFormLabel>
|
||||
{i18n.translate('xpack.observability.slos.sloEdit.objectives.timesliceWindow', {
|
||||
defaultMessage: 'Timeslice window (minutes)',
|
||||
})}
|
||||
</EuiFormLabel>
|
||||
|
||||
<Controller
|
||||
name="objective.timesliceWindow"
|
||||
control={control}
|
||||
rules={{ required: true, min: 1, max: 120 }}
|
||||
render={({ field }) => (
|
||||
<EuiFieldNumber
|
||||
{...field}
|
||||
data-test-subj="sloFormObjectiveTimesliceWindowInput"
|
||||
value={String(field.value)}
|
||||
min={1}
|
||||
max={120}
|
||||
step={1}
|
||||
onChange={(event) => field.onChange(String(event.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGrid>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { CreateSLOParams } from '@kbn/slo-schema';
|
||||
|
||||
import {
|
||||
BUDGETING_METHOD_OPTIONS,
|
||||
TIMEWINDOW_OPTIONS,
|
||||
} from './components/slo_edit_form_objectives';
|
||||
|
||||
export const SLI_OPTIONS = [
|
||||
{
|
||||
value: 'sli.kql.custom' as const,
|
||||
text: i18n.translate('xpack.observability.slos.sloTypes.kqlCustomIndicator', {
|
||||
defaultMessage: 'KQL custom indicator',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const SLO_EDIT_FORM_DEFAULT_VALUES: CreateSLOParams = {
|
||||
name: '',
|
||||
description: '',
|
||||
indicator: {
|
||||
type: SLI_OPTIONS[0].value,
|
||||
params: {
|
||||
index: '',
|
||||
filter: '',
|
||||
good: '',
|
||||
total: '',
|
||||
},
|
||||
},
|
||||
timeWindow: {
|
||||
duration: TIMEWINDOW_OPTIONS[0].value as any, // Get this to be a proper Duration
|
||||
isRolling: true,
|
||||
},
|
||||
budgetingMethod: BUDGETING_METHOD_OPTIONS[0].value,
|
||||
objective: {
|
||||
target: 99.5,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { CreateSLOParams, GetSLOResponse } from '@kbn/slo-schema';
|
||||
|
||||
export function transformGetSloToCreateSloParams(
|
||||
values: GetSLOResponse | undefined
|
||||
): CreateSLOParams | undefined {
|
||||
if (!values) return undefined;
|
||||
|
||||
return {
|
||||
...values,
|
||||
objective: {
|
||||
target: values.objective.target * 100,
|
||||
...(values.objective.timesliceTarget && {
|
||||
timesliceTarget: values.objective.timesliceTarget * 100,
|
||||
}),
|
||||
},
|
||||
} as unknown as CreateSLOParams;
|
||||
}
|
||||
|
||||
export function processValues(values: CreateSLOParams): CreateSLOParams {
|
||||
return {
|
||||
...values,
|
||||
objective: {
|
||||
target: values.objective.target / 100,
|
||||
...(values.objective.timesliceTarget && {
|
||||
timesliceTarget: values.objective.timesliceTarget / 100,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FormState, UseFormGetFieldState } from 'react-hook-form';
|
||||
import type { CreateSLOParams } from '@kbn/slo-schema';
|
||||
|
||||
interface Props {
|
||||
getFieldState: UseFormGetFieldState<CreateSLOParams>;
|
||||
formState: FormState<CreateSLOParams>;
|
||||
}
|
||||
|
||||
export function useCheckFormPartialValidities({ getFieldState, formState }: Props) {
|
||||
const isDefinitionValid = (
|
||||
[
|
||||
'indicator.params.index',
|
||||
'indicator.params.filter',
|
||||
'indicator.params.good',
|
||||
'indicator.params.total',
|
||||
] as const
|
||||
).every((field) => getFieldState(field, formState).error === undefined);
|
||||
|
||||
const isObjectiveValid = (
|
||||
['budgetingMethod', 'timeWindow.duration', 'objective.target'] as const
|
||||
).every((field) => getFieldState(field, formState).error === undefined);
|
||||
|
||||
const isDescriptionValid = (['name', 'description'] as const).every(
|
||||
(field) => getFieldState(field, formState).error === undefined
|
||||
);
|
||||
|
||||
return { isDefinitionValid, isObjectiveValid, isDescriptionValid };
|
||||
}
|
|
@ -0,0 +1,322 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Router from 'react-router-dom';
|
||||
import { waitFor, fireEvent, screen } from '@testing-library/dom';
|
||||
import { BasePath } from '@kbn/core-http-server-internal';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
import { render } from '../../utils/test_helper';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
import { useFetchIndices } from '../../hooks/use_fetch_indices';
|
||||
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
|
||||
import { useCreateSlo } from '../../hooks/slo/use_create_slo';
|
||||
import { kibanaStartMock } from '../../utils/kibana_react.mock';
|
||||
import { ConfigSchema } from '../../plugin';
|
||||
import { Subset } from '../../typings';
|
||||
import { SLO_EDIT_FORM_DEFAULT_VALUES } from './constants';
|
||||
import { anSLO } from '../../data/slo';
|
||||
import { paths } from '../../config';
|
||||
import { SloEditPage } from '.';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../hooks/use_breadcrumbs');
|
||||
jest.mock('../../hooks/use_fetch_indices');
|
||||
jest.mock('../../hooks/slo/use_fetch_slo_details');
|
||||
jest.mock('../../hooks/slo/use_create_slo');
|
||||
|
||||
const mockUseKibanaReturnValue = kibanaStartMock.startContract();
|
||||
|
||||
jest.mock('../../utils/kibana_react', () => ({
|
||||
useKibana: jest.fn(() => mockUseKibanaReturnValue),
|
||||
}));
|
||||
|
||||
const useFetchIndicesMock = useFetchIndices as jest.Mock;
|
||||
const useFetchSloMock = useFetchSloDetails as jest.Mock;
|
||||
const useCreateSloMock = useCreateSlo as jest.Mock;
|
||||
|
||||
const mockAddSuccess = jest.fn();
|
||||
const mockAddError = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
application: { navigateToUrl: mockNavigate },
|
||||
http: {
|
||||
basePath: new BasePath('', undefined),
|
||||
},
|
||||
notifications: {
|
||||
toasts: {
|
||||
addSuccess: mockAddSuccess,
|
||||
addError: mockAddError,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const config: Subset<ConfigSchema> = {
|
||||
unsafe: {
|
||||
slo: { enabled: true },
|
||||
},
|
||||
};
|
||||
|
||||
describe('SLO Edit Page', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Silence all the ref errors in Eui components.
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('when the feature flag is not enabled', () => {
|
||||
it('renders the not found page when no sloId param is passed', async () => {
|
||||
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
|
||||
|
||||
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
|
||||
|
||||
render(<SloEditPage />, { unsafe: { slo: { enabled: false } } });
|
||||
|
||||
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the not found page when sloId param is passed', async () => {
|
||||
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '1234' });
|
||||
|
||||
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
|
||||
|
||||
render(<SloEditPage />, { unsafe: { slo: { enabled: false } } });
|
||||
|
||||
expect(screen.queryByTestId('pageNotFound')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the feature flag is enabled', () => {
|
||||
it('renders the SLO Edit page in pristine state when no sloId route param is passed', async () => {
|
||||
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: undefined });
|
||||
|
||||
useFetchSloMock.mockReturnValue({ loading: false, slo: undefined });
|
||||
useFetchIndicesMock.mockReturnValue({
|
||||
loading: false,
|
||||
indices: [{ name: 'some-index' }],
|
||||
});
|
||||
useCreateSloMock.mockReturnValue({
|
||||
loading: false,
|
||||
success: false,
|
||||
error: '',
|
||||
createSlo: jest.fn(),
|
||||
});
|
||||
|
||||
render(<SloEditPage />, config);
|
||||
|
||||
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
|
||||
expect(screen.queryByTestId('sloForm')).toBeTruthy();
|
||||
|
||||
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.index
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
|
||||
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.filter
|
||||
: ''
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormCustomKqlGoodQueryInput')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
|
||||
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.good
|
||||
: ''
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormCustomKqlTotalQueryInput')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.indicator.type === 'sli.kql.custom'
|
||||
? SLO_EDIT_FORM_DEFAULT_VALUES.indicator.params.total
|
||||
: ''
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.budgetingMethod
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.timeWindow.duration as any
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.objective.target
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.name
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(
|
||||
SLO_EDIT_FORM_DEFAULT_VALUES.description
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the SLO Edit page with prefilled form values if sloId route param is passed', async () => {
|
||||
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
|
||||
|
||||
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
|
||||
useFetchIndicesMock.mockReturnValue({
|
||||
loading: false,
|
||||
indices: [{ name: 'some-index' }],
|
||||
});
|
||||
useCreateSloMock.mockReturnValue({
|
||||
loading: false,
|
||||
success: false,
|
||||
error: '',
|
||||
createSlo: jest.fn(),
|
||||
});
|
||||
render(<SloEditPage />, config);
|
||||
|
||||
expect(screen.queryByTestId('slosEditPage')).toBeTruthy();
|
||||
expect(screen.queryByTestId('sloForm')).toBeTruthy();
|
||||
|
||||
expect(screen.queryByTestId('sloFormIndicatorTypeSelect')).toHaveValue(anSLO.indicator.type);
|
||||
|
||||
expect(screen.queryByTestId('sloFormCustomKqlIndexInput')).toHaveValue(
|
||||
anSLO.indicator.params.index
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormCustomKqlFilterQueryInput')).toHaveValue(
|
||||
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.filter : ''
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormCustomKqlGoodQueryInput')).toHaveValue(
|
||||
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.good : ''
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormCustomKqlTotalQueryInput')).toHaveValue(
|
||||
anSLO.indicator.type === 'sli.kql.custom' ? anSLO.indicator.params.total : ''
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('sloFormBudgetingMethodSelect')).toHaveValue(
|
||||
anSLO.budgetingMethod
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormTimeWindowDurationSelect')).toHaveValue(
|
||||
anSLO.timeWindow.duration
|
||||
);
|
||||
expect(screen.queryByTestId('sloFormObjectiveTargetInput')).toHaveValue(
|
||||
anSLO.objective.target * 100
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('sloFormNameInput')).toHaveValue(anSLO.name);
|
||||
expect(screen.queryByTestId('sloFormDescriptionTextArea')).toHaveValue(anSLO.description);
|
||||
});
|
||||
|
||||
it('enables submitting if all required values are filled in', async () => {
|
||||
// Note: the `anSLO` object is considered to have (at least)
|
||||
// values for all required fields.
|
||||
|
||||
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
|
||||
|
||||
useFetchIndicesMock.mockReturnValue({
|
||||
loading: false,
|
||||
indices: [{ name: 'some-index' }],
|
||||
});
|
||||
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
|
||||
const mockCreate = jest.fn();
|
||||
useCreateSloMock.mockReturnValue({
|
||||
loading: false,
|
||||
success: false,
|
||||
error: '',
|
||||
createSlo: mockCreate,
|
||||
});
|
||||
|
||||
render(<SloEditPage />, config);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('sloFormSubmitButton')).toBeEnabled());
|
||||
|
||||
fireEvent.click(screen.queryByTestId('sloFormSubmitButton')!);
|
||||
|
||||
expect(mockCreate).toBeCalledWith(anSLO);
|
||||
});
|
||||
|
||||
it('blocks submitting if not all required values are filled in', async () => {
|
||||
// Note: the `anSLO` object is considered to have (at least)
|
||||
// values for all required fields.
|
||||
|
||||
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
|
||||
useFetchIndicesMock.mockReturnValue({
|
||||
loading: false,
|
||||
indices: [],
|
||||
});
|
||||
|
||||
useFetchSloMock.mockReturnValue({ loading: false, slo: { ...anSLO, name: '' } });
|
||||
|
||||
render(<SloEditPage />, config);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('sloFormSubmitButton')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('if submitting has completed successfully', () => {
|
||||
it('renders a success toast', async () => {
|
||||
// Note: the `anSLO` object is considered to have (at least)
|
||||
// values for all required fields.
|
||||
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
|
||||
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
|
||||
useFetchIndicesMock.mockReturnValue({
|
||||
loading: false,
|
||||
indices: [{ name: 'some-index' }],
|
||||
});
|
||||
useCreateSloMock.mockReturnValue({
|
||||
loading: false,
|
||||
success: true,
|
||||
error: '',
|
||||
createSlo: jest.fn(),
|
||||
});
|
||||
render(<SloEditPage />, config);
|
||||
expect(mockAddSuccess).toBeCalled();
|
||||
});
|
||||
|
||||
it('navigates to the SLO List page', async () => {
|
||||
// Note: the `anSLO` object is considered to have (at least)
|
||||
// values for all required fields.
|
||||
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
|
||||
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
|
||||
useFetchIndicesMock.mockReturnValue({
|
||||
loading: false,
|
||||
indices: [{ name: 'some-index' }],
|
||||
});
|
||||
useCreateSloMock.mockReturnValue({
|
||||
loading: false,
|
||||
success: true,
|
||||
error: '',
|
||||
createSlo: jest.fn(),
|
||||
});
|
||||
render(<SloEditPage />, config);
|
||||
expect(mockNavigate).toBeCalledWith(paths.observability.slos);
|
||||
});
|
||||
});
|
||||
|
||||
describe('if submitting has not completed successfully', () => {
|
||||
it('renders an error toast', async () => {
|
||||
// Note: the `anSLO` object is considered to have (at least)
|
||||
// values for all required fields.
|
||||
jest.spyOn(Router, 'useParams').mockReturnValue({ sloId: '123' });
|
||||
useFetchSloMock.mockReturnValue({ loading: false, slo: anSLO });
|
||||
useFetchIndicesMock.mockReturnValue({
|
||||
loading: false,
|
||||
indices: [{ name: 'some-index' }],
|
||||
});
|
||||
useCreateSloMock.mockReturnValue({
|
||||
loading: false,
|
||||
success: false,
|
||||
error: 'Argh, API died',
|
||||
createSlo: jest.fn(),
|
||||
});
|
||||
render(<SloEditPage />, config);
|
||||
expect(mockAddError).toBeCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
65
x-pack/plugins/observability/public/pages/slo_edit/index.tsx
Normal file
65
x-pack/plugins/observability/public/pages/slo_edit/index.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { ObservabilityAppServices } from '../../application/types';
|
||||
import { paths } from '../../config';
|
||||
import { usePluginContext } from '../../hooks/use_plugin_context';
|
||||
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
|
||||
import { SloEditForm } from './components/slo_edit_form';
|
||||
import PageNotFound from '../404';
|
||||
import { isSloFeatureEnabled } from '../slos/helpers/is_slo_feature_enabled';
|
||||
|
||||
export function SloEditPage() {
|
||||
const { http } = useKibana<ObservabilityAppServices>().services;
|
||||
const { ObservabilityPageTemplate, config } = usePluginContext();
|
||||
|
||||
const { sloId } = useParams<{ sloId: string | undefined }>();
|
||||
|
||||
useBreadcrumbs([
|
||||
{
|
||||
href: http.basePath.prepend(paths.observability.slos),
|
||||
text: i18n.translate('xpack.observability.breadcrumbs.sloEditLinkText', {
|
||||
defaultMessage: 'SLOs',
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const { slo, loading } = useFetchSloDetails(sloId);
|
||||
|
||||
if (!isSloFeatureEnabled(config)) {
|
||||
return <PageNotFound />;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ObservabilityPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: slo
|
||||
? i18n.translate('xpack.observability.sloEditPageTitle', {
|
||||
defaultMessage: 'Edit SLO',
|
||||
})
|
||||
: i18n.translate('xpack.observability.sloCreatePageTitle', {
|
||||
defaultMessage: 'Create new SLO',
|
||||
}),
|
||||
rightSideItems: [],
|
||||
bottomBorder: false,
|
||||
}}
|
||||
data-test-subj="slosEditPage"
|
||||
>
|
||||
<SloEditForm slo={slo} />
|
||||
</ObservabilityPageTemplate>
|
||||
);
|
||||
}
|
|
@ -45,6 +45,10 @@ export function SloListItem({ slo, onDeleted, onDeleting }: SloListItemProps) {
|
|||
setIsActionsPopoverOpen(!isActionsPopoverOpen);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
navigateToUrl(basePath.prepend(paths.observability.sloEdit(slo.id)));
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteConfirmationModalOpen(true);
|
||||
setIsDeleting(true);
|
||||
|
@ -112,7 +116,12 @@ export function SloListItem({ slo, onDeleted, onDeleting }: SloListItemProps) {
|
|||
<EuiContextMenuPanel
|
||||
size="s"
|
||||
items={[
|
||||
<EuiContextMenuItem key="edit" icon="trash" onClick={handleDelete}>
|
||||
<EuiContextMenuItem key="edit" icon="pencil" onClick={handleEdit}>
|
||||
{i18n.translate('xpack.observability.slos.slo.item.actions.edit', {
|
||||
defaultMessage: 'Edit',
|
||||
})}
|
||||
</EuiContextMenuItem>,
|
||||
<EuiContextMenuItem key="delete" icon="trash" onClick={handleDelete}>
|
||||
{i18n.translate('xpack.observability.slos.slo.item.actions.delete', {
|
||||
defaultMessage: 'Delete',
|
||||
})}
|
||||
|
|
|
@ -8,11 +8,27 @@
|
|||
import React from 'react';
|
||||
import { EuiPageTemplate, EuiButton, EuiTitle, EuiLink, EuiImage } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { paths } from '../../../config';
|
||||
import { useKibana } from '../../../utils/kibana_react';
|
||||
import illustration from './assets/illustration.svg';
|
||||
|
||||
export function SloListWelcomePrompt() {
|
||||
const {
|
||||
application: { navigateToUrl },
|
||||
http: { basePath },
|
||||
} = useKibana().services;
|
||||
|
||||
const handleClickCreateSlo = () => {
|
||||
navigateToUrl(basePath.prepend(paths.observability.sloCreate));
|
||||
};
|
||||
|
||||
return (
|
||||
<EuiPageTemplate minHeight="0" data-test-subj="slosPageWelcomePrompt">
|
||||
<EuiPageTemplate
|
||||
minHeight="0"
|
||||
data-test-subj="slosPageWelcomePrompt"
|
||||
style={{ paddingBlockStart: 0 }}
|
||||
>
|
||||
<EuiPageTemplate.EmptyPrompt
|
||||
title={
|
||||
<EuiTitle size="l">
|
||||
|
@ -51,7 +67,7 @@ export function SloListWelcomePrompt() {
|
|||
</>
|
||||
}
|
||||
actions={
|
||||
<EuiButton color="primary" fill>
|
||||
<EuiButton color="primary" fill onClick={handleClickCreateSlo}>
|
||||
{i18n.translate('xpack.observability.slos.sloList.welcomePrompt.buttonLabel', {
|
||||
defaultMessage: 'Create first SLO',
|
||||
})}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { isSloFeatureEnabled } from './is_slo_feature_enabled';
|
|
@ -10,7 +10,6 @@ import { screen } from '@testing-library/react';
|
|||
|
||||
import { ConfigSchema } from '../../plugin';
|
||||
import { Subset } from '../../typings';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
import { kibanaStartMock } from '../../utils/kibana_react.mock';
|
||||
import { render } from '../../utils/test_helper';
|
||||
import { SlosPage } from '.';
|
||||
|
@ -22,25 +21,15 @@ jest.mock('react-router-dom', () => ({
|
|||
useParams: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../hooks/slo/use_fetch_slo_list');
|
||||
jest.mock('../../utils/kibana_react');
|
||||
jest.mock('../../hooks/use_breadcrumbs');
|
||||
|
||||
jest.mock('./components/slo_list_item', () => ({ SloListItem: () => 'mocked SloListItem' }));
|
||||
const mockUseKibanaReturnValue = kibanaStartMock.startContract();
|
||||
|
||||
jest.mock('../../utils/kibana_react', () => ({
|
||||
useKibana: jest.fn(() => mockUseKibanaReturnValue),
|
||||
}));
|
||||
|
||||
const useFetchSloListMock = useFetchSloList as jest.Mock;
|
||||
const useKibanaMock = useKibana as jest.Mock;
|
||||
const mockKibana = () => {
|
||||
useKibanaMock.mockReturnValue({
|
||||
services: {
|
||||
...kibanaStartMock.startContract(),
|
||||
http: {
|
||||
basePath: {
|
||||
prepend: jest.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const config: Subset<ConfigSchema> = {
|
||||
unsafe: {
|
||||
|
@ -51,7 +40,6 @@ const config: Subset<ConfigSchema> = {
|
|||
describe('SLOs Page', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockKibana();
|
||||
});
|
||||
|
||||
it('renders the not found page when the feature flag is not enabled', async () => {
|
||||
|
|
|
@ -6,13 +6,15 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { ObservabilityAppServices } from '../../application/types';
|
||||
import { paths } from '../../config';
|
||||
import { usePluginContext } from '../../hooks/use_plugin_context';
|
||||
import { useBreadcrumbs } from '../../hooks/use_breadcrumbs';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
import { isSloFeatureEnabled } from './helpers';
|
||||
import { isSloFeatureEnabled } from './helpers/is_slo_feature_enabled';
|
||||
import { SLOS_BREADCRUMB_TEXT, SLOS_PAGE_TITLE } from './translations';
|
||||
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
|
||||
import { SloList } from './components/slo_list';
|
||||
|
@ -20,7 +22,10 @@ import { SloListWelcomePrompt } from './components/slo_list_welcome_prompt';
|
|||
import PageNotFound from '../404';
|
||||
|
||||
export function SlosPage() {
|
||||
const { http } = useKibana<ObservabilityAppServices>().services;
|
||||
const {
|
||||
application: { navigateToUrl },
|
||||
http: { basePath },
|
||||
} = useKibana<ObservabilityAppServices>().services;
|
||||
const { ObservabilityPageTemplate, config } = usePluginContext();
|
||||
|
||||
const {
|
||||
|
@ -30,11 +35,15 @@ export function SlosPage() {
|
|||
|
||||
useBreadcrumbs([
|
||||
{
|
||||
href: http.basePath.prepend(paths.observability.slos),
|
||||
href: basePath.prepend(paths.observability.slos),
|
||||
text: SLOS_BREADCRUMB_TEXT,
|
||||
},
|
||||
]);
|
||||
|
||||
const handleClickCreateSlo = () => {
|
||||
navigateToUrl(basePath.prepend(paths.observability.sloCreate));
|
||||
};
|
||||
|
||||
if (!isSloFeatureEnabled(config)) {
|
||||
return <PageNotFound />;
|
||||
}
|
||||
|
@ -51,7 +60,13 @@ export function SlosPage() {
|
|||
<ObservabilityPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: SLOS_PAGE_TITLE,
|
||||
rightSideItems: [],
|
||||
rightSideItems: [
|
||||
<EuiButton color="primary" fill onClick={handleClickCreateSlo}>
|
||||
{i18n.translate('xpack.observability.slos.sloList.pageHeader.createNewButtonLabel', {
|
||||
defaultMessage: 'Create new SLO',
|
||||
})}
|
||||
</EuiButton>,
|
||||
],
|
||||
bottomBorder: false,
|
||||
}}
|
||||
data-test-subj="slosPage"
|
||||
|
|
|
@ -22,6 +22,7 @@ import { AlertDetails } from '../pages/alert_details';
|
|||
import { DatePickerContextProvider } from '../context/date_picker_context';
|
||||
import { SlosPage } from '../pages/slos';
|
||||
import { SloDetailsPage } from '../pages/slo_details';
|
||||
import { SloEditPage } from '../pages/slo_edit';
|
||||
|
||||
export type RouteParams<T extends keyof typeof routes> = DecodeParams<typeof routes[T]['params']>;
|
||||
|
||||
|
@ -138,6 +139,20 @@ export const routes = {
|
|||
params: {},
|
||||
exact: true,
|
||||
},
|
||||
'/slos/create': {
|
||||
handler: () => {
|
||||
return <SloEditPage />;
|
||||
},
|
||||
params: {},
|
||||
exact: true,
|
||||
},
|
||||
'/slos/edit/:sloId': {
|
||||
handler: () => {
|
||||
return <SloEditPage />;
|
||||
},
|
||||
params: {},
|
||||
exact: true,
|
||||
},
|
||||
'/slos/:sloId': {
|
||||
handler: () => {
|
||||
return <SloDetailsPage />;
|
||||
|
|
|
@ -33,8 +33,8 @@ import { IndicatorTypes } from '../../domain/models';
|
|||
import { createObservabilityServerRoute } from '../create_observability_server_route';
|
||||
|
||||
const transformGenerators: Record<IndicatorTypes, TransformGenerator> = {
|
||||
'sli.apm.transaction_duration': new ApmTransactionDurationTransformGenerator(),
|
||||
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
'sli.apm.transactionDuration': new ApmTransactionDurationTransformGenerator(),
|
||||
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
'sli.kql.custom': new KQLCustomTransformGenerator(),
|
||||
};
|
||||
|
||||
|
|
|
@ -56,7 +56,7 @@ describe('FindSLO', () => {
|
|||
transactionType: 'irrelevant',
|
||||
'threshold.us': 500000,
|
||||
},
|
||||
type: 'sli.apm.transaction_duration',
|
||||
type: 'sli.apm.transactionDuration',
|
||||
},
|
||||
objective: {
|
||||
target: 0.999,
|
||||
|
@ -116,12 +116,12 @@ describe('FindSLO', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('calls the repository with the indicator_type filter criteria', async () => {
|
||||
it('calls the repository with the indicatorType filter criteria', async () => {
|
||||
const slo = createSLO();
|
||||
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
|
||||
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
|
||||
|
||||
await findSLO.execute({ indicator_types: ['sli.kql.custom'] });
|
||||
await findSLO.execute({ indicatorTypes: ['sli.kql.custom'] });
|
||||
|
||||
expect(mockRepository.find).toHaveBeenCalledWith(
|
||||
{ indicatorTypes: ['sli.kql.custom'] },
|
||||
|
@ -135,7 +135,7 @@ describe('FindSLO', () => {
|
|||
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
|
||||
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
|
||||
|
||||
await findSLO.execute({ name: 'My SLO*', page: '2', per_page: '100' });
|
||||
await findSLO.execute({ name: 'My SLO*', page: '2', perPage: '100' });
|
||||
|
||||
expect(mockRepository.find).toHaveBeenCalledWith(
|
||||
{ name: 'My SLO*' },
|
||||
|
@ -149,7 +149,7 @@ describe('FindSLO', () => {
|
|||
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
|
||||
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
|
||||
|
||||
await findSLO.execute({ page: '-1', per_page: '0' });
|
||||
await findSLO.execute({ page: '-1', perPage: '0' });
|
||||
|
||||
expect(mockRepository.find).toHaveBeenCalledWith(
|
||||
{ name: undefined },
|
||||
|
@ -163,7 +163,7 @@ describe('FindSLO', () => {
|
|||
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
|
||||
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
|
||||
|
||||
await findSLO.execute({ sort_by: undefined });
|
||||
await findSLO.execute({ sortBy: undefined });
|
||||
|
||||
expect(mockRepository.find).toHaveBeenCalledWith(
|
||||
{ name: undefined },
|
||||
|
@ -177,7 +177,7 @@ describe('FindSLO', () => {
|
|||
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
|
||||
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
|
||||
|
||||
await findSLO.execute({ sort_by: 'indicator_type' });
|
||||
await findSLO.execute({ sortBy: 'indicatorType' });
|
||||
|
||||
expect(mockRepository.find).toHaveBeenCalledWith(
|
||||
{ name: undefined },
|
||||
|
@ -191,7 +191,7 @@ describe('FindSLO', () => {
|
|||
mockRepository.find.mockResolvedValueOnce(createPaginatedSLO(slo));
|
||||
mockSLIClient.fetchCurrentSLIData.mockResolvedValueOnce(someIndicatorData(slo));
|
||||
|
||||
await findSLO.execute({ sort_by: 'indicator_type', sort_direction: 'desc' });
|
||||
await findSLO.execute({ sortBy: 'indicatorType', sortDirection: 'desc' });
|
||||
|
||||
expect(mockRepository.find).toHaveBeenCalledWith(
|
||||
{ name: undefined },
|
||||
|
|
|
@ -73,7 +73,7 @@ function computeSloWithSummary(
|
|||
|
||||
function toPagination(params: FindSLOParams): Pagination {
|
||||
const page = Number(params.page);
|
||||
const perPage = Number(params.per_page);
|
||||
const perPage = Number(params.perPage);
|
||||
|
||||
return {
|
||||
page: !isNaN(page) && page >= 1 ? page : DEFAULT_PAGE,
|
||||
|
@ -82,12 +82,12 @@ function toPagination(params: FindSLOParams): Pagination {
|
|||
}
|
||||
|
||||
function toCriteria(params: FindSLOParams): Criteria {
|
||||
return { name: params.name, indicatorTypes: params.indicator_types };
|
||||
return { name: params.name, indicatorTypes: params.indicatorTypes };
|
||||
}
|
||||
|
||||
function toSort(params: FindSLOParams): Sort {
|
||||
return {
|
||||
field: params.sort_by === 'indicator_type' ? SortField.IndicatorType : SortField.Name,
|
||||
direction: params.sort_direction === 'desc' ? SortDirection.Desc : SortDirection.Asc,
|
||||
field: params.sortBy === 'indicatorType' ? SortField.IndicatorType : SortField.Name,
|
||||
direction: params.sortDirection === 'desc' ? SortDirection.Desc : SortDirection.Asc,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -28,7 +28,7 @@ import { sevenDaysRolling } from './time_window';
|
|||
export const createAPMTransactionErrorRateIndicator = (
|
||||
params: Partial<APMTransactionErrorRateIndicator['params']> = {}
|
||||
): Indicator => ({
|
||||
type: 'sli.apm.transaction_error_rate',
|
||||
type: 'sli.apm.transactionErrorRate',
|
||||
params: {
|
||||
environment: 'irrelevant',
|
||||
service: 'irrelevant',
|
||||
|
@ -42,7 +42,7 @@ export const createAPMTransactionErrorRateIndicator = (
|
|||
export const createAPMTransactionDurationIndicator = (
|
||||
params: Partial<APMTransactionDurationIndicator['params']> = {}
|
||||
): Indicator => ({
|
||||
type: 'sli.apm.transaction_duration',
|
||||
type: 'sli.apm.transactionDuration',
|
||||
params: {
|
||||
environment: 'irrelevant',
|
||||
service: 'irrelevant',
|
||||
|
|
|
@ -51,7 +51,7 @@ describe('GetSLO', () => {
|
|||
transactionType: 'irrelevant',
|
||||
goodStatusCodes: ['2xx', '3xx', '4xx'],
|
||||
},
|
||||
type: 'sli.apm.transaction_error_rate',
|
||||
type: 'sli.apm.transactionErrorRate',
|
||||
},
|
||||
objective: {
|
||||
target: 0.999,
|
||||
|
|
|
@ -197,7 +197,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
|
|||
soClientMock.find.mockResolvedValueOnce(aFindResponse(SOME_SLO));
|
||||
|
||||
const result = await repository.find(
|
||||
{ indicatorTypes: ['sli.kql.custom', 'sli.apm.transaction_duration'] },
|
||||
{ indicatorTypes: ['sli.kql.custom', 'sli.apm.transactionDuration'] },
|
||||
DEFAULT_SORTING,
|
||||
DEFAULT_PAGINATION
|
||||
);
|
||||
|
@ -212,7 +212,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
|
|||
type: SO_SLO_TYPE,
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
filter: `(slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transaction_duration)`,
|
||||
filter: `(slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transactionDuration)`,
|
||||
sortField: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
|
@ -224,7 +224,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
|
|||
soClientMock.find.mockResolvedValueOnce(aFindResponse(SOME_SLO));
|
||||
|
||||
const result = await repository.find(
|
||||
{ name: 'latency', indicatorTypes: ['sli.kql.custom', 'sli.apm.transaction_duration'] },
|
||||
{ name: 'latency', indicatorTypes: ['sli.kql.custom', 'sli.apm.transactionDuration'] },
|
||||
DEFAULT_SORTING,
|
||||
DEFAULT_PAGINATION
|
||||
);
|
||||
|
@ -239,7 +239,7 @@ describe('KibanaSavedObjectsSLORepository', () => {
|
|||
type: SO_SLO_TYPE,
|
||||
page: 1,
|
||||
perPage: 25,
|
||||
filter: `(slo.attributes.name: *latency*) and (slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transaction_duration)`,
|
||||
filter: `(slo.attributes.name: *latency*) and (slo.attributes.indicator.type: sli.kql.custom or slo.attributes.indicator.type: sli.apm.transactionDuration)`,
|
||||
sortField: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
|
|
|
@ -41,19 +41,19 @@ describe('TransformManager', () => {
|
|||
it('throws when no generator exists for the slo indicator type', async () => {
|
||||
// @ts-ignore defining only a subset of the possible SLI
|
||||
const generators: Record<IndicatorTypes, TransformGenerator> = {
|
||||
'sli.apm.transaction_duration': new DummyTransformGenerator(),
|
||||
'sli.apm.transactionDuration': new DummyTransformGenerator(),
|
||||
};
|
||||
const service = new DefaultTransformManager(generators, esClientMock, loggerMock);
|
||||
|
||||
await expect(
|
||||
service.install(createSLO({ indicator: createAPMTransactionErrorRateIndicator() }))
|
||||
).rejects.toThrowError('Unsupported SLI type: sli.apm.transaction_error_rate');
|
||||
).rejects.toThrowError('Unsupported SLI type: sli.apm.transactionErrorRate');
|
||||
});
|
||||
|
||||
it('throws when transform generator fails', async () => {
|
||||
// @ts-ignore defining only a subset of the possible SLI
|
||||
const generators: Record<IndicatorTypes, TransformGenerator> = {
|
||||
'sli.apm.transaction_duration': new FailTransformGenerator(),
|
||||
'sli.apm.transactionDuration': new FailTransformGenerator(),
|
||||
};
|
||||
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
|
||||
|
||||
|
@ -68,7 +68,7 @@ describe('TransformManager', () => {
|
|||
it('installs the transform', async () => {
|
||||
// @ts-ignore defining only a subset of the possible SLI
|
||||
const generators: Record<IndicatorTypes, TransformGenerator> = {
|
||||
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
};
|
||||
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
|
||||
const slo = createSLO({ indicator: createAPMTransactionErrorRateIndicator() });
|
||||
|
@ -84,7 +84,7 @@ describe('TransformManager', () => {
|
|||
it('starts the transform', async () => {
|
||||
// @ts-ignore defining only a subset of the possible SLI
|
||||
const generators: Record<IndicatorTypes, TransformGenerator> = {
|
||||
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
};
|
||||
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
|
||||
|
||||
|
@ -98,7 +98,7 @@ describe('TransformManager', () => {
|
|||
it('stops the transform', async () => {
|
||||
// @ts-ignore defining only a subset of the possible SLI
|
||||
const generators: Record<IndicatorTypes, TransformGenerator> = {
|
||||
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
};
|
||||
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
|
||||
|
||||
|
@ -112,7 +112,7 @@ describe('TransformManager', () => {
|
|||
it('uninstalls the transform', async () => {
|
||||
// @ts-ignore defining only a subset of the possible SLI
|
||||
const generators: Record<IndicatorTypes, TransformGenerator> = {
|
||||
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
};
|
||||
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
|
||||
|
||||
|
@ -127,7 +127,7 @@ describe('TransformManager', () => {
|
|||
);
|
||||
// @ts-ignore defining only a subset of the possible SLI
|
||||
const generators: Record<IndicatorTypes, TransformGenerator> = {
|
||||
'sli.apm.transaction_error_rate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
'sli.apm.transactionErrorRate': new ApmTransactionErrorRateTransformGenerator(),
|
||||
};
|
||||
const transformManager = new DefaultTransformManager(generators, esClientMock, loggerMock);
|
||||
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
"@kbn/share-plugin",
|
||||
"@kbn/core-notifications-browser",
|
||||
"@kbn/slo-schema",
|
||||
"@kbn/core-http-server-internal",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue