[SLO] Group filters in the Overview embeddable (#179620)

Fixes https://github.com/elastic/kibana/issues/179908
Fixes https://github.com/elastic/kibana/issues/179895




2924e619-9080-4c80-b5c3-7066a100026c



## UI changes

### SLO Overview configuration
<img width="600" alt="Screenshot 2024-04-15 at 13 34 21"
src="3635c9e7-e5c1-454b-bb60-93b5d637083e">


### Rendered panel with Group of SLOs
<img width="1022" alt="Screenshot 2024-04-03 at 13 08 11"
src="377c5cd0-b6e0-4906-888b-93893a5f1208">

### Updated SLO list group by page
<img width="1316" alt="Screenshot 2024-03-28 at 23 56 12"
src="d6b306a0-77e8-44e5-b103-ce3537b0ceda">

## Responsiveness
This PR makes sure that the UI doesn't look broken in smaller screen
resolutions and fixes following issues. If we want to display things
differently in smaller screen resolutions, we should handle in a follow
up PR (cc @maciejforcone)

### Before 

####  SLO list page in smaller screen resolution
<img width="500" alt="Screenshot 2024-04-03 at 10 14 31"
src="f2b11408-3356-4f4b-9c4b-ec085ff494fa">

####  Rendered panel with Group of SLOs in smaller screen resolution
<img width="500" alt="Screenshot 2024-04-03 at 10 13 18"
src="6e37ad61-5a0a-4027-8b90-4f8fad243637">

#### After

####  SLO list page in smaller screen resolution
<img width="500" alt="Screenshot 2024-04-03 at 11 04 35"
src="fa875aa8-bc54-4c6b-9159-4462e585a9cb">

####  Rendered panel with Group of SLOs in smaller screen resolution
I applied a `min-width` of 100px to the SLO name, so that it doesn't
look broken. What is not visible in the screenshot is a horizontal
scrollbar, so user can still see the part with Worst performing that is
not visible.
<img width="500" alt="Screenshot 2024-04-03 at 10 13 47"
src="d9566ca3-42af-4d85-a3f7-6374cc27981d">


## Acceptance criteria
- Overview configuration
  -  toggles between Single SLO or Grouped SLOs
- Required field for the `Grouped SLOs` view is only the `Group by`
option, the rest filters are optional
  - Users can optionally select the groups they want
  - Users can use the KQL bar to apply extra filters
- Overview configuration opens in a `Flyout` (modal was causing some
issues with z-index and kqlbar suggestions being displayed below the
overlay)
- Embeddable panel
- The rendered panel shows the selected groups with summary data divided
with a horizontal like and no border around it
- Clicking on a group opens an accordion similar to the SLO list group
by page
 - An Edit criteria link is visible only in the Grouped Embeddable panel
- Clicking on the Edit criteria link opens the configuration modal,
showing only the grouped tab and hiding the tabbed button group
 - The grouped embeddable should react to Dashboard's Refresh button
 - `Add to Dashboard` action should be hidden in Dashboard context

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Cee Chen <549407+cee-chen@users.noreply.github.com>
This commit is contained in:
Panagiota Mitsopoulou 2024-04-16 00:55:15 +02:00 committed by GitHub
parent 186d9cc5e7
commit 081f4e44ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1286 additions and 237 deletions

View file

@ -123,6 +123,7 @@ const findSLOGroupsParamsSchema = t.partial({
page: t.string,
perPage: t.string,
groupBy: groupBySchema,
groupsFilter: t.union([t.array(t.string), t.string]),
kqlQuery: t.string,
filters: t.string,
}),

View file

@ -25,8 +25,8 @@ export const paths = {
`${SLOS_BASE_PATH}${SLOS_PATH}/edit/${encodeURIComponent(sloId)}?_a=${encodedParams}`,
sloDetails: (sloId: string, instanceId?: string) =>
!!instanceId
? `${SLOS_BASE_PATH}${SLOS_PATH}/${encodeURIComponent(sloId)}?instanceId=${encodeURIComponent(
? `${SLOS_BASE_PATH}/${encodeURIComponent(sloId)}?instanceId=${encodeURIComponent(
instanceId
)}`
: `${SLOS_BASE_PATH}${SLOS_PATH}/${encodeURIComponent(sloId)}`,
: `${SLOS_BASE_PATH}/${encodeURIComponent(sloId)}`,
};

View file

@ -19,4 +19,10 @@ export interface PluginContextValue {
experimentalFeatures?: ExperimentalFeatures;
}
export const PluginContext = createContext({} as PluginContextValue);
export interface OverviewEmbeddableContextValue {
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry;
}
export const PluginContext = createContext(
{} as PluginContextValue | OverviewEmbeddableContextValue
);

View file

@ -8,3 +8,4 @@ import { AlertConsumers } from '@kbn/rule-data-utils';
export const SLO_ALERTS_EMBEDDABLE = 'SLO_ALERTS_EMBEDDABLE';
export const SLO_ALERTS_TABLE_CONFIG_ID = `${AlertConsumers.SLO}-embeddable-alerts-table`;
export const SLO_EMBEDDABLE = 'SLO_EMBEDDABLE';

View file

@ -0,0 +1,72 @@
/*
* 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, { useState, useEffect } from 'react';
import { Filter } from '@kbn/es-query';
import { Subject } from 'rxjs';
import { SLOView } from '../../../../pages/slos/components/toggle_slo_view';
import { SloEmbeddableInput } from '../types';
import { GroupView } from '../../../../pages/slos/components/grouped_slos/group_view';
import { buildCombinedKqlQuery } from './helpers/build_kql_query';
interface Props {
groupBy: string;
groups?: string[];
kqlQuery?: string;
sloView: SLOView;
sort?: string;
filters?: Filter[];
reloadGroupSubject: Subject<SloEmbeddableInput | undefined>;
}
export function GroupSloView({
sloView,
groupBy: initialGroupBy = 'status',
groups: initialGroups = [],
kqlQuery: initialKqlQuery = '',
filters: initialFilters = [],
reloadGroupSubject,
}: Props) {
const [lastRefreshTime, setLastRefreshTime] = useState<number | undefined>(undefined);
const [groupBy, setGroupBy] = useState(initialGroupBy);
const [kqlQuery, setKqlQuery] = useState(initialKqlQuery);
const [filters, setFilters] = useState(initialFilters);
const [groups, setGroups] = useState(initialGroups);
const combinedKqlQuery = buildCombinedKqlQuery({ groups, groupBy, kqlQuery });
useEffect(() => {
const subs = reloadGroupSubject?.subscribe((input) => {
if (input) {
const nGroupBy = input?.groupFilters?.groupBy ?? groupBy;
setGroupBy(nGroupBy);
const nKqlInput = input?.groupFilters?.kqlQuery ?? kqlQuery;
setKqlQuery(nKqlInput);
const nFilters = input?.groupFilters?.filters ?? filters;
setFilters(nFilters);
const nGroups = input?.groupFilters?.groups ?? groups;
setGroups(nGroups);
}
setLastRefreshTime(Date.now());
});
return () => {
subs?.unsubscribe();
};
}, [filters, groupBy, groups, kqlQuery, reloadGroupSubject]);
return (
<GroupView
sloView={sloView}
groupBy={groupBy}
groupsFilter={groups}
kqlQuery={combinedKqlQuery}
filters={filters}
lastRefreshTime={lastRefreshTime}
/>
);
}

View file

@ -0,0 +1,9 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`buildCombinedKqlQuery should generate correct es query for no selected groups, no kql 1`] = `""`;
exports[`buildCombinedKqlQuery should generate correct es query for no selected groups, with kql 1`] = `"status:\\"VIOLATED\\""`;
exports[`buildCombinedKqlQuery should generate correct es query for selected groups, no kql 1`] = `"(slo.tags:\\"production\\" or slo.tags:\\"dev\\")"`;
exports[`buildCombinedKqlQuery should generate correct es query for selected groups, with kql 1`] = `"(slo.tags:\\"production\\" or slo.tags:\\"dev\\") and status:\\"VIOLATED\\""`;

View file

@ -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 { buildCombinedKqlQuery } from './build_kql_query';
describe('buildCombinedKqlQuery', () => {
const testData = [
{
title: 'no selected groups, with kql',
props: {
groups: [],
groupBy: 'slo.tags',
kqlQuery: 'status:"VIOLATED"',
},
},
{
title: 'selected groups, with kql',
props: {
groups: ['production', 'dev'],
groupBy: 'slo.tags',
kqlQuery: 'status:"VIOLATED"',
},
},
{
title: 'selected groups, no kql',
props: {
groups: ['production', 'dev'],
groupBy: 'slo.tags',
kqlQuery: '',
},
},
{
title: 'no selected groups, no kql',
props: {
groups: [],
groupBy: 'slo.tags',
kqlQuery: '',
},
},
];
test.each(testData)('should generate correct es query for $title', ({ props }) => {
expect(buildCombinedKqlQuery(props)).toMatchSnapshot();
});
});

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.
*/
interface Props {
kqlQuery: string;
groups: string[];
groupBy: string;
}
export const buildCombinedKqlQuery = ({ groups, groupBy, kqlQuery }: Props) => {
let groupsKqlQuery = '';
if (groups.length > 0) {
groupsKqlQuery += `(`;
groups.map((group, index) => {
const shouldAddOr = index < groups.length - 1;
groupsKqlQuery += `${groupBy}:"${group}"`;
if (shouldAddOr) {
groupsKqlQuery += ' or ';
}
});
groupsKqlQuery += `)`;
}
let combinedKqlQuery = '';
if (kqlQuery && groupsKqlQuery) {
combinedKqlQuery = `${groupsKqlQuery} and ${kqlQuery}`;
} else if (groupsKqlQuery) {
combinedKqlQuery = groupsKqlQuery;
} else if (kqlQuery) {
combinedKqlQuery = kqlQuery;
}
return combinedKqlQuery;
};

View file

@ -0,0 +1,4 @@
.sloOverviewEmbeddable .uniSearchBar {
padding-left: 0;
padding-right: 0;
}

View file

@ -0,0 +1,250 @@
/*
* 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 './slo_group_filters.scss';
import React, { useState, useEffect, useMemo } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { Filter } from '@kbn/es-query';
import { debounce } from 'lodash';
import { EuiFormRow, EuiComboBox, EuiSelect, EuiComboBoxOptionOption, EuiText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useFetchSloGroups } from '../../../../hooks/use_fetch_slo_groups';
import { SLI_OPTIONS } from '../../../../pages/slo_edit/constants';
import { useKibana } from '../../../../utils/kibana_react';
import { useCreateDataView } from '../../../../hooks/use_create_data_view';
import { SLO_SUMMARY_DESTINATION_INDEX_NAME } from '../../../../../common/constants';
import { sloAppId } from '../../../../../common';
import type { GroupFilters, GroupBy } from '../types';
interface Option {
value: string;
text: string;
}
export const groupByOptions: Option[] = [
{
text: i18n.translate('xpack.slo.sloGroupConfiguration.groupBy.tags', {
defaultMessage: 'Tags',
}),
value: 'slo.tags',
},
{
text: i18n.translate('xpack.slo.sloGroupConfiguration.groupBy.status', {
defaultMessage: 'Status',
}),
value: 'status',
},
{
text: i18n.translate('xpack.slo.sloGroupConfiguration.groupBy.sliType', {
defaultMessage: 'SLI type',
}),
value: 'slo.indicator.type',
},
];
interface Props {
onSelected: (prop: string, value: string | Array<string | undefined> | Filter[]) => void;
selectedFilters: GroupFilters;
}
const mapGroupsToOptions = (groups: string[] | undefined, selectedGroupBy: string) =>
groups?.map((group) => {
let label = group;
if (selectedGroupBy === 'status') {
label = group.toLowerCase();
} else if (selectedGroupBy === 'slo.indicator.type') {
label = SLI_OPTIONS.find((option) => option.value === group)!.text ?? group;
}
return {
label,
value: group,
};
}) ?? [];
export function SloGroupFilters({ selectedFilters, onSelected }: Props) {
const {
unifiedSearch: {
ui: { SearchBar },
},
} = useKibana().services;
const { dataView } = useCreateDataView({
indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_NAME,
});
const [selectedGroupBy, setSelectedGroupBy] =
useState<GroupBy>(selectedFilters.groupBy) ?? 'status';
const [filters, setFilters] = useState(selectedFilters.filters) ?? [];
const [kqlQuery, setkqlQuery] = useState(selectedFilters.kqlQuery);
const [selectedGroupByLabel, setSelectedGroupByLabel] = useState('Status');
const [groupOptions, setGroupOptions] = useState<Array<EuiComboBoxOptionOption<string>>>([]);
const [selectedGroupOptions, setSelectedGroupOptions] = useState<
Array<EuiComboBoxOptionOption<string>>
>(mapGroupsToOptions(selectedFilters.groups, selectedGroupBy));
const [searchValue, setSearchValue] = useState<string>('');
const sliTypeSearch = SLI_OPTIONS.find((option) =>
option.text.toLowerCase().includes(searchValue.toLowerCase())
)?.value;
const query = `${searchValue}*`;
const { data, isLoading } = useFetchSloGroups({
perPage: 100,
groupBy: selectedGroupBy,
kqlQuery: `slo.tags: (${query}) or status: (${query.toUpperCase()}) or slo.indicator.type: (${sliTypeSearch})`,
});
useEffect(() => {
const isLoadedWithData = !isLoading && data?.results !== undefined;
const opts: Array<EuiComboBoxOptionOption<string>> = isLoadedWithData
? mapGroupsToOptions(
data?.results.map((item) => item.group),
selectedGroupBy
)
: [];
setGroupOptions(opts);
if (selectedGroupBy === 'slo.tags') {
setSelectedGroupByLabel(
i18n.translate('xpack.slo.sloGroupConfiguration.tagsLabel', {
defaultMessage: 'Tags',
})
);
} else if (selectedGroupBy === 'status') {
setSelectedGroupByLabel(
i18n.translate('xpack.slo.sloGroupConfiguration.statusLabel', {
defaultMessage: 'Status',
})
);
} else if (selectedGroupBy === 'slo.indicator.type') {
setSelectedGroupByLabel(
i18n.translate('xpack.slo.sloGroupConfiguration.sliTypeLabel', {
defaultMessage: 'SLI type',
})
);
}
}, [isLoading, data, selectedGroupBy]);
const onChange = (opts: Array<EuiComboBoxOptionOption<string>>) => {
setSelectedGroupOptions(opts);
const selectedGroups = opts.length >= 1 ? opts.map((opt) => opt.value) : [];
onSelected('groups', selectedGroups);
};
const onSearchChange = useMemo(
() =>
debounce((value: string) => {
setSearchValue(value);
}, 300),
[]
);
return (
<>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.slo.sloGroupConfiguration.groupTypeLabel', {
defaultMessage: 'Group by',
})}
>
<EuiSelect
fullWidth
data-test-subj="o11ySloGroupConfigurationSelect"
options={groupByOptions}
value={selectedGroupBy}
onChange={(e) => {
setSelectedGroupBy(e.target.value as GroupBy);
onSelected('groupBy', e.target.value);
setSelectedGroupOptions([]);
onSelected('groups', []);
}}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.slo.sloGroupConfiguration.groupByLabel', {
defaultMessage: '{ selectedGroupByLabel }',
values: { selectedGroupByLabel },
})}
labelAppend={
<EuiText color="subdued" size="xs">
<FormattedMessage
id="xpack.slo.sloGroupConfiguration.groupsOptional"
defaultMessage="Optional"
/>
</EuiText>
}
>
<EuiComboBox
aria-label={i18n.translate('xpack.slo.sloGroupConfiguration.sloSelector.ariaLabel', {
defaultMessage: '{ selectedGroupByLabel }',
values: { selectedGroupByLabel },
})}
placeholder={i18n.translate('xpack.slo.sloGroupConfiguration.sloSelector.placeholder', {
defaultMessage: 'Select a {selectedGroupByLabel}',
values: { selectedGroupByLabel },
})}
data-test-subj="sloGroup"
options={groupOptions}
selectedOptions={selectedGroupOptions}
async
onChange={onChange}
onSearchChange={onSearchChange}
fullWidth
singleSelection={false}
/>
</EuiFormRow>
<EuiFormRow
fullWidth
label={i18n.translate('xpack.slo.sloGroupConfiguration.customFiltersLabel', {
defaultMessage: 'Custom filter',
})}
labelAppend={
<EuiText color="subdued" size="xs">
<FormattedMessage
id="xpack.slo.sloGroupConfiguration.customFiltersOptional"
defaultMessage="Optional"
/>
</EuiText>
}
>
<SearchBar
appName={sloAppId}
placeholder={PLACEHOLDER}
indexPatterns={dataView ? [dataView] : []}
showSubmitButton={false}
showFilterBar={true}
filters={filters}
onFiltersUpdated={(newFilters) => {
setFilters(newFilters);
onSelected('filters', newFilters);
}}
onQuerySubmit={({ query: value }) => {
setkqlQuery(String(value?.query));
onSelected('kqlQuery', String(value?.query));
}}
onQueryChange={({ query: value }) => {
setkqlQuery(String(value?.query));
onSelected('kqlQuery', String(value?.query));
}}
query={{ query: String(kqlQuery), language: 'kuery' }}
showDatePicker={false}
disableQueryLanguageSwitcher={true}
saveQueryMenuVisibility="globally_managed"
onClearSavedQuery={() => {}}
showQueryInput={true}
onSavedQueryUpdated={(savedQuery) => {
setFilters(savedQuery.attributes.filters);
setkqlQuery(String(savedQuery.attributes.query.query));
}}
/>
</EuiFormRow>
</>
);
}
const PLACEHOLDER = i18n.translate('xpack.slo.sloGroupConfiguration.customFilterText', {
defaultMessage: 'Custom filter',
});

View file

@ -24,7 +24,7 @@ export async function resolveEmbeddableSloUserInput(
const queryClient = new QueryClient();
return new Promise(async (resolve, reject) => {
try {
const modalSession = overlays.openModal(
const flyoutSession = overlays.openFlyout(
toMountPoint(
<KibanaContextProvider
services={{
@ -34,12 +34,13 @@ export async function resolveEmbeddableSloUserInput(
>
<QueryClientProvider client={queryClient}>
<SloConfiguration
initialInput={input}
onCreate={(update: EmbeddableSloProps) => {
modalSession.close();
flyoutSession.close();
resolve(update);
}}
onCancel={() => {
modalSession.close();
flyoutSession.close();
reject();
}}
/>

View file

@ -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 React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiButtonGroup, type EuiButtonGroupOptionProps } from '@elastic/eui';
import { OverviewMode } from './types';
const overviewModeOptions: EuiButtonGroupOptionProps[] = [
{
id: `single`,
label: (
<FormattedMessage
id="xpack.slo.overviewEmbeddable.typeSelector.singleSLOLabel"
defaultMessage="Single SLO"
/>
),
},
{
id: `groups`,
label: (
<FormattedMessage
id="xpack.slo.overviewEmbeddable.typeSelector.groupSLOLabel"
defaultMessage="Grouped SLOs"
/>
),
},
];
export interface OverviewModeSelectorProps {
value: string;
onChange: (update: OverviewMode) => void;
}
export function OverviewModeSelector({ value, onChange }: OverviewModeSelectorProps) {
return (
<EuiButtonGroup
isFullWidth
legend="This is a basic group"
options={overviewModeOptions}
idSelected={value}
onChange={onChange as (id: string) => void}
/>
);
}

View file

@ -7,100 +7,232 @@
import React, { useState } from 'react';
import {
EuiModal,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalBody,
EuiModalFooter,
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiSwitch,
EuiSpacer,
EuiFlyout,
EuiFlyoutHeader,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { ALL_VALUE } from '@kbn/slo-schema';
import { i18n } from '@kbn/i18n';
import { SloSelector } from '../alerts/slo_selector';
import type { EmbeddableSloProps } from './types';
import type {
SingleSloProps,
GroupSloProps,
SloEmbeddableInput,
GroupFilters,
OverviewMode,
} from './types';
import { SloGroupFilters } from './group_view/slo_group_filters';
import { OverviewModeSelector } from './overview_mode_selector';
interface SloConfigurationProps {
onCreate: (props: EmbeddableSloProps) => void;
initialInput?: Partial<SloEmbeddableInput> | undefined;
onCreate: (props: SingleSloProps | GroupSloProps) => void;
onCancel: () => void;
}
export function SloConfiguration({ onCreate, onCancel }: SloConfigurationProps) {
const [selectedSlo, setSelectedSlo] = useState<EmbeddableSloProps>();
interface SingleConfigurationProps {
onCreate: (props: SingleSloProps) => void;
onCancel: () => void;
overviewMode: OverviewMode;
}
interface GroupConfigurationProps {
onCreate: (props: GroupSloProps) => void;
onCancel: () => void;
overviewMode: OverviewMode;
initialInput?: GroupSloProps;
}
function SingleSloConfiguration({ overviewMode, onCreate, onCancel }: SingleConfigurationProps) {
const [selectedSlo, setSelectedSlo] = useState<SingleSloProps>();
const [showAllGroupByInstances, setShowAllGroupByInstances] = useState(false);
const [hasError, setHasError] = useState(false);
const hasGroupBy = selectedSlo && selectedSlo.sloInstanceId !== ALL_VALUE;
const onConfirmClick = () =>
onCreate({
showAllGroupByInstances,
sloId: selectedSlo?.sloId,
sloInstanceId: selectedSlo?.sloInstanceId,
overviewMode,
});
const [hasError, setHasError] = useState(false);
return (
<EuiModal onClose={onCancel} style={{ minWidth: 550 }}>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('xpack.slo.sloEmbeddable.config.sloSelector.headerTitle', {
defaultMessage: 'SLO configuration',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<>
<EuiFlyoutBody>
<EuiFlexGroup>
<EuiFlexItem grow>
<SloSelector
singleSelection={true}
hasError={hasError}
onSelected={(slo) => {
setHasError(slo === undefined);
if (slo && 'id' in slo) {
setSelectedSlo({ sloId: slo.id, sloInstanceId: slo.instanceId });
}
}}
/>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<SloSelector
singleSelection={true}
hasError={hasError}
onSelected={(slo) => {
setHasError(slo === undefined);
if (slo && 'id' in slo) {
setSelectedSlo({ sloId: slo.id, sloInstanceId: slo.instanceId });
}
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
{hasGroupBy && (
<>
<EuiSpacer />
<EuiSwitch
label={i18n.translate(
'xpack.slo.sloConfiguration.euiSwitch.showAllGroupByLabel',
{
defaultMessage: 'Show all related group-by instances',
}
)}
checked={showAllGroupByInstances}
onChange={(e) => {
setShowAllGroupByInstances(e.target.checked);
}}
/>
</>
)}
</EuiFlexItem>
</EuiFlexGroup>
{selectedSlo?.sloInstanceId !== ALL_VALUE && (
<>
<EuiSpacer />
<EuiSwitch
label={i18n.translate('xpack.slo.sloConfiguration.euiSwitch.showAllGroupByLabel', {
defaultMessage: 'Show all related group-by instances',
})}
checked={showAllGroupByInstances}
onChange={(e) => {
setShowAllGroupByInstances(e.target.checked);
}}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiButtonEmpty onClick={onCancel} data-test-subj="sloCancelButton">
<FormattedMessage
id="xpack.slo.sloEmbeddable.config.cancelButtonLabel"
defaultMessage="Cancel"
/>
</>
)}
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel} data-test-subj="sloCancelButton">
<FormattedMessage
id="xpack.slo.sloEmbeddable.config.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiButtonEmpty>
<EuiButton
data-test-subj="sloConfirmButton"
isDisabled={!selectedSlo || hasError}
onClick={onConfirmClick}
fill
>
<FormattedMessage
id="xpack.slo.embeddableSlo.config.confirmButtonLabel"
defaultMessage="Confirm configurations"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
<EuiButton
data-test-subj="sloConfirmButton"
isDisabled={!selectedSlo || hasError}
onClick={onConfirmClick}
fill
>
<FormattedMessage
id="xpack.slo.overviewEmbeddableSlo.config.confirmButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
}
function GroupSloConfiguration({
overviewMode,
onCreate,
onCancel,
initialInput,
}: GroupConfigurationProps) {
const [selectedGroupFilters, setSelectedGroupFilters] = useState<GroupFilters>({
groupBy: initialInput?.groupFilters.groupBy ?? 'status',
filters: initialInput?.groupFilters.filters ?? [],
kqlQuery: initialInput?.groupFilters.kqlQuery ?? '',
groups: initialInput?.groupFilters.groups ?? [],
});
const onConfirmClick = () =>
onCreate({
groupFilters: selectedGroupFilters,
overviewMode,
});
return (
<>
<EuiFlyoutBody className="sloOverviewEmbeddable">
<EuiFlexGroup>
<EuiFlexItem>
<EuiFlexGroup>
<EuiFlexItem>
<SloGroupFilters
selectedFilters={selectedGroupFilters}
onSelected={(prop, value) => {
setSelectedGroupFilters((prevState) => ({ ...prevState, [prop]: value }));
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiButtonEmpty onClick={onCancel} data-test-subj="sloCancelButton">
<FormattedMessage
id="xpack.slo.sloEmbeddable.config.cancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton data-test-subj="sloConfirmButton" onClick={onConfirmClick} fill>
<FormattedMessage
id="xpack.slo.overviewEmbeddableSlo.config.confirmButtonLabel"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexGroup>
</EuiFlyoutFooter>
</>
);
}
export function SloConfiguration({ initialInput, onCreate, onCancel }: SloConfigurationProps) {
const [overviewMode, setOverviewMode] = useState<OverviewMode>(
initialInput?.overviewMode ?? 'single'
);
return (
<EuiFlyout onClose={onCancel}>
<EuiFlyoutHeader>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle>
<h2>
{i18n.translate('xpack.slo.sloEmbeddable.config.sloSelector.headerTitle', {
defaultMessage: 'SLO configuration',
})}
</h2>
</EuiTitle>
</EuiFlexItem>
{initialInput?.overviewMode === undefined && (
<EuiFlexItem>
<OverviewModeSelector
value={overviewMode}
onChange={(mode) => setOverviewMode(mode)}
/>
<EuiSpacer size="m" />
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlyoutHeader>
{overviewMode === 'groups' ? (
<GroupSloConfiguration
initialInput={initialInput as GroupSloProps}
overviewMode={overviewMode}
onCreate={onCreate}
onCancel={onCancel}
/>
) : (
<SingleSloConfiguration
overviewMode={overviewMode}
onCreate={onCreate}
onCancel={onCancel}
/>
)}
</EuiFlyout>
);
}

View file

@ -0,0 +1,173 @@
/*
* 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 { nextTick } from '@kbn/test-jest-helpers';
import { ReactWrapper } from 'enzyme';
import { ReactElement } from 'react';
import { SLOEmbeddable, SloEmbeddableDeps } from './slo_embeddable';
import type { SloEmbeddableInput, OverviewMode, GroupFilters } from './types';
import { uiSettingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import { i18nServiceMock } from '@kbn/core-i18n-browser-mocks';
import { themeServiceMock } from '@kbn/core-theme-browser-mocks';
import { notificationServiceMock } from '@kbn/core-notifications-browser-mocks';
import { applicationServiceMock } from '@kbn/core/public/mocks';
import { httpServiceMock } from '@kbn/core-http-browser-mocks';
import { createObservabilityRuleTypeRegistryMock } from '@kbn/observability-plugin/public';
import { SloOverview } from './slo_overview';
import { useFetchSloDetails } from '../../../hooks/use_fetch_slo_details';
import { useFetchActiveAlerts } from '../../../hooks/use_fetch_active_alerts';
import { useFetchHistoricalSummary } from '../../../hooks/use_fetch_historical_summary';
import { useFetchRulesForSlo } from '../../../hooks/use_fetch_rules_for_slo';
import { ActiveAlerts } from '../../../hooks/active_alerts';
import { buildSlo } from '../../../data/slo/slo';
import { historicalSummaryData } from '../../../data/slo/historical_summary_data';
import { uiActionsPluginMock } from '@kbn/ui-actions-plugin/public/mocks';
import { GroupListView } from '../../../pages/slos/components/grouped_slos/group_list_view';
import { render } from 'react-dom';
import { act } from 'react-dom/test-utils';
let mockWrapper: ReactWrapper;
jest.mock('react-dom', () => {
const { mount } = jest.requireActual('enzyme');
return {
...jest.requireActual('react-dom'),
render: jest.fn((component: ReactElement) => {
mockWrapper = mount(component);
}),
};
});
jest.mock('../../../hooks/use_fetch_slo_details');
jest.mock('../../../hooks/use_fetch_active_alerts');
jest.mock('../../../hooks/use_fetch_historical_summary');
jest.mock('../../../hooks/use_fetch_rules_for_slo');
const useFetchSloDetailsMock = useFetchSloDetails as jest.Mock;
const useFetchActiveAlertsMock = useFetchActiveAlerts as jest.Mock;
const useFetchHistoricalSummaryMock = useFetchHistoricalSummary as jest.Mock;
const useFetchRulesForSloMock = useFetchRulesForSlo as jest.Mock;
function createSloEmbeddableDepsMock(): SloEmbeddableDeps {
return {
application: applicationServiceMock.createStartContract(),
uiSettings: uiSettingsServiceMock.createSetupContract(),
i18n: i18nServiceMock.createStartContract(),
theme: themeServiceMock.createStartContract(),
notifications: notificationServiceMock.createStartContract(),
http: httpServiceMock.createStartContract(),
uiActions: uiActionsPluginMock.createStartContract(),
observability: {
config: {
unsafe: {
alertDetails: {
observability: { enabled: true },
metrics: { enabled: false },
uptime: { enabled: false },
},
},
},
useRulesLink: () => ({ href: 'newRuleLink' }),
observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(),
},
};
}
const waitOneTick = () => act(() => new Promise((resolve) => setTimeout(resolve, 0)));
describe('SLO Overview embeddable', () => {
let mountpoint: HTMLDivElement;
let depsMock: jest.Mocked<SloEmbeddableDeps>;
const createEmbeddable = ({
overviewMode,
sloId,
sloInstanceId,
groupFilters,
}: {
overviewMode: OverviewMode;
sloId?: string;
sloInstanceId?: string;
groupFilters?: GroupFilters;
}) => {
const baseInput: SloEmbeddableInput = {
id: 'mock-embeddable-id',
overviewMode,
};
let initialInput = baseInput;
initialInput =
overviewMode === 'single'
? {
...baseInput,
sloId,
sloInstanceId,
}
: {
...baseInput,
groupFilters,
};
return new SLOEmbeddable(depsMock, initialInput);
};
beforeEach(() => {
mountpoint = document.createElement('div');
depsMock = createSloEmbeddableDepsMock() as unknown as jest.Mocked<SloEmbeddableDeps>;
});
afterEach(() => {
mountpoint.remove();
jest.restoreAllMocks();
});
it('should render single Overview', async () => {
const slo = buildSlo();
useFetchSloDetailsMock.mockReturnValue({ isLoading: false, data: slo, refetch: () => {} });
useFetchActiveAlertsMock.mockReturnValue({ isLoading: false, data: new ActiveAlerts() });
useFetchHistoricalSummaryMock.mockReturnValue({
isLoading: false,
data: historicalSummaryData,
});
useFetchRulesForSloMock.mockReturnValue({
isLoading: false,
data: [],
});
const embeddable = createEmbeddable({
overviewMode: 'single',
sloId: 'sloId',
sloInstanceId: 'sloInstanceId',
});
await waitOneTick();
expect(render).toHaveBeenCalledTimes(0);
embeddable.render(mountpoint);
expect(render).toHaveBeenCalledTimes(1);
expect(mockWrapper.find(SloOverview).exists()).toBe(true);
expect(mockWrapper.find(GroupListView).exists()).toBe(false);
});
it('should show SLOs grouped by tags', async () => {
await act(async () => {
await nextTick();
mockWrapper.unmount();
mockWrapper.update();
});
const embeddable = createEmbeddable({
overviewMode: 'groups',
groupFilters: {
groupBy: 'slo.tags',
groups: ['production'],
},
});
await waitOneTick();
expect(render).toHaveBeenCalledTimes(0);
embeddable.render(mountpoint);
mockWrapper.update();
expect(render).toHaveBeenCalledTimes(1);
expect(mockWrapper.find(SloOverview).exists()).toBe(false);
});
});

View file

@ -6,6 +6,8 @@
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { EuiFlexItem, EuiLink, EuiFlexGroup } from '@elastic/eui';
import styled from 'styled-components';
import { i18n } from '@kbn/i18n';
import { Router } from '@kbn/shared-ux-router';
import {
@ -22,29 +24,40 @@ import {
ApplicationStart,
NotificationsStart,
} from '@kbn/core/public';
import { Subject } from 'rxjs';
import { Subject, Subscription } from 'rxjs';
import { ObservabilityPublicStart } from '@kbn/observability-plugin/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { createBrowserHistory } from 'history';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
import { ActionExecutionContext } from '@kbn/ui-actions-plugin/public';
import type { UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { PluginContext } from '../../../context/plugin_context';
import { SloCardChartList } from './slo_overview_grid';
import { SloOverview } from './slo_overview';
import { GroupSloView } from './group_view/group_view';
import type { SloEmbeddableInput } from './types';
import { EDIT_SLO_OVERVIEW_ACTION } from '../../../ui_actions/edit_slo_overview_panel';
export const SLO_EMBEDDABLE = 'SLO_EMBEDDABLE';
interface SloEmbeddableDeps {
export interface SloEmbeddableDeps {
uiSettings: IUiSettingsClient;
http: CoreStart['http'];
i18n: CoreStart['i18n'];
theme: CoreStart['theme'];
application: ApplicationStart;
notifications: NotificationsStart;
observability: ObservabilityPublicStart;
uiActions: UiActionsStart;
}
export class SLOEmbeddable extends AbstractEmbeddable<SloEmbeddableInput, EmbeddableOutput> {
public readonly type = SLO_EMBEDDABLE;
private node?: HTMLElement;
private reloadSubject: Subject<boolean>;
private reloadGroupSubject: Subject<SloEmbeddableInput | undefined>;
private subscription: Subscription;
constructor(
private readonly deps: SloEmbeddableDeps,
@ -53,13 +66,17 @@ export class SLOEmbeddable extends AbstractEmbeddable<SloEmbeddableInput, Embedd
) {
super(initialInput, {}, parent);
this.reloadSubject = new Subject<boolean>();
this.reloadGroupSubject = new Subject<SloEmbeddableInput | undefined>();
this.setTitle(
this.input.title ||
i18n.translate('xpack.slo.sloEmbeddable.displayTitle', {
defaultMessage: 'SLO Overview',
})
);
this.subscription = this.getInput$().subscribe((input) => {
this.reloadGroupSubject.next(input);
});
}
setTitle(title: string) {
@ -75,30 +92,82 @@ export class SLOEmbeddable extends AbstractEmbeddable<SloEmbeddableInput, Embedd
this.node = node;
// required for the export feature to work
this.node.setAttribute('data-shared-item', '');
const { sloId, sloInstanceId, showAllGroupByInstances } = this.getInput();
const { sloId, sloInstanceId, showAllGroupByInstances, overviewMode, groupFilters } =
this.getInput();
const queryClient = new QueryClient();
const { observabilityRuleTypeRegistry } = this.deps.observability;
const I18nContext = this.deps.i18n.Context;
const renderOverview = () => {
if (overviewMode === 'groups') {
const groupBy = groupFilters?.groupBy ?? 'status';
const kqlQuery = groupFilters?.kqlQuery ?? '';
const groups = groupFilters?.groups ?? [];
return (
<Wrapper>
<EuiFlexGroup
justifyContent="flexEnd"
wrap
css={`
margin-bottom: 20px;
`}
>
<EuiFlexItem grow={false}>
<EuiLink
onClick={() => {
const trigger = this.deps.uiActions.getTrigger(CONTEXT_MENU_TRIGGER);
this.deps.uiActions.getAction(EDIT_SLO_OVERVIEW_ACTION).execute({
trigger,
embeddable: this,
} as ActionExecutionContext);
}}
data-test-subj="o11ySloAlertsWrapperSlOsIncludedLink"
>
{i18n.translate('xpack.slo.overviewEmbeddable.editCriteriaLabel', {
defaultMessage: 'Edit criteria',
})}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexItem grow={false}>
<GroupSloView
sloView="cardView"
groupBy={groupBy}
groups={groups}
kqlQuery={kqlQuery}
filters={groupFilters?.filters}
reloadGroupSubject={this.reloadGroupSubject}
/>
</EuiFlexItem>
</Wrapper>
);
} else {
return (
<SloOverview
onRenderComplete={() => this.onRenderComplete()}
sloId={sloId}
sloInstanceId={sloInstanceId}
reloadSubject={this.reloadSubject}
showAllGroupByInstances={showAllGroupByInstances}
/>
);
}
};
ReactDOM.render(
<I18nContext>
<Router history={createBrowserHistory()}>
<EuiThemeProvider darkMode={true}>
<KibanaThemeProvider theme={this.deps.theme}>
<KibanaContextProvider services={this.deps}>
<QueryClientProvider client={queryClient}>
{showAllGroupByInstances ? (
<SloCardChartList sloId={sloId!} />
) : (
<SloOverview
onRenderComplete={() => this.onRenderComplete()}
sloId={sloId}
sloInstanceId={sloInstanceId}
reloadSubject={this.reloadSubject}
showAllGroupByInstances={showAllGroupByInstances}
/>
)}
</QueryClientProvider>
<PluginContext.Provider value={{ observabilityRuleTypeRegistry }}>
<QueryClientProvider client={queryClient}>
{showAllGroupByInstances ? (
<SloCardChartList sloId={sloId!} />
) : (
renderOverview()
)}
</QueryClientProvider>
</PluginContext.Provider>
</KibanaContextProvider>
</KibanaThemeProvider>
</EuiThemeProvider>
@ -108,14 +177,34 @@ export class SLOEmbeddable extends AbstractEmbeddable<SloEmbeddableInput, Embedd
);
}
public getSloOverviewConfig() {
return this.getInput();
}
public updateSloOverviewConfig(next: SloEmbeddableInput) {
this.updateInput(next);
}
public reload() {
this.reloadSubject.next(true);
this.reloadGroupSubject?.next(undefined);
}
public destroy() {
super.destroy();
this.subscription.unsubscribe();
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}
}
const Wrapper = styled.div`
width: 100%;
padding: 5px 15px;
overflow: scroll;
.euiAccordion__buttonContent {
min-width: 100px;
}
`;

View file

@ -57,7 +57,7 @@ export class SloOverviewEmbeddableFactoryDefinition
SloEmbeddableInput,
unknown
>['getPanelPlacementSettings'] = (input) => {
if (input.showAllGroupByInstances) {
if (input.showAllGroupByInstances || input.groupFilters) {
return { width: 24, height: 8 };
}
return { width: 12, height: 8 };

View file

@ -19,14 +19,14 @@ import { SloCardItemBadges } from '../../../pages/slos/components/card_view/slo_
import { SloCardChart } from '../../../pages/slos/components/card_view/slo_card_item';
import { useFetchSloDetails } from '../../../hooks/use_fetch_slo_details';
import { EmbeddableSloProps } from './types';
import { SingleSloProps } from './types';
export function SloOverview({
sloId,
sloInstanceId,
onRenderComplete,
reloadSubject,
}: EmbeddableSloProps) {
}: SingleSloProps) {
const [lastRefreshTime, setLastRefreshTime] = useState<number | undefined>(undefined);
useEffect(() => {

View file

@ -6,13 +6,44 @@
*/
import { EmbeddableInput } from '@kbn/embeddable-plugin/public';
import { Subject } from 'rxjs';
import { Filter } from '@kbn/es-query';
export interface EmbeddableSloProps {
sloId: string | undefined;
sloInstanceId: string | undefined;
reloadSubject?: Subject<boolean>;
onRenderComplete?: () => void;
showAllGroupByInstances?: boolean;
export type OverviewMode = 'single' | 'groups';
export type GroupBy = 'slo.tags' | 'status' | 'slo.indicator.type';
export interface GroupFilters {
groupBy: GroupBy;
groups?: string[];
filters?: Filter[];
kqlQuery?: string;
}
export type SloEmbeddableInput = EmbeddableInput & EmbeddableSloProps;
export type SingleSloProps = EmbeddableSloProps & {
sloId: string | undefined;
sloInstanceId: string | undefined;
showAllGroupByInstances?: boolean;
};
export type GroupSloProps = EmbeddableSloProps & {
groupFilters: GroupFilters;
};
export interface EmbeddableSloProps {
reloadSubject?: Subject<boolean>;
onRenderComplete?: () => void;
overviewMode?: OverviewMode;
}
export type SloEmbeddableInput = EmbeddableInput & Partial<GroupSloProps> & Partial<SingleSloProps>;
export interface HasSloOverviewConfig {
getSloOverviewConfig: () => SloEmbeddableInput;
updateSloOverviewConfig: (next: SloEmbeddableInput) => void;
}
export const apiHasSloOverviewConfig = (api: unknown | null): api is HasSloOverviewConfig => {
return Boolean(
api &&
typeof (api as HasSloOverviewConfig).getSloOverviewConfig === 'function' &&
typeof (api as HasSloOverviewConfig).updateSloOverviewConfig === 'function'
);
};

View file

@ -24,6 +24,7 @@ interface SloGroupListFilter {
kqlQuery: string;
filters: string;
lastRefresh?: number;
groupsFilter?: string[];
}
export const sloKeys = {

View file

@ -4,7 +4,12 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useQuery } from '@tanstack/react-query';
import {
useQuery,
RefetchOptions,
QueryObserverResult,
RefetchQueryFilters,
} from '@tanstack/react-query';
import { i18n } from '@kbn/i18n';
import { buildQueryFromFilters, Filter } from '@kbn/es-query';
import { useMemo } from 'react';
@ -20,6 +25,7 @@ interface SLOGroupsParams {
page?: number;
perPage?: number;
groupBy?: string;
groupsFilter?: string[];
kqlQuery?: string;
tagsFilter?: SearchState['tagsFilter'];
statusFilter?: SearchState['statusFilter'];
@ -33,12 +39,16 @@ interface UseFetchSloGroupsResponse {
isSuccess: boolean;
isError: boolean;
data: FindSLOGroupsResponse | undefined;
refetch: <TPageData>(
options?: (RefetchOptions & RefetchQueryFilters<TPageData>) | undefined
) => Promise<QueryObserverResult<FindSLOGroupsResponse | undefined, unknown>>;
}
export function useFetchSloGroups({
page = 1,
perPage = DEFAULT_SLO_GROUPS_PAGE_SIZE,
groupBy = 'ungrouped',
groupsFilter = [],
kqlQuery = '',
tagsFilter,
statusFilter,
@ -73,9 +83,16 @@ export function useFetchSloGroups({
return '';
}
}, [filterDSL, tagsFilter, statusFilter, dataView]);
const { data, isLoading, isSuccess, isError, isRefetching } = useQuery({
queryKey: sloKeys.group({ page, perPage, groupBy, kqlQuery, filters, lastRefresh }),
const { data, isLoading, isSuccess, isError, isRefetching, refetch } = useQuery({
queryKey: sloKeys.group({
page,
perPage,
groupBy,
groupsFilter,
kqlQuery,
filters,
lastRefresh,
}),
queryFn: async ({ signal }) => {
const response = await http.get<FindSLOGroupsResponse>(
'/internal/api/observability/slos/_groups',
@ -84,6 +101,7 @@ export function useFetchSloGroups({
...(page && { page }),
...(perPage && { perPage }),
...(groupBy && { groupBy }),
...(groupsFilter && { groupsFilter }),
...(kqlQuery && { kqlQuery }),
...(filters && { filters }),
},
@ -115,5 +133,6 @@ export function useFetchSloGroups({
isSuccess,
isError,
isRefetching,
refetch,
};
}

View file

@ -7,7 +7,8 @@
import { useContext } from 'react';
import { PluginContext } from '../context/plugin_context';
import type { PluginContextValue } from '../context/plugin_context';
export function usePluginContext() {
return useContext(PluginContext);
return useContext(PluginContext) as PluginContextValue;
}

View file

@ -115,7 +115,7 @@ const mockKibana = () => {
},
executionContext: {
get: () => ({
name: 'observability-overview',
name: 'slo',
}),
},
},

View file

@ -11,7 +11,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiLink,
EuiPanel,
EuiHorizontalRule,
EuiSpacer,
EuiTablePagination,
EuiText,
@ -35,13 +35,13 @@ import { SLOView } from '../toggle_slo_view';
interface Props {
group: string;
kqlQuery: string;
kqlQuery?: string;
sloView: SLOView;
sort: string;
direction: SortDirection;
sort?: string;
direction?: SortDirection;
groupBy: string;
summary: GroupSummary;
filters: Filter[];
summary?: GroupSummary;
filters?: Filter[];
}
export function GroupListView({
@ -93,121 +93,119 @@ export function GroupListView({
setPage(pageNumber);
};
const worstSLI = useSloFormattedSLIValue(summary.worst.sliValue);
const worstSLI = useSloFormattedSLIValue(summary?.worst.sliValue);
return (
<>
<EuiPanel hasBorder={true} data-test-subj="sloGroupViewPanel">
<EuiFlexGroup>
<EuiFlexItem>
<MemoEuiAccordion
forceState={accordionState}
onToggle={onToggle}
buttonContent={
<EuiFlexGroup alignItems="center" responsive={false}>
<EuiFlexGroup data-test-subj="sloGroupViewPanel">
<EuiFlexItem>
<MemoEuiAccordion
forceState={accordionState}
onToggle={onToggle}
buttonContent={
<EuiFlexGroup alignItems="center" responsive={false} gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h3>{groupName}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color="subdued">({summary?.total})</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
}
extraAction={
<EuiFlexGroup responsive={false} alignItems="center">
{summary!.violated > 0 && (
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<h3>{groupName}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color="subdued">({summary.total})</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
}
extraAction={
<EuiFlexGroup alignItems="center">
{summary.violated > 0 && (
<EuiFlexItem grow={false}>
<EuiBadge color="danger">
{i18n.translate('xpack.slo.group.totalViolated', {
defaultMessage: '{total} Violated',
values: {
total: summary.violated,
},
})}
</EuiBadge>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiBadge color={'success'}>
{i18n.translate('xpack.slo.group.totalHealthy', {
defaultMessage: '{total} Healthy',
<EuiBadge color="danger">
{i18n.translate('xpack.slo.group.totalViolated', {
defaultMessage: '{total} Violated',
values: {
total: summary.healthy,
total: summary?.violated,
},
})}
</EuiBadge>
</EuiFlexItem>
<EuiFlexItem>
<EuiToolTip
content={
<>
<EuiText size="s">
{i18n.translate('xpack.slo.group.totalSloViolatedTooltip', {
defaultMessage: 'SLO: {name}',
values: {
name: summary.worst.slo?.name,
},
})}
</EuiText>
<EuiText size="s">
{i18n.translate('xpack.slo.group.totalSloViolatedTooltip.instance', {
defaultMessage: 'Instance: {instance}',
values: {
instance: summary.worst.slo?.instanceId,
},
})}
</EuiText>
</>
}
)}
<EuiFlexItem grow={false}>
<EuiBadge color={'success'}>
{i18n.translate('xpack.slo.group.totalHealthy', {
defaultMessage: '{total} Healthy',
values: {
total: summary?.healthy,
},
})}
</EuiBadge>
</EuiFlexItem>
<EuiFlexItem>
<EuiToolTip
content={
<>
<EuiText size="s">
{i18n.translate('xpack.slo.group.totalSloViolatedTooltip', {
defaultMessage: 'SLO: {name}',
values: {
name: summary?.worst.slo?.name,
},
})}
</EuiText>
<EuiText size="s">
{i18n.translate('xpack.slo.group.totalSloViolatedTooltip.instance', {
defaultMessage: 'Instance: {instance}',
values: {
instance: summary?.worst.slo?.instanceId,
},
})}
</EuiText>
</>
}
>
<EuiLink
data-test-subj="o11yGroupListViewLink"
href={basePath.prepend(
paths.sloDetails(summary!.worst.slo?.id, summary!.worst.slo?.instanceId)
)}
>
<EuiLink
data-test-subj="o11yGroupListViewLink"
href={basePath.prepend(
paths.sloDetails(summary.worst.slo?.id, summary.worst.slo?.instanceId)
)}
{i18n.translate('xpack.slo.group.worstPerforming', {
defaultMessage: 'Worst performing: ',
})}
<EuiTextColor
color={summary?.worst.status !== 'HEALTHY' ? 'danger' : undefined}
>
{i18n.translate('xpack.slo.group.worstPerforming', {
defaultMessage: 'Worst performing: ',
})}
<EuiTextColor
color={summary.worst.status !== 'HEALTHY' ? 'danger' : undefined}
>
<strong>{worstSLI}</strong>
</EuiTextColor>
</EuiLink>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
}
id={group}
initialIsOpen={false}
>
{isAccordionOpen && (
<>
<EuiSpacer size="m" />
<SlosView
sloList={results}
loading={isLoading || isRefetching}
error={isError}
sloView={sloView}
/>
<EuiSpacer size="m" />
<EuiTablePagination
pageCount={Math.ceil(total / itemsPerPage)}
activePage={page}
onChangePage={handlePageClick}
itemsPerPage={itemsPerPage}
onChangeItemsPerPage={(perPage) => setItemsPerPage(perPage)}
/>
</>
)}
</MemoEuiAccordion>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
<EuiSpacer size="m" />
<strong>{worstSLI}</strong>
</EuiTextColor>
</EuiLink>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
}
id={group}
initialIsOpen={false}
>
{isAccordionOpen && (
<>
<EuiSpacer size="m" />
<SlosView
sloList={results}
loading={isLoading || isRefetching}
error={isError}
sloView={sloView}
/>
<EuiSpacer size="m" />
<EuiTablePagination
pageCount={Math.ceil(total / itemsPerPage)}
activePage={page}
onChangePage={handlePageClick}
itemsPerPage={itemsPerPage}
onChangeItemsPerPage={(perPage) => setItemsPerPage(perPage)}
/>
</>
)}
</MemoEuiAccordion>
</EuiFlexItem>
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
</>
);
}

View file

@ -46,6 +46,8 @@ describe('Group View', () => {
useFetchSloGroupsMock.mockReturnValue({
isError: true,
isLoading: false,
isRefetching: false,
refetch: jest.fn(),
});
const { queryByTestId, getByTestId } = render(
<GroupView groupBy="slo.tags" kqlQuery="" sloView="cardView" sort="status" direction="desc" />
@ -65,6 +67,7 @@ describe('Group View', () => {
total: 0,
results: [],
},
refetch: jest.fn(),
});
const { queryByTestId, getByTestId } = render(
@ -78,6 +81,7 @@ describe('Group View', () => {
it('should show loading indicator', async () => {
useFetchSloGroupsMock.mockReturnValue({
isLoading: true,
refetch: jest.fn(),
});
const { queryByTestId, getByTestId } = render(
@ -115,6 +119,7 @@ describe('Group View', () => {
},
],
},
refetch: jest.fn(),
});
const { queryAllByTestId, getByTestId } = render(
<GroupView
@ -163,6 +168,7 @@ describe('Group View', () => {
},
],
},
refetch: jest.fn(),
});
const { queryAllByTestId } = render(

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import { EuiEmptyPrompt, EuiFlexItem, EuiLoadingSpinner, EuiTablePagination } from '@elastic/eui';
import React from 'react';
import React, { useEffect } from 'react';
import { Filter } from '@kbn/es-query';
import { useFetchSloGroups } from '../../../../hooks/use_fetch_slo_groups';
import { useUrlSearchState } from '../../hooks/use_url_search_state';
import type { SortDirection } from '../slo_list_search_bar';
@ -16,17 +17,28 @@ import { GroupListView } from './group_list_view';
interface Props {
groupBy: string;
kqlQuery: string;
kqlQuery?: string;
sloView: SLOView;
sort: string;
direction: SortDirection;
sort?: string;
direction?: SortDirection;
filters?: Filter[];
lastRefreshTime?: number;
groupsFilter?: string[];
}
export function GroupView({ kqlQuery, sloView, sort, direction, groupBy }: Props) {
export function GroupView({
kqlQuery,
sloView,
sort,
direction,
groupBy,
groupsFilter,
filters,
lastRefreshTime,
}: Props) {
const { state, onStateChange } = useUrlSearchState();
const { tagsFilter, statusFilter, filters, page, perPage, lastRefresh } = state;
const { data, isLoading, isError } = useFetchSloGroups({
const { tagsFilter, statusFilter, page, perPage, lastRefresh } = state;
const { data, isLoading, isError, isRefetching, refetch } = useFetchSloGroups({
perPage,
page: page + 1,
groupBy,
@ -35,13 +47,19 @@ export function GroupView({ kqlQuery, sloView, sort, direction, groupBy }: Props
statusFilter,
filters,
lastRefresh,
groupsFilter,
});
useEffect(() => {
refetch();
}, [lastRefreshTime, refetch]);
const { results = [], total = 0 } = data ?? {};
const handlePageClick = (pageNumber: number) => {
onStateChange({ page: pageNumber });
};
if (isLoading) {
if (isLoading || isRefetching) {
return (
<EuiEmptyPrompt
data-test-subj="sloGroupListLoading"

View file

@ -75,7 +75,10 @@ export function SloItemActions({
share: {
url: { locators },
},
executionContext,
} = useKibana().services;
const executionContextName = executionContext.get().name;
const isDashboardContext = executionContextName === 'dashboards';
const { hasWriteCapabilities } = useCapabilities();
const sloDetailsUrl = basePath.prepend(
@ -220,17 +223,22 @@ export function SloItemActions({
>
{i18n.translate('xpack.slo.item.actions.delete', { defaultMessage: 'Delete' })}
</EuiContextMenuItem>,
<EuiContextMenuItem
icon="dashboardApp"
key="addToDashboard"
onClick={handleAddToDashboard}
data-test-subj="sloActionsAddToDashboard"
>
{i18n.translate('xpack.slo.item.actions.addToDashboard', {
defaultMessage: 'Add to Dashboard',
})}
</EuiContextMenuItem>,
]}
].concat(
!isDashboardContext ? (
<EuiContextMenuItem
icon="dashboardApp"
key="addToDashboard"
onClick={handleAddToDashboard}
data-test-subj="sloActionsAddToDashboard"
>
{i18n.translate('xpack.slo.item.actions.addToDashboard', {
defaultMessage: 'Add to Dashboard',
})}
</EuiContextMenuItem>
) : (
[]
)
)}
/>
</EuiPopover>
);

View file

@ -124,6 +124,11 @@ const mockKibana = () => {
hasQuerySuggestions: () => {},
},
},
executionContext: {
get: () => ({
name: 'slo',
}),
},
},
});
};

View file

@ -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 { i18n } from '@kbn/i18n';
import { ViewMode } from '@kbn/embeddable-plugin/common';
import type { CoreSetup } from '@kbn/core/public';
import {
apiCanAccessViewMode,
apiHasType,
apiIsOfType,
EmbeddableApiContext,
getInheritedViewMode,
CanAccessViewMode,
HasType,
} from '@kbn/presentation-publishing';
import { createAction } from '@kbn/ui-actions-plugin/public';
import type { SLOEmbeddable } from '../embeddable/slo/overview/slo_embeddable';
import { SLO_EMBEDDABLE } from '../embeddable/slo/constants';
import { SloPublicPluginsStart, SloPublicStart } from '..';
import { HasSloOverviewConfig } from '../embeddable/slo/overview/types';
export const EDIT_SLO_OVERVIEW_ACTION = 'editSloOverviewPanelAction';
type EditSloOverviewPanelApi = CanAccessViewMode & HasType & HasSloOverviewConfig;
const isEditSloOverviewPanelApi = (api: unknown): api is EditSloOverviewPanelApi =>
Boolean(
apiHasType(api) &&
apiIsOfType(api, SLO_EMBEDDABLE) &&
apiCanAccessViewMode(api) &&
getInheritedViewMode(api) === ViewMode.EDIT
);
export function createEditSloOverviewPanelAction(
getStartServices: CoreSetup<SloPublicPluginsStart, SloPublicStart>['getStartServices']
) {
return createAction<EmbeddableApiContext>({
id: EDIT_SLO_OVERVIEW_ACTION,
type: EDIT_SLO_OVERVIEW_ACTION,
getIconType(): string {
return 'pencil';
},
getDisplayName: () =>
i18n.translate('xpack.slo.actions.editSloOverviewEmbeddableTitle', {
defaultMessage: 'Edit criteria',
}),
async execute({ embeddable }: EmbeddableApiContext) {
if (!embeddable) {
throw new Error('Not possible to execute an action without the embeddable context');
}
const [coreStart, pluginStart] = await getStartServices();
try {
const { resolveEmbeddableSloUserInput } = await import(
'../embeddable/slo/overview/handle_explicit_input'
);
const result = await resolveEmbeddableSloUserInput(
coreStart,
pluginStart,
(embeddable as SLOEmbeddable).getSloOverviewConfig()
);
(embeddable as SLOEmbeddable).updateInput(result);
} catch (e) {
return Promise.reject();
}
},
isCompatible: async ({ embeddable }: EmbeddableApiContext) =>
isEditSloOverviewPanelApi(embeddable) &&
embeddable.getSloOverviewConfig().overviewMode === 'groups',
});
}

View file

@ -9,6 +9,7 @@ import type { UiActionsSetup } from '@kbn/ui-actions-plugin/public';
import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
import type { CoreSetup } from '@kbn/core/public';
import { createEditSloAlertsPanelAction } from './edit_slo_alerts_panel';
import { createEditSloOverviewPanelAction } from './edit_slo_overview_panel';
import { SloPublicPluginsStart, SloPublicStart } from '..';
export function registerSloAlertsUiActions(
@ -17,6 +18,8 @@ export function registerSloAlertsUiActions(
) {
// Initialize actions
const editSloAlertsPanelAction = createEditSloAlertsPanelAction(core.getStartServices);
const editSloOverviewPanelAction = createEditSloOverviewPanelAction(core.getStartServices);
// Assign triggers
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, editSloAlertsPanelAction);
uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, editSloOverviewPanelAction);
}

View file

@ -49,16 +49,18 @@ export class FindSLOGroups {
public async execute(params: FindSLOGroupsParams): Promise<FindSLOGroupsResponse> {
const pagination = toPagination(params);
const groupBy = params.groupBy;
const groupsFilter = [params.groupsFilter ?? []].flat();
const kqlQuery = params.kqlQuery ?? '';
const filters = params.filters ?? '';
let parsedFilters: any = {};
try {
parsedFilters = JSON.parse(filters);
} catch (e) {
this.logger.error(`Failed to parse filters: ${e.message}`);
}
const hasSelectedTags = groupBy === 'slo.tags' && groupsFilter.length > 0;
const response = await typedSearch(this.esClient, {
index: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
size: 0,
@ -77,6 +79,7 @@ export class FindSLOGroups {
terms: {
field: groupBy,
size: 10000,
...(hasSelectedTags && { include: groupsFilter }),
},
aggs: {
worst: {

View file

@ -85,6 +85,12 @@
"@kbn/discover-plugin",
"@kbn/field-formats-plugin",
"@kbn/core-http-server",
"@kbn/presentation-publishing"
"@kbn/presentation-publishing",
"@kbn/test-jest-helpers",
"@kbn/core-ui-settings-browser-mocks",
"@kbn/core-i18n-browser-mocks",
"@kbn/core-theme-browser-mocks",
"@kbn/core-notifications-browser-mocks",
"@kbn/core-http-browser-mocks"
]
}