[SLOs] Unified Search (#174054)

## Summary

Fixes https://github.com/elastic/kibana/issues/173601

Implement Unified Search in SLO List view 

1. SLO title has been removed to make it more consistent with other
kibana apps
2. Feedback button is moved up 
3. Auto refresh is removed

<img width="1720" alt="image"
src="0ff6fd83-98ac-4737-bf4f-aa087739f110">


<img width="1728" alt="image"
src="aa71e2e7-3bc8-4be7-afb3-f4b7efffc953">

Filters

<img width="1727" alt="image"
src="d3bbf3d3-d2b3-4574-ae7f-bb8258016930">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Shahzad 2024-01-23 10:16:22 +01:00 committed by GitHub
parent 615c16ec8a
commit f1057ca3e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 479 additions and 379 deletions

View file

@ -14,7 +14,7 @@ import { I18nProvider } from '@kbn/i18n-react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public';
import { buildExistsFilter } from '@kbn/es-query';
import { EuiComboBox } from '@elastic/eui';
import { EuiButton, EuiComboBox } from '@elastic/eui';
import { SearchBar, SearchBarProps } from '../search_bar';
import { setIndexPatterns } from '../services';
@ -695,4 +695,20 @@ storiesOf('SearchBar', module)
},
submitButtonStyle: 'full',
})
)
.add('with renderQueryInputAppend prop', () =>
wrapSearchBarInContext({
dataViewPickerComponentProps: {
currentDataViewId: '1234',
trigger: {
'data-test-subj': 'dataView-switch-link',
label: 'logstash-*',
title: 'logstash-*',
},
onChangeDataView: action('onChangeDataView'),
},
submitButtonStyle: 'full',
renderQueryInputAppend: () => <EuiButton onClick={() => {}}>Append</EuiButton>,
})
);

View file

@ -70,6 +70,7 @@ export interface QueryBarMenuProps extends WithCloseFilterEditorConfirmModalProp
buttonProps?: Partial<EuiButtonIconProps>;
isDisabled?: boolean;
suggestionsAbstraction?: SuggestionsAbstraction;
renderQueryInputAppend?: () => React.ReactNode;
}
function QueryBarMenuComponent({

View file

@ -163,6 +163,7 @@ export interface QueryBarTopRowProps<QT extends Query | AggregateQuery = Query>
onTextLangQuerySubmit: (query?: Query | AggregateQuery) => void;
onTextLangQueryChange: (query: AggregateQuery) => void;
submitOnBlur?: boolean;
renderQueryInputAppend?: () => React.ReactNode;
}
export const SharingMetaFields = React.memo(function SharingMetaFields({
@ -702,6 +703,7 @@ export const QueryBarTopRow = React.memo(
? renderTextLangEditor()
: null}
</EuiFlexItem>
{props.renderQueryInputAppend?.()}
{shouldShowDatePickerAsBadge() && props.filterBar}
{renderUpdateButton()}
</EuiFlexGroup>

View file

@ -120,6 +120,8 @@ export interface SearchBarOwnProps<QT extends AggregateQuery | Query = Query> {
isDisabled?: boolean;
submitOnBlur?: boolean;
renderQueryInputAppend?: () => React.ReactNode;
}
export type SearchBarProps<QT extends Query | AggregateQuery = Query> = SearchBarOwnProps<QT> &
@ -524,6 +526,7 @@ class SearchBarUI<QT extends (Query | AggregateQuery) | Query = Query> extends C
: undefined
}
suggestionsAbstraction={this.props.suggestionsAbstraction}
renderQueryInputAppend={this.props.renderQueryInputAppend}
/>
) : undefined;
@ -610,6 +613,7 @@ class SearchBarUI<QT extends (Query | AggregateQuery) | Query = Query> extends C
onTextLangQueryChange={this.onTextLangQueryChange}
submitOnBlur={this.props.submitOnBlur}
suggestionsAbstraction={this.props.suggestionsAbstraction}
renderQueryInputAppend={this.props.renderQueryInputAppend}
/>
</div>
);

View file

@ -87,6 +87,7 @@ const sortBySchema = t.union([
const findSLOParamsSchema = t.partial({
query: t.partial({
filters: t.string,
kqlQuery: t.string,
page: t.string,
perPage: t.string,

View file

@ -1,33 +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.
*/
import React from 'react';
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
const SLO_FEEDBACK_LINK = 'https://ela.st/slo-feedback';
interface Props {
disabled?: boolean;
}
export function FeedbackButton({ disabled }: Props) {
return (
<EuiButton
data-test-subj="sloFeedbackButton"
isDisabled={disabled}
href={SLO_FEEDBACK_LINK}
target="_blank"
color="warning"
iconType="editorComment"
>
{i18n.translate('xpack.observability.slo.feedbackButtonLabel', {
defaultMessage: 'Tell us what you think!',
})}
</EuiButton>
);
}

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton, EuiCallOut } from '@elastic/eui';
import { EuiButton, EuiCallOut, EuiSpacer } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
@ -25,37 +25,40 @@ export function SloOutdatedCallout() {
const { isLoading, data } = useFetchSloDefinitions({ includeOutdatedOnly: true });
if (!isLoading && data && data.total > 0) {
return (
<EuiCallOut
color="warning"
iconType="warning"
title={i18n.translate('xpack.observability.slo.outdatedSloCallout.title', {
defaultMessage: '{total} Outdated SLOs Detected',
values: {
total: data.total,
},
})}
>
<p>
<FormattedMessage
id="xpack.observability.slo.outdatedSloCallout.message"
defaultMessage="We've noticed that you have {total} outdated SLO definitions, these SLOs will not be running or alerting until you've reset them. Please click the button below to review the SLO definitions; you can choose to either reset the SLO definition or remove it."
values={{ total: data.total }}
/>
</p>
<p>
<EuiButton
color="warning"
data-test-subj="o11ySloOutdatedCalloutViewOutdatedSloDefinitionsButton"
fill
onClick={handleClick}
>
<>
<EuiCallOut
color="warning"
iconType="warning"
title={i18n.translate('xpack.observability.slo.outdatedSloCallout.title', {
defaultMessage: '{total} Outdated SLOs Detected',
values: {
total: data.total,
},
})}
>
<p>
<FormattedMessage
id="xpack.observability.outdatedSloCallout.buttonLabel"
defaultMessage="Review Outdated SLO Definitions"
id="xpack.observability.slo.outdatedSloCallout.message"
defaultMessage="We've noticed that you have {total} outdated SLO definitions, these SLOs will not be running or alerting until you've reset them. Please click the button below to review the SLO definitions; you can choose to either reset the SLO definition or remove it."
values={{ total: data.total }}
/>
</EuiButton>
</p>
</EuiCallOut>
</p>
<p>
<EuiButton
color="warning"
data-test-subj="o11ySloOutdatedCalloutViewOutdatedSloDefinitionsButton"
fill
onClick={handleClick}
>
<FormattedMessage
id="xpack.observability.outdatedSloCallout.buttonLabel"
defaultMessage="Review Outdated SLO Definitions"
/>
</EuiButton>
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
}
return null;

View file

@ -13,6 +13,8 @@ interface SloListFilter {
perPage: number;
sortBy: string;
sortDirection: string;
filters: string;
lastRefresh?: number;
}
export const sloKeys = {

View file

@ -8,9 +8,13 @@
import { i18n } from '@kbn/i18n';
import { FindSLOResponse } from '@kbn/slo-schema';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { DEFAULT_SLO_PAGE_SIZE } from '../../../common/slo/constants';
import { SLO_LONG_REFETCH_INTERVAL, SLO_SHORT_REFETCH_INTERVAL } from '../../constants';
import { useMemo } from 'react';
import { buildQueryFromFilters, Filter } from '@kbn/es-query';
import { useCreateDataView } from '../use_create_data_view';
import {
DEFAULT_SLO_PAGE_SIZE,
SLO_SUMMARY_DESTINATION_INDEX_NAME,
} from '../../../common/slo/constants';
import { useKibana } from '../../utils/kibana_react';
import { sloKeys } from './query_key_factory';
@ -20,8 +24,9 @@ interface SLOListParams {
page?: number;
sortBy?: string;
sortDirection?: 'asc' | 'desc';
shouldRefetch?: boolean;
perPage?: number;
filters?: Filter[];
lastRefresh?: number;
}
export interface UseFetchSloListResponse {
@ -38,37 +43,57 @@ export function useFetchSloList({
page = 1,
sortBy = 'status',
sortDirection = 'desc',
shouldRefetch,
perPage = DEFAULT_SLO_PAGE_SIZE,
filters: filterDSL = [],
lastRefresh,
}: SLOListParams = {}): UseFetchSloListResponse {
const {
http,
notifications: { toasts },
} = useKibana().services;
const queryClient = useQueryClient();
const [stateRefetchInterval, setStateRefetchInterval] = useState<number>(
SLO_SHORT_REFETCH_INTERVAL
);
const { dataView } = useCreateDataView({
indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_NAME,
});
const filters = useMemo(() => {
try {
return JSON.stringify(
buildQueryFromFilters(filterDSL, dataView, {
ignoreFilterIfFieldNotInIndex: true,
})
);
} catch (e) {
return '';
}
}, [filterDSL, dataView]);
const { isInitialLoading, isLoading, isError, isSuccess, isRefetching, data } = useQuery({
queryKey: sloKeys.list({ kqlQuery, page, perPage, sortBy, sortDirection }),
queryKey: sloKeys.list({
kqlQuery,
page,
perPage,
sortBy,
sortDirection,
filters,
lastRefresh,
}),
queryFn: async ({ signal }) => {
const response = await http.get<FindSLOResponse>(`/api/observability/slos`, {
return await http.get<FindSLOResponse>(`/api/observability/slos`, {
query: {
...(kqlQuery && { kqlQuery }),
...(sortBy && { sortBy }),
...(sortDirection && { sortDirection }),
...(page && { page }),
...(perPage && { perPage }),
...(filters && { filters }),
},
signal,
});
return response;
},
cacheTime: 0,
refetchOnWindowFocus: false,
refetchInterval: shouldRefetch ? stateRefetchInterval : undefined,
retry: (failureCount, error) => {
if (String(error) === 'Error: Forbidden') {
return false;
@ -79,16 +104,6 @@ export function useFetchSloList({
queryClient.invalidateQueries({ queryKey: sloKeys.historicalSummaries(), exact: false });
queryClient.invalidateQueries({ queryKey: sloKeys.activeAlerts(), exact: false });
queryClient.invalidateQueries({ queryKey: sloKeys.rules(), exact: false });
if (!shouldRefetch) {
return;
}
if (results.find((slo) => slo.summary.status === 'NO_DATA' || !slo.summary)) {
setStateRefetchInterval(SLO_SHORT_REFETCH_INTERVAL);
} else {
setStateRefetchInterval(SLO_LONG_REFETCH_INTERVAL);
}
},
onError: (error: Error) => {
toasts.addError(error, {

View file

@ -14,7 +14,7 @@ describe('SloListLocator', () => {
const location = await locator.getLocation({});
expect(location.app).toEqual('observability');
expect(location.path).toMatchInlineSnapshot(
`"/slos?search=(compact:!t,kqlQuery:'',page:0,perPage:25,sort:(by:status,direction:desc),view:cardView)"`
`"/slos?search=(compact:!t,filters:!(),kqlQuery:'',lastRefresh:0,page:0,perPage:25,sort:(by:status,direction:desc),view:cardView)"`
);
});
@ -24,7 +24,7 @@ describe('SloListLocator', () => {
});
expect(location.app).toEqual('observability');
expect(location.path).toMatchInlineSnapshot(
`"/slos?search=(compact:!t,kqlQuery:'slo.name:%20%22Service%20Availability%22%20and%20slo.indicator.type%20:%20%22sli.kql.custom%22',page:0,perPage:25,sort:(by:status,direction:desc),view:cardView)"`
`"/slos?search=(compact:!t,filters:!(),kqlQuery:'slo.name:%20%22Service%20Availability%22%20and%20slo.indicator.type%20:%20%22sli.kql.custom%22',lastRefresh:0,page:0,perPage:25,sort:(by:status,direction:desc),view:cardView)"`
);
});
});

View file

@ -12,6 +12,7 @@ import React from 'react';
import { useKibana } from '../../../../utils/kibana_react';
import { usePluginContext } from '../../../../hooks/use_plugin_context';
import HeaderMenuPortal from './header_menu_portal';
const SLO_FEEDBACK_LINK = 'https://ela.st/slo-feedback';
export function HeaderMenu(): React.ReactElement | null {
const {
@ -27,6 +28,16 @@ export function HeaderMenu(): React.ReactElement | null {
return (
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu} theme$={theme.theme$}>
<EuiHeaderLinks>
<EuiHeaderLink
data-test-subj="sloFeedbackButton"
color="warning"
href={SLO_FEEDBACK_LINK}
iconType="popout"
>
{i18n.translate('xpack.observability.slo.giveFeedback', {
defaultMessage: 'Give feedback',
})}
</EuiHeaderLink>
<EuiHeaderLink
color="primary"
href={http.basePath.prepend('/app/integrations/browse')}

View file

@ -26,7 +26,6 @@ import { HeaderControl } from './components/header_control';
import { paths } from '../../../common/locators/paths';
import type { SloDetailsPathParams } from './types';
import { AutoRefreshButton } from '../../components/slo/auto_refresh_button';
import { FeedbackButton } from '../../components/slo/feedback_button/feedback_button';
import { useGetInstanceIdQueryParam } from './hooks/use_get_instance_id_query_param';
import { useAutoRefreshStorage } from '../../components/slo/auto_refresh_button/hooks/use_auto_refresh_storage';
import { HeaderMenu } from '../overview/components/header_menu/header_menu';
@ -81,7 +80,6 @@ export function SloDetailsPage() {
isAutoRefreshing={isAutoRefreshing}
onClick={handleToggleAutoRefresh}
/>,
<FeedbackButton disabled={isPerformingAction} />,
],
bottomBorder: false,
}}

View file

@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n';
import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
import { createHtmlPortalNode, OutPortal } from 'react-reverse-portal';
import { FeedbackButton } from '../alert_details/components/feedback_button';
import { paths } from '../../../common/locators/paths';
import { useKibana } from '../../utils/kibana_react';
import { usePluginContext } from '../../hooks/use_plugin_context';
@ -18,7 +19,6 @@ import { useFetchSloDetails } from '../../hooks/slo/use_fetch_slo_details';
import { useLicense } from '../../hooks/use_license';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
import { useFetchSloGlobalDiagnosis } from '../../hooks/slo/use_fetch_global_diagnosis';
import { FeedbackButton } from '../../components/slo/feedback_button/feedback_button';
import { SloEditForm } from './components/slo_edit_form';
import { HeaderMenu } from '../overview/components/header_menu/header_menu';

View file

@ -0,0 +1,39 @@
/*
* 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 { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { useKibana } from '../../../../utils/kibana_react';
import { paths } from '../../../../../common/locators/paths';
import { useCapabilities } from '../../../../hooks/slo/use_capabilities';
export function CreateSloBtn() {
const {
application: { navigateToUrl },
http: { basePath },
} = useKibana().services;
const { hasWriteCapabilities } = useCapabilities();
const handleClickCreateSlo = () => {
navigateToUrl(basePath.prepend(paths.observability.sloCreate));
};
return (
<EuiButton
color="primary"
data-test-subj="slosPageCreateNewSloButton"
disabled={!hasWriteCapabilities}
fill
onClick={handleClickCreateSlo}
>
{i18n.translate('xpack.observability.slo.sloList.pageHeader.create', {
defaultMessage: 'Create SLO',
})}
</EuiButton>
);
}

View file

@ -0,0 +1,113 @@
/*
* 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 {
EuiFilterButton,
EuiFilterGroup,
EuiPopover,
EuiPopoverTitle,
EuiSelectable,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { SearchState } from '../../hooks/use_url_search_state';
import { Item, SortField } from '../slo_list_search_bar';
interface Props {
initialState: SearchState;
loading: boolean;
onStateChange: (newState: Partial<SearchState>) => void;
}
export function SortBySelect({ initialState, onStateChange, loading }: Props) {
const [isSortPopoverOpen, setSortPopoverOpen] = useState(false);
const [sortOptions, setSortOptions] = useState<Array<Item<SortField>>>(
SORT_OPTIONS.map((option) => ({
...option,
checked: option.type === initialState.sort.by ? 'on' : undefined,
}))
);
const selectedSort = sortOptions.find((option) => option.checked === 'on');
const handleToggleSortButton = () => setSortPopoverOpen(!isSortPopoverOpen);
const handleChangeSort = (newOptions: Array<Item<SortField>>) => {
setSortOptions(newOptions);
setSortPopoverOpen(false);
onStateChange({
page: 0,
sort: { by: newOptions.find((o) => o.checked)!.type, direction: initialState.sort.direction },
});
};
return (
<EuiFilterGroup>
<EuiPopover
button={
<EuiFilterButton
disabled={loading}
iconType="arrowDown"
onClick={handleToggleSortButton}
isSelected={isSortPopoverOpen}
>
{i18n.translate('xpack.observability.slo.list.sortByType', {
defaultMessage: 'Sort by {type}',
values: { type: selectedSort?.label.toLowerCase() ?? '' },
})}
</EuiFilterButton>
}
isOpen={isSortPopoverOpen}
closePopover={handleToggleSortButton}
panelPaddingSize="none"
anchorPosition="downCenter"
>
<div style={{ width: 250 }}>
<EuiPopoverTitle paddingSize="s">
{i18n.translate('xpack.observability.slo.list.sortBy', {
defaultMessage: 'Sort by',
})}
</EuiPopoverTitle>
<EuiSelectable<Item<SortField>>
singleSelection="always"
options={sortOptions}
onChange={handleChangeSort}
isLoading={loading}
>
{(list) => list}
</EuiSelectable>
</div>
</EuiPopover>
</EuiFilterGroup>
);
}
const SORT_OPTIONS: Array<Item<SortField>> = [
{
label: i18n.translate('xpack.observability.slo.list.sortBy.sliValue', {
defaultMessage: 'SLI value',
}),
type: 'sli_value',
},
{
label: i18n.translate('xpack.observability.slo.list.sortBy.sloStatus', {
defaultMessage: 'SLO status',
}),
type: 'status',
},
{
label: i18n.translate('xpack.observability.slo.list.sortBy.errorBudgetConsumed', {
defaultMessage: 'Error budget consumed',
}),
type: 'error_budget_consumed',
},
{
label: i18n.translate('xpack.observability.slo.list.sortBy.errorBudgetRemaining', {
defaultMessage: 'Error budget remaining',
}),
type: 'error_budget_remaining',
},
];

View file

@ -9,7 +9,7 @@ import React from 'react';
import { ComponentStory } from '@storybook/react';
import { KibanaReactStorybookDecorator } from '../../../utils/kibana_react.storybook_decorator';
import { SloList as Component, Props } from './slo_list';
import { SloList as Component } from './slo_list';
export default {
component: Component,
@ -18,11 +18,9 @@ export default {
decorators: [KibanaReactStorybookDecorator],
};
const Template: ComponentStory<typeof Component> = (props: Props) => <Component {...props} />;
const Template: ComponentStory<typeof Component> = () => <Component />;
const defaultProps = {
autoRefresh: true,
};
const defaultProps = {};
export const SloList = Template.bind({});
SloList.args = defaultProps;

View file

@ -7,26 +7,17 @@
import { EuiFlexGroup, EuiFlexItem, EuiTablePagination } from '@elastic/eui';
import { useIsMutating } from '@tanstack/react-query';
import React, { useState } from 'react';
import React from 'react';
import { CreateSloBtn } from './common/create_slo_btn';
import { useFetchSloList } from '../../../hooks/slo/use_fetch_slo_list';
import { useUrlSearchState } from '../hooks/use_url_search_state';
import { SearchState, useUrlSearchState } from '../hooks/use_url_search_state';
import { SlosView } from './slos_view';
import { SloListSearchBar, SortDirection, SortField } from './slo_list_search_bar';
import { SLOView, ToggleSLOView } from './toggle_slo_view';
import { SloListSearchBar } from './slo_list_search_bar';
import { ToggleSLOView } from './toggle_slo_view';
export interface Props {
autoRefresh: boolean;
}
export function SloList({ autoRefresh }: Props) {
export function SloList() {
const { state, store: storeState } = useUrlSearchState();
const [page, setPage] = useState(state.page);
const [perPage, setPerPage] = useState(state.perPage);
const [query, setQuery] = useState(state.kqlQuery);
const [sort, setSort] = useState<SortField>(state.sort.by);
const [direction] = useState<SortDirection>(state.sort.direction);
const [view, setView] = useState<SLOView>(state.view);
const [isCompact, setCompact] = useState<boolean>(state.compact);
const { view, page, perPage, kqlQuery, filters, compact: isCompact } = state;
const {
isLoading,
@ -35,11 +26,12 @@ export function SloList({ autoRefresh }: Props) {
data: sloList,
} = useFetchSloList({
perPage,
filters,
page: page + 1,
kqlQuery: query,
sortBy: sort,
sortDirection: direction,
shouldRefetch: autoRefresh,
kqlQuery,
sortBy: state.sort.by,
sortDirection: state.sort.direction,
lastRefresh: state.lastRefresh,
});
const { results = [], total = 0 } = sloList ?? {};
@ -49,49 +41,34 @@ export function SloList({ autoRefresh }: Props) {
const isUpdatingSlo = Boolean(useIsMutating(['updatingSlo']));
const isDeletingSlo = Boolean(useIsMutating(['deleteSlo']));
const handlePageClick = (pageNumber: number) => {
setPage(pageNumber);
storeState({ page: pageNumber });
};
const handleChangeQuery = (newQuery: string) => {
setPage(0);
setQuery(newQuery);
storeState({ page: 0, kqlQuery: newQuery });
};
const handleChangeSort = (newSort: SortField) => {
setPage(0);
setSort(newSort);
storeState({ page: 0, sort: { by: newSort, direction: state.sort.direction } });
};
const handleChangeView = (newView: SLOView) => {
setView(newView);
storeState({ view: newView });
};
const handleToggleCompactView = () => {
const newCompact = !isCompact;
setCompact(newCompact);
storeState({ compact: newCompact });
const onStateChange = (newState: Partial<SearchState>) => {
storeState({ page: 0, ...newState });
};
return (
<EuiFlexGroup direction="column" gutterSize="m" data-test-subj="sloList">
<EuiFlexItem grow>
<SloListSearchBar
loading={isLoading || isCreatingSlo || isCloningSlo || isUpdatingSlo || isDeletingSlo}
onChangeQuery={handleChangeQuery}
onChangeSort={handleChangeSort}
initialState={state}
/>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={true}>
<SloListSearchBar
query={kqlQuery}
filters={filters}
loading={isLoading || isCreatingSlo || isCloningSlo || isUpdatingSlo || isDeletingSlo}
onStateChange={onStateChange}
initialState={state}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<CreateSloBtn />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ToggleSLOView
sloList={sloList}
sloView={view}
onChangeView={handleChangeView}
onToggleCompactView={handleToggleCompactView}
onChangeView={(newView) => onStateChange({ view: newView })}
onToggleCompactView={() => onStateChange({ compact: !isCompact })}
isCompact={isCompact}
/>
</EuiFlexItem>
@ -108,12 +85,13 @@ export function SloList({ autoRefresh }: Props) {
<EuiTablePagination
pageCount={Math.ceil(total / perPage)}
activePage={page}
onChangePage={handlePageClick}
onChangePage={(newPage) => {
onStateChange({ page: newPage });
}}
itemsPerPage={perPage}
itemsPerPageOptions={[10, 25, 50, 100]}
onChangeItemsPerPage={(newPerPage) => {
setPerPage(newPerPage);
storeState({ perPage: newPerPage });
storeState({ perPage: newPerPage, page: 0 });
}}
/>
</EuiFlexItem>

View file

@ -22,8 +22,7 @@ const Template: ComponentStory<typeof Component> = (props: Props) => <Component
const defaultProps: Props = {
loading: false,
onChangeQuery: () => {},
onChangeSort: () => {},
onStateChange: () => {},
initialState: DEFAULT_STATE,
};

View file

@ -5,29 +5,25 @@
* 2.0.
*/
import {
EuiFilterButton,
EuiFilterGroup,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiPopoverTitle,
EuiSelectable,
EuiSelectableOption,
} from '@elastic/eui';
import { EuiSelectableOption } from '@elastic/eui';
import { EuiSelectableOptionCheckedType } from '@elastic/eui/src/components/selectable/selectable_option';
import { Query } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { useCreateDataView } from '../../../hooks/use_create_data_view';
import React from 'react';
import { Filter } from '@kbn/es-query';
import styled from 'styled-components';
import { useKibana } from '../../../utils/kibana_react';
import { ObservabilityPublicPluginsStart } from '../../..';
import { SortBySelect } from './common/sort_by_select';
import { SLO_SUMMARY_DESTINATION_INDEX_NAME } from '../../../../common/slo/constants';
import { useCreateDataView } from '../../../hooks/use_create_data_view';
import { SearchState } from '../hooks/use_url_search_state';
export interface Props {
query?: string;
filters?: Filter[];
loading: boolean;
initialState: SearchState;
onChangeQuery: (query: string) => void;
onChangeSort: (sort: SortField) => void;
onStateChange: (newState: Partial<SearchState>) => void;
}
export type SortField = 'sli_value' | 'error_budget_consumed' | 'error_budget_remaining' | 'status';
@ -39,127 +35,54 @@ export type Item<T> = EuiSelectableOption & {
checked?: EuiSelectableOptionCheckedType;
};
const SORT_OPTIONS: Array<Item<SortField>> = [
{
label: i18n.translate('xpack.observability.slo.list.sortBy.sliValue', {
defaultMessage: 'SLI value',
}),
type: 'sli_value',
},
{
label: i18n.translate('xpack.observability.slo.list.sortBy.sloStatus', {
defaultMessage: 'SLO status',
}),
type: 'status',
},
{
label: i18n.translate('xpack.observability.slo.list.sortBy.errorBudgetConsumed', {
defaultMessage: 'Error budget consumed',
}),
type: 'error_budget_consumed',
},
{
label: i18n.translate('xpack.observability.slo.list.sortBy.errorBudgetRemaining', {
defaultMessage: 'Error budget remaining',
}),
type: 'error_budget_remaining',
},
];
export type ViewMode = 'default' | 'compact';
export function SloListSearchBar({ loading, onChangeQuery, onChangeSort, initialState }: Props) {
export function SloListSearchBar({ query, filters, loading, initialState, onStateChange }: Props) {
const { dataView } = useCreateDataView({
indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_NAME,
});
const {
unifiedSearch: {
ui: { QueryStringInput },
ui: { SearchBar },
},
} = useKibana().services;
const { dataView } = useCreateDataView({ indexPatternString: '.slo-observability.summary-*' });
const [query, setQuery] = useState(initialState.kqlQuery);
const [isSortPopoverOpen, setSortPopoverOpen] = useState(false);
const [sortOptions, setSortOptions] = useState<Array<Item<SortField>>>(
SORT_OPTIONS.map((option) => ({
...option,
checked: option.type === initialState.sort.by ? 'on' : undefined,
}))
);
const selectedSort = sortOptions.find((option) => option.checked === 'on');
const handleToggleSortButton = () => setSortPopoverOpen(!isSortPopoverOpen);
const handleChangeSort = (newOptions: Array<Item<SortField>>) => {
setSortOptions(newOptions);
setSortPopoverOpen(false);
onChangeSort(newOptions.find((o) => o.checked)!.type);
};
} = useKibana<ObservabilityPublicPluginsStart>().services;
return (
<EuiFlexGroup direction="row" gutterSize="s" responsive>
<EuiFlexItem grow>
<QueryStringInput
appName="Observability"
bubbleSubmitEvent={false}
disableAutoFocus
onSubmit={(value: Query) => {
setQuery(String(value.query));
onChangeQuery(String(value.query));
}}
disableLanguageSwitcher
isDisabled={loading}
autoSubmit
indexPatterns={dataView ? [dataView] : []}
placeholder={i18n.translate('xpack.observability.slo.list.search', {
defaultMessage: 'Search your SLOs...',
})}
query={{ query: String(query), language: 'kuery' }}
size="s"
onChange={(value) => setQuery(String(value.query))}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="row" gutterSize="s" responsive>
<EuiFlexItem style={{ maxWidth: 250 }}>
<EuiFilterGroup>
<EuiPopover
button={
<EuiFilterButton
disabled={loading}
iconType="arrowDown"
onClick={handleToggleSortButton}
isSelected={isSortPopoverOpen}
>
{i18n.translate('xpack.observability.slo.list.sortByType', {
defaultMessage: 'Sort by {type}',
values: { type: selectedSort?.label.toLowerCase() ?? '' },
})}
</EuiFilterButton>
}
isOpen={isSortPopoverOpen}
closePopover={handleToggleSortButton}
panelPaddingSize="none"
anchorPosition="downCenter"
>
<div style={{ width: 250 }}>
<EuiPopoverTitle paddingSize="s">
{i18n.translate('xpack.observability.slo.list.sortBy', {
defaultMessage: 'Sort by',
})}
</EuiPopoverTitle>
<EuiSelectable<Item<SortField>>
singleSelection="always"
options={sortOptions}
onChange={handleChangeSort}
isLoading={loading}
>
{(list) => list}
</EuiSelectable>
</div>
</EuiPopover>
</EuiFilterGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<Container>
<SearchBar
appName="observability"
placeholder={i18n.translate('xpack.observability.slo.list.search', {
defaultMessage: 'Search your SLOs...',
})}
indexPatterns={dataView ? [dataView] : []}
isDisabled={loading}
renderQueryInputAppend={() => (
<SortBySelect
initialState={initialState}
loading={loading}
onStateChange={onStateChange}
/>
)}
filters={filters}
onFiltersUpdated={(newFilters) => {
onStateChange({ filters: newFilters });
}}
onQuerySubmit={({ query: value }) => {
onStateChange({ kqlQuery: String(value?.query), lastRefresh: Date.now() });
}}
query={{ query: String(query), language: 'kuery' }}
showSubmitButton={true}
showDatePicker={false}
showQueryInput={true}
disableQueryLanguageSwitcher={true}
/>
</Container>
);
}
const Container = styled.div`
.uniSearchBar {
padding: 0;
}
`;

View file

@ -7,7 +7,9 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { FindSLOResponse } from '@kbn/slo-schema';
import { SLOViewSettings } from './slo_view_settings';
export type SLOView = 'cardView' | 'listView';
@ -17,6 +19,7 @@ interface Props {
onChangeView: (view: SLOView) => void;
isCompact: boolean;
sloView: SLOView;
sloList?: FindSLOResponse;
}
const toggleButtonsIcons = [
@ -39,10 +42,38 @@ export function ToggleSLOView({
onChangeView,
onToggleCompactView,
isCompact = true,
sloList,
}: Props) {
const total = sloList?.total ?? 0;
const pageSize = sloList?.perPage ?? 0;
const pageIndex = sloList?.page ?? 1;
const rangeStart = (total === 0 ? 0 : pageSize * (pageIndex - 1)) + 1;
const rangeEnd = Math.min(total, pageSize * (pageIndex - 1) + pageSize);
return (
<EuiFlexGroup alignItems="center">
<EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiText size="s">
<FormattedMessage
id="xpack.observability.overview.pagination.description"
defaultMessage="Showing {currentCount} of {total} {slos}"
values={{
currentCount: <strong>{`${rangeStart}-${rangeEnd}`}</strong>,
total,
slos: (
<strong>
<FormattedMessage
id="xpack.observability.overview.slos.label"
defaultMessage="SLOs"
/>
</strong>
),
}}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonGroup
legend={i18n.translate('xpack.observability.toggleSLOView.euiButtonGroup.sloView', {
defaultMessage: 'SLO View',

View file

@ -8,6 +8,8 @@
import { createKbnUrlStateStorage } from '@kbn/kibana-utils-plugin/public';
import deepmerge from 'deepmerge';
import { useHistory } from 'react-router-dom';
import { Filter } from '@kbn/es-query';
import { useEffect, useRef, useState } from 'react';
import { DEFAULT_SLO_PAGE_SIZE } from '../../../../common/slo/constants';
import type { SortField, SortDirection } from '../components/slo_list_search_bar';
import type { SLOView } from '../components/toggle_slo_view';
@ -24,6 +26,8 @@ export interface SearchState {
};
view: SLOView;
compact: boolean;
filters: Filter[];
lastRefresh?: number;
}
export const DEFAULT_STATE = {
@ -33,27 +37,51 @@ export const DEFAULT_STATE = {
sort: { by: 'status' as const, direction: 'desc' as const },
view: 'cardView' as const,
compact: true,
filters: [],
lastRefresh: 0,
};
export function useUrlSearchState(): {
state: SearchState;
store: (state: Partial<SearchState>) => Promise<string | undefined>;
} {
const [state, setState] = useState<SearchState>(DEFAULT_STATE);
const history = useHistory();
const urlStateStorage = createKbnUrlStateStorage({
history,
useHash: false,
useHashQuery: false,
});
const urlStateStorage = useRef(
createKbnUrlStateStorage({
history,
useHash: false,
useHashQuery: false,
})
);
const searchState =
urlStateStorage.get<SearchState>(SLO_LIST_SEARCH_URL_STORAGE_KEY) ?? DEFAULT_STATE;
useEffect(() => {
const sub = urlStateStorage.current
?.change$<SearchState>(SLO_LIST_SEARCH_URL_STORAGE_KEY)
.subscribe((newSearchState) => {
if (newSearchState) {
setState(newSearchState);
}
});
setState(
urlStateStorage.current?.get<SearchState>(SLO_LIST_SEARCH_URL_STORAGE_KEY) ?? DEFAULT_STATE
);
return () => {
sub?.unsubscribe();
};
}, [urlStateStorage]);
return {
state: deepmerge(DEFAULT_STATE, searchState),
store: (state: Partial<SearchState>) =>
urlStateStorage.set(SLO_LIST_SEARCH_URL_STORAGE_KEY, deepmerge(searchState, state), {
replace: true,
}),
state: deepmerge(DEFAULT_STATE, state),
store: (newState: Partial<SearchState>) =>
urlStateStorage.current?.set(
SLO_LIST_SEARCH_URL_STORAGE_KEY,
{ ...state, ...newState },
{
replace: true,
}
),
};
}

View file

@ -111,6 +111,7 @@ const mockKibana = () => {
},
unifiedSearch: {
ui: {
SearchBar: () => <div>SearchBar</div>,
QueryStringInput: () => <div>Query String Input</div>,
},
autocomplete: {
@ -181,22 +182,7 @@ describe('SLOs Page', () => {
render(<SlosPage />);
});
expect(screen.getByText('Create new SLO')).toBeTruthy();
});
it('should have an Auto Refresh button', async () => {
useFetchSloListMock.mockReturnValue({ isLoading: false, data: sloList });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
data: historicalSummaryData,
});
await act(async () => {
render(<SlosPage />);
});
expect(screen.getByTestId('autoRefreshButton')).toBeTruthy();
expect(screen.getByText('Create SLO')).toBeTruthy();
});
describe('when API has returned results', () => {
@ -218,7 +204,7 @@ describe('SLOs Page', () => {
expect(screen.queryByTestId('slosPage')).toBeTruthy();
expect(screen.queryByTestId('sloList')).toBeTruthy();
expect(screen.queryAllByTestId('sloItem')).toBeTruthy();
expect(screen.queryAllByTestId('sloItem').length).toBe(sloList.results.length);
expect((await screen.findAllByTestId('sloItem')).length).toBe(sloList.results.length);
});
it('allows editing an SLO', async () => {

View file

@ -5,22 +5,16 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { EuiButton, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useEffect } from 'react';
import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../utils/kibana_react';
import { usePluginContext } from '../../hooks/use_plugin_context';
import { useLicense } from '../../hooks/use_license';
import { useCapabilities } from '../../hooks/slo/use_capabilities';
import { useFetchSloList } from '../../hooks/slo/use_fetch_slo_list';
import { SloList } from './components/slo_list';
import { AutoRefreshButton } from '../../components/slo/auto_refresh_button';
import { HeaderTitle } from './components/header_title';
import { FeedbackButton } from '../../components/slo/feedback_button/feedback_button';
import { paths } from '../../../common/locators/paths';
import { useAutoRefreshStorage } from '../../components/slo/auto_refresh_button/hooks/use_auto_refresh_storage';
import { HeaderMenu } from '../overview/components/header_menu/header_menu';
import { SloOutdatedCallout } from '../../components/slo/slo_outdated_callout';
@ -30,15 +24,11 @@ export function SlosPage() {
http: { basePath },
} = useKibana().services;
const { ObservabilityPageTemplate } = usePluginContext();
const { hasWriteCapabilities } = useCapabilities();
const { hasAtLeast } = useLicense();
const { isLoading, isError, data: sloList } = useFetchSloList();
const { total } = sloList ?? { total: 0 };
const { storeAutoRefreshState, getAutoRefreshState } = useAutoRefreshStorage();
const [isAutoRefreshing, setIsAutoRefreshing] = useState<boolean>(getAutoRefreshState());
useBreadcrumbs([
{
href: basePath.prepend(paths.observability.slos),
@ -55,45 +45,11 @@ export function SlosPage() {
}
}, [basePath, hasAtLeast, isError, isLoading, navigateToUrl, total]);
const handleClickCreateSlo = () => {
navigateToUrl(basePath.prepend(paths.observability.sloCreate));
};
const handleToggleAutoRefresh = () => {
setIsAutoRefreshing(!isAutoRefreshing);
storeAutoRefreshState(!isAutoRefreshing);
};
return (
<ObservabilityPageTemplate
pageHeader={{
pageTitle: <HeaderTitle />,
rightSideItems: [
<EuiButton
color="primary"
data-test-subj="slosPageCreateNewSloButton"
disabled={!hasWriteCapabilities}
fill
onClick={handleClickCreateSlo}
>
{i18n.translate('xpack.observability.slo.sloList.pageHeader.createNewButtonLabel', {
defaultMessage: 'Create new SLO',
})}
</EuiButton>,
<AutoRefreshButton
isAutoRefreshing={isAutoRefreshing}
onClick={handleToggleAutoRefresh}
/>,
<FeedbackButton />,
],
bottomBorder: false,
}}
data-test-subj="slosPage"
>
<ObservabilityPageTemplate data-test-subj="slosPage">
<HeaderMenu />
<SloOutdatedCallout />
<EuiSpacer size="l" />
<SloList autoRefresh={isAutoRefreshing} />
<SloList />
</ObservabilityPageTemplate>
);
}

View file

@ -35,6 +35,7 @@ describe('FindSLO', () => {
expect(mockSummarySearchClient.search.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"",
"",
Object {
"direction": "asc",
@ -128,6 +129,7 @@ describe('FindSLO', () => {
expect(mockSummarySearchClient.search.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"slo.name:'Service*' and slo.indicator.type:'sli.kql.custom'",
"",
Object {
"direction": "asc",
"field": "error_budget_consumed",

View file

@ -24,6 +24,7 @@ export class FindSLO {
public async execute(params: FindSLOParams): Promise<FindSLOResponse> {
const sloSummaryList = await this.summarySearchClient.search(
params.kqlQuery ?? '',
params.filters ?? '',
toSort(params),
toPagination(params)
);

View file

@ -36,7 +36,7 @@ describe('Summary Search Client', () => {
it('returns an empty response on error', async () => {
esClientMock.count.mockRejectedValue(new Error('Cannot reach es'));
await expect(service.search('', defaultSort, defaultPagination)).resolves
await expect(service.search('', '', defaultSort, defaultPagination)).resolves
.toMatchInlineSnapshot(`
Object {
"page": 1,
@ -53,7 +53,7 @@ describe('Summary Search Client', () => {
_shards: { failed: 0, successful: 1, total: 1 },
});
await expect(service.search('', defaultSort, defaultPagination)).resolves
await expect(service.search('', '', defaultSort, defaultPagination)).resolves
.toMatchInlineSnapshot(`
Object {
"page": 1,
@ -99,7 +99,7 @@ describe('Summary Search Client', () => {
},
});
const results = await service.search('', defaultSort, defaultPagination);
const results = await service.search('', '', defaultSort, defaultPagination);
expect(esClientMock.deleteByQuery).toHaveBeenCalled();
expect(esClientMock.deleteByQuery.mock.calls[0]).toMatchSnapshot();

View file

@ -44,7 +44,12 @@ export interface Sort {
}
export interface SummarySearchClient {
search(kqlQuery: string, sort: Sort, pagination: Pagination): Promise<Paginated<SLOSummary>>;
search(
kqlQuery: string,
filters: string,
sort: Sort,
pagination: Pagination
): Promise<Paginated<SLOSummary>>;
}
export class DefaultSummarySearchClient implements SummarySearchClient {
@ -56,16 +61,29 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
async search(
kqlQuery: string,
filters: string,
sort: Sort,
pagination: Pagination
): Promise<Paginated<SLOSummary>> {
let parsedFilters: any = {};
try {
parsedFilters = JSON.parse(filters);
} catch (e) {
this.logger.error(`Failed to parse filters: ${e.message}`);
}
try {
const summarySearch = await this.esClient.search<EsSummaryDocument>({
index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
track_total_hits: true,
query: {
bool: {
filter: [{ term: { spaceId: this.spaceId } }, getElastichsearchQueryOrThrow(kqlQuery)],
filter: [
{ term: { spaceId: this.spaceId } },
getElastichsearchQueryOrThrow(kqlQuery),
...(parsedFilters.filter ?? []),
],
},
},
sort: {

View file

@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n';
import { ApplicationStart, ChromeBreadcrumb, ChromeStart } from '@kbn/core/public';
import { MouseEvent, useEffect } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { ChromeBreadcrumbsAppendExtension } from '@kbn/core-chrome-browser';
import { useQueryParams } from './use_query_params';
function addClickHandlers(
@ -36,13 +37,14 @@ function getTitleFromBreadCrumbs(breadcrumbs: ChromeBreadcrumb[]) {
export const useBreadcrumbs = (
extraCrumbs: ChromeBreadcrumb[],
app?: { id: string; label: string }
app?: { id: string; label: string },
breadcrumbsAppendExtension?: ChromeBreadcrumbsAppendExtension
) => {
const params = useQueryParams();
const {
services: {
chrome: { docTitle, setBreadcrumbs },
chrome: { docTitle, setBreadcrumbs, setBreadcrumbsAppendExtension },
application: { getUrlForApp, navigateToUrl },
},
} = useKibana<{
@ -52,6 +54,17 @@ export const useBreadcrumbs = (
const setTitle = docTitle.change;
const appPath = getUrlForApp(app?.id ?? 'observability-overview') ?? '';
useEffect(() => {
if (breadcrumbsAppendExtension) {
setBreadcrumbsAppendExtension(breadcrumbsAppendExtension);
}
return () => {
if (breadcrumbsAppendExtension) {
setBreadcrumbsAppendExtension(undefined);
}
};
}, [breadcrumbsAppendExtension, setBreadcrumbsAppendExtension]);
useEffect(() => {
const breadcrumbs = [
{

View file

@ -39,7 +39,8 @@
"@kbn/shared-ux-error-boundary",
"@kbn/management-settings-field-definition",
"@kbn/management-settings-types",
"@kbn/management-settings-utilities"
"@kbn/management-settings-utilities",
"@kbn/core-chrome-browser"
],
"exclude": ["target/**/*"]
}

View file

@ -29019,7 +29019,6 @@
"xpack.observability.slo.duration.minutely": "Par minute",
"xpack.observability.slo.duration.monthly": "Mensuel",
"xpack.observability.slo.duration.weekly": "Hebdomadaire",
"xpack.observability.slo.feedbackButtonLabel": "Dites-nous ce que vous pensez !",
"xpack.observability.slo.globalDiagnosis.errorNotification": "Vous ne disposez pas des autorisations nécessaires pour utiliser cette fonctionnalité.",
"xpack.observability.slo.indicators.apmAvailability": "Disponibilité APM",
"xpack.observability.slo.indicators.apmLatency": "Latence APM",
@ -29199,7 +29198,6 @@
"xpack.observability.slo.sloEdit.timeWindowDuration.tooltip": "La durée de la fenêtre temporelle utilisée pour calculer le SLO.",
"xpack.observability.slo.sloEdit.timeWindowType.label": "Fenêtre temporelle",
"xpack.observability.slo.sloEdit.timeWindowType.tooltip": "Choisissez entre une fenêtre glissante ou alignée sur le calendrier.",
"xpack.observability.slo.sloList.pageHeader.createNewButtonLabel": "Créer un nouveau SLO",
"xpack.observability.slo.sloList.welcomePrompt.buttonLabel": "Créer un SLO",
"xpack.observability.slo.sloList.welcomePrompt.getStartedMessage": "Pour commencer, créez votre premier SLO.",
"xpack.observability.slo.sloList.welcomePrompt.learnMore": "Envie d'en savoir plus ?",

View file

@ -29020,7 +29020,6 @@
"xpack.observability.slo.duration.minutely": "毎分",
"xpack.observability.slo.duration.monthly": "月ごと",
"xpack.observability.slo.duration.weekly": "週ごと",
"xpack.observability.slo.feedbackButtonLabel": "ご意見をお聞かせください。",
"xpack.observability.slo.globalDiagnosis.errorNotification": "この機能を使用する権限がありません。",
"xpack.observability.slo.indicators.apmAvailability": "APM可用性",
"xpack.observability.slo.indicators.apmLatency": "APMレイテンシ",
@ -29200,7 +29199,6 @@
"xpack.observability.slo.sloEdit.timeWindowDuration.tooltip": "SLOを計算するために使用される時間枠期間。",
"xpack.observability.slo.sloEdit.timeWindowType.label": "時間枠",
"xpack.observability.slo.sloEdit.timeWindowType.tooltip": "ローリング時間枠とカレンダー時間枠のどちらかを選択します。",
"xpack.observability.slo.sloList.pageHeader.createNewButtonLabel": "新規SLOを作成",
"xpack.observability.slo.sloList.welcomePrompt.buttonLabel": "SLOの作成",
"xpack.observability.slo.sloList.welcomePrompt.getStartedMessage": "開始するには、まずSLOを作成します。",
"xpack.observability.slo.sloList.welcomePrompt.learnMore": "詳細について",

View file

@ -29004,7 +29004,6 @@
"xpack.observability.slo.duration.minutely": "每分钟",
"xpack.observability.slo.duration.monthly": "每月",
"xpack.observability.slo.duration.weekly": "每周",
"xpack.observability.slo.feedbackButtonLabel": "告诉我们您的看法!",
"xpack.observability.slo.globalDiagnosis.errorNotification": "您没有适当权限,无法使用此功能。",
"xpack.observability.slo.indicators.apmAvailability": "APM 可用性",
"xpack.observability.slo.indicators.apmLatency": "APM 延迟",
@ -29184,7 +29183,6 @@
"xpack.observability.slo.sloEdit.timeWindowDuration.tooltip": "用于在其间计算 SLO 的时间窗口持续时间。",
"xpack.observability.slo.sloEdit.timeWindowType.label": "时间窗口",
"xpack.observability.slo.sloEdit.timeWindowType.tooltip": "选择滚动或日历对齐窗口。",
"xpack.observability.slo.sloList.pageHeader.createNewButtonLabel": "创建新 SLO",
"xpack.observability.slo.sloList.welcomePrompt.buttonLabel": "创建 SLO",
"xpack.observability.slo.sloList.welcomePrompt.getStartedMessage": "要开始使用,请创建您的首个 SLO。",
"xpack.observability.slo.sloList.welcomePrompt.learnMore": "希望了解详情?",