[streams] show inheriting streams/inherited retention when updating lifecycle (#210055)

When updating a wired streams lifecycle, the new configuration have side
effects that are not obvious.

This change surfaces:
- the child streams that will inherit the new configuration and thus be
updated, if any
- from which parent we inherit the new configuration when _resetting to
default_


The change also includes a refactor of the retention input validation as
a https://github.com/elastic/kibana/pull/208461 follow up

-----
![Screenshot 2025-02-06 at 17 27
10](https://github.com/user-attachments/assets/378421f6-2cd5-4aea-8f4b-c53acdb794c4)

![Screenshot 2025-02-06 at 17 26
26](https://github.com/user-attachments/assets/64e5b165-92f4-484c-99bc-a1375edab771)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Kevin Lacabane 2025-02-10 15:46:12 +01:00 committed by GitHub
parent 72265c026b
commit f947bd320d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 243 additions and 63 deletions

View file

@ -7,5 +7,6 @@
export * from './type_guards';
export * from './hierarchy';
export * from './lifecycle';
export * from './condition_fields';
export * from './condition_to_query_dsl';

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { WiredStreamDefinition } from '@kbn/streams-schema';
import { WiredStreamDefinition } from '../models/ingest/base';
import { findInheritedLifecycle, findInheritingStreams } from './lifecycle';
describe('Lifecycle helpers', () => {

View file

@ -5,25 +5,20 @@
* 2.0.
*/
import { WiredStreamDefinition } from '../models/ingest/base';
import {
WiredIngestStreamEffectiveLifecycle,
WiredStreamDefinition,
getSegments,
isChildOf,
isDescendantOf,
isInheritLifecycle,
} from '@kbn/streams-schema';
import { orderBy } from 'lodash';
WiredIngestStreamEffectiveLifecycle,
} from '../models/ingest/lifecycle';
import { isDescendantOf, isChildOf, getSegments } from './hierarchy';
export function findInheritedLifecycle(
definition: WiredStreamDefinition,
ancestors: WiredStreamDefinition[]
): WiredIngestStreamEffectiveLifecycle {
const originDefinition = orderBy(
[...ancestors, definition],
(parent) => getSegments(parent.name).length,
'asc'
).findLast(({ ingest }) => !isInheritLifecycle(ingest.lifecycle));
const originDefinition = [...ancestors, definition]
.sort((a, b) => getSegments(a.name).length - getSegments(b.name).length)
.findLast(({ ingest }) => !isInheritLifecycle(ingest.lifecycle));
if (!originDefinition) {
throw new Error('Unable to find inherited lifecycle');

View file

@ -35,6 +35,8 @@ import {
isUnwiredStreamDefinition,
isWiredStreamDefinition,
streamDefinitionSchema,
findInheritedLifecycle,
findInheritingStreams,
} from '@kbn/streams-schema';
import { cloneDeep, keyBy, omit, orderBy } from 'lodash';
import { AssetClient } from './assets/asset_client';
@ -63,7 +65,6 @@ import { updateDataStreamsLifecycle } from './data_streams/manage_data_streams';
import { DefinitionNotFoundError } from './errors/definition_not_found_error';
import { MalformedStreamIdError } from './errors/malformed_stream_id_error';
import { SecurityError } from './errors/security_error';
import { findInheritedLifecycle, findInheritingStreams } from './helpers/lifecycle';
import { NameTakenError } from './errors/name_taken_error';
import { MalformedStreamError } from './errors/malformed_stream_error';

View file

@ -9,6 +9,7 @@ import {
InheritedFieldDefinition,
StreamGetResponse,
WiredStreamGetResponse,
findInheritedLifecycle,
isGroupStreamDefinition,
isUnwiredStreamDefinition,
} from '@kbn/streams-schema';
@ -19,7 +20,6 @@ import {
getDataStreamLifecycle,
getUnmanagedElasticsearchAssets,
} from '../../../lib/streams/stream_crud';
import { findInheritedLifecycle } from '../../../lib/streams/helpers/lifecycle';
export async function readStream({
name,

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
IlmLocatorParams,
Phases,
@ -16,8 +16,16 @@ import {
IngestStreamGetResponse,
IngestStreamLifecycle,
StreamGetResponse,
UnwiredStreamGetResponse,
WiredStreamGetResponse,
getAncestors,
isIlmLifecycle,
isUnwiredStreamGetResponse,
isWiredStreamDefinition,
isWiredStreamGetResponse,
findInheritedLifecycle,
findInheritingStreams,
isDslLifecycle,
} from '@kbn/streams-schema';
import {
EuiButton,
@ -25,11 +33,12 @@ import {
EuiCallOut,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiFieldNumber,
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiHighlight,
EuiLink,
EuiLoadingSpinner,
EuiModal,
EuiModalBody,
EuiModalFooter,
@ -46,6 +55,8 @@ import {
import { i18n } from '@kbn/i18n';
import { useBoolean } from '@kbn/react-hooks';
import useToggle from 'react-use/lib/useToggle';
import { useStreamsAppRouter } from '../../hooks/use_streams_app_router';
import { useWiredStreams } from '../../hooks/use_wired_streams';
export type LifecycleEditAction = 'none' | 'dsl' | 'ilm' | 'inherit';
@ -77,6 +88,16 @@ export function EditLifecycleModal({
return <InheritModal {...options} />;
}
const isInvalidRetention = (value: string) => {
const num = Number(value);
return isNaN(num) || num < 1 || num % 1 > 0;
};
const parseRetentionDuration = (value: string = '') => {
const result = /(\d+)([d|m|s|h])/.exec(value);
return { value: result?.[1], unit: result?.[2] };
};
function DslModal({ closeModal, definition, updateInProgress, updateLifecycle }: ModalOptions) {
const timeUnits = [
{ name: 'Days', value: 'd' },
@ -85,10 +106,24 @@ function DslModal({ closeModal, definition, updateInProgress, updateLifecycle }:
{ name: 'Seconds', value: 's' },
];
const [selectedUnit, setSelectedUnit] = useState(timeUnits[0]);
const [retentionValue, setRetentionValue] = useState(1);
const [noRetention, toggleNoRetention] = useToggle(false);
const existingRetention = isDslLifecycle(definition.stream.ingest.lifecycle)
? parseRetentionDuration(definition.stream.ingest.lifecycle.dsl.data_retention)
: undefined;
const [selectedUnit, setSelectedUnit] = useState(
(existingRetention && timeUnits.find((unit) => unit.value === existingRetention.unit)) ||
timeUnits[0]
);
const [retentionValue, setRetentionValue] = useState(
(existingRetention && existingRetention.value) || '1'
);
const [noRetention, toggleNoRetention] = useToggle(
Boolean(existingRetention && !existingRetention.value)
);
const [showUnitMenu, { on: openUnitMenu, off: closeUnitMenu }] = useBoolean(false);
const invalidRetention = useMemo(
() => isInvalidRetention(retentionValue) && !noRetention,
[retentionValue, noRetention]
);
return (
<EuiModal onClose={closeModal}>
@ -105,20 +140,13 @@ function DslModal({ closeModal, definition, updateInProgress, updateLifecycle }:
defaultMessage: 'Specify a custom data retention period for this stream.',
})}
<EuiSpacer />
<EuiFieldNumber
data-test-subj="streamsAppDslModalFieldNumber"
<EuiFieldText
data-test-subj="streamsAppDslModalDaysField"
value={retentionValue}
onChange={(e) => {
const valueAsNumber = e.target.valueAsNumber;
if (isNaN(valueAsNumber) || valueAsNumber < 1) {
setRetentionValue(1);
} else {
setRetentionValue(valueAsNumber);
}
}}
min={1}
onChange={(e) => setRetentionValue(e.target.value)}
disabled={noRetention}
fullWidth
isInvalid={invalidRetention}
append={
<EuiPopover
isOpen={showUnitMenu}
@ -155,6 +183,16 @@ function DslModal({ closeModal, definition, updateInProgress, updateLifecycle }:
</EuiPopover>
}
/>
{invalidRetention ? (
<>
<EuiSpacer size="xs" />
<EuiText color="danger" size="xs">
{i18n.translate('xpack.streams.streamDetailLifecycle.invalidRetentionValue', {
defaultMessage: 'A positive integer is required',
})}
</EuiText>
</>
) : null}
<EuiSpacer />
<EuiSwitch
label={i18n.translate('xpack.streams.streamDetailLifecycle.keepDataIndefinitely', {
@ -170,10 +208,13 @@ function DslModal({ closeModal, definition, updateInProgress, updateLifecycle }:
definition={definition}
confirmationLabel="Save"
closeModal={closeModal}
confirmationIsDisabled={invalidRetention}
onConfirm={() => {
updateLifecycle({
dsl: {
data_retention: noRetention ? undefined : `${retentionValue}${selectedUnit.value}`,
data_retention: noRetention
? undefined
: `${Number(retentionValue)}${selectedUnit.value}`,
},
});
}}
@ -341,7 +382,31 @@ function IlmModal({
);
}
function InheritModal({ definition, closeModal, updateInProgress, updateLifecycle }: ModalOptions) {
function InheritModal({ definition, ...options }: ModalOptions) {
if (isWiredStreamGetResponse(definition)) {
return <InheritModalWired definition={definition} {...options} />;
} else if (isUnwiredStreamGetResponse(definition)) {
return <InheritModalUnwired definition={definition} {...options} />;
}
}
function InheritModalWired({
definition,
closeModal,
updateInProgress,
updateLifecycle,
}: ModalOptions & { definition: WiredStreamGetResponse }) {
const { wiredStreams, isLoading: wiredStreamsLoading } = useWiredStreams();
const parents = useMemo(() => {
if (wiredStreamsLoading || !wiredStreams) {
return undefined;
}
const ancestors = getAncestors(definition.stream.name);
return wiredStreams.filter((stream) => ancestors.includes(stream.name));
}, [definition, wiredStreams, wiredStreamsLoading]);
return (
<EuiModal onClose={closeModal}>
<EuiModalHeader>
@ -353,16 +418,67 @@ function InheritModal({ definition, closeModal, updateInProgress, updateLifecycl
</EuiModalHeader>
<EuiModalBody>
{isWiredStreamGetResponse(definition)
? i18n.translate('xpack.streams.streamDetailLifecycle.defaultLifecycleWiredDesc', {
defaultMessage:
'All custom retention settings for this stream will be removed, resetting it to inherit data retention from its nearest parent.',
})
: i18n.translate('xpack.streams.streamDetailLifecycle.defaultLifecycleUnwiredDesc', {
defaultMessage:
'All custom retention settings for this stream will be removed, resetting it to use the configuration of the data stream.',
})}
<EuiSpacer />
{i18n.translate('xpack.streams.streamDetailLifecycle.defaultLifecycleWiredDesc', {
defaultMessage:
'All custom retention settings for this stream will be removed, resetting it to inherit data retention from',
})}{' '}
{wiredStreamsLoading || !parents ? (
<EuiLoadingSpinner size="s" />
) : (
<>
<LinkToStream
name={
findInheritedLifecycle(
{
...definition.stream,
ingest: { ...definition.stream.ingest, lifecycle: { inherit: {} } },
},
parents
).from
}
/>
.
</>
)}
</EuiModalBody>
<ModalFooter
definition={definition}
confirmationLabel={i18n.translate(
'xpack.streams.streamDetailLifecycle.defaultLifecycleAction',
{
defaultMessage: 'Set to default',
}
)}
closeModal={closeModal}
onConfirm={() => updateLifecycle({ inherit: {} })}
updateInProgress={updateInProgress}
/>
</EuiModal>
);
}
function InheritModalUnwired({
definition,
closeModal,
updateInProgress,
updateLifecycle,
}: ModalOptions & { definition: UnwiredStreamGetResponse }) {
return (
<EuiModal onClose={closeModal}>
<EuiModalHeader>
<EuiModalHeaderTitle>
{i18n.translate('xpack.streams.streamDetailLifecycle.defaultLifecycleTitle', {
defaultMessage: 'Set data retention to default',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
{i18n.translate('xpack.streams.streamDetailLifecycle.defaultLifecycleUnwiredDesc', {
defaultMessage:
'All custom retention settings for this stream will be removed, resetting it to use the configuration of the data stream.',
})}
</EuiModalBody>
<ModalFooter
@ -391,6 +507,17 @@ function ModalFooter({
onConfirm: () => void;
closeModal: () => void;
}) {
const { wiredStreams, isLoading: wiredStreamsLoading } = useWiredStreams();
const inheritingStreams = useMemo(() => {
if (!isWiredStreamGetResponse(definition) || wiredStreamsLoading || !wiredStreams) {
return [];
}
return findInheritingStreams(
definition.stream,
wiredStreams.filter(isWiredStreamDefinition)
).filter((name) => name !== definition.stream.name);
}, [definition, wiredStreams, wiredStreamsLoading]);
return (
<EuiModalFooter>
<EuiFlexGroup direction="column">
@ -413,6 +540,27 @@ function ModalFooter({
'Data retention changes will apply to dependant streams unless they already have custom retention settings in place.',
}
)}
<EuiSpacer />
{wiredStreamsLoading ? (
<EuiLoadingSpinner size="s" />
) : inheritingStreams.length > 0 ? (
<>
{i18n.translate('xpack.streams.streamDetailLifecycle.inheritingChildStreams', {
defaultMessage: 'The following child streams will be updated:',
})}{' '}
{inheritingStreams.map((name) => (
<>
{' '}
<LinkToStream name={name} />{' '}
</>
))}
.
</>
) : (
'No child streams will be updated.'
)}
</p>
</EuiCallOut>
</EuiFlexItem>
@ -450,3 +598,22 @@ function ModalFooter({
</EuiModalFooter>
);
}
function LinkToStream({ name }: { name: string }) {
const router = useStreamsAppRouter();
return (
<EuiLink
data-test-subj="streamsAppLinkToStreamLink"
target="_blank"
href={router.link('/{key}/{tab}', {
path: {
key: name,
tab: 'overview',
},
})}
>
[{name}]
</EuiLink>
);
}

View file

@ -21,6 +21,7 @@ import React, { useMemo } from 'react';
import { css } from '@emotion/css';
import {
IngestStreamGetResponse,
isDescendantOf,
isUnwiredStreamGetResponse,
isWiredStreamDefinition,
} from '@kbn/streams-schema';
@ -36,6 +37,7 @@ import { useStreamsAppRouter } from '../../hooks/use_streams_app_router';
import { useDashboardsFetch } from '../../hooks/use_dashboards_fetch';
import { DashboardsTable } from '../stream_detail_dashboards_view/dashboard_table';
import { AssetImage } from '../asset_image';
import { useWiredStreams } from '../../hooks/use_wired_streams';
const formatNumber = (val: number) => {
return Number(val).toLocaleString('en', {
@ -288,34 +290,18 @@ function QuickLinks({ definition }: { definition?: IngestStreamGetResponse }) {
}
function ChildStreamList({ definition }: { definition?: IngestStreamGetResponse }) {
const {
dependencies: {
start: {
streams: { streamsRepositoryClient },
},
},
} = useKibana();
const router = useStreamsAppRouter();
const streamsListFetch = useStreamsAppFetch(
({ signal }) => {
return streamsRepositoryClient.fetch('GET /api/streams', {
signal,
});
},
[streamsRepositoryClient]
);
const { wiredStreams } = useWiredStreams();
const childrenStreams = useMemo(() => {
if (!definition) {
return [];
}
return streamsListFetch.value?.streams.filter(
(d) => isWiredStreamDefinition(d) && d.name.startsWith(definition.stream.name)
);
}, [definition, streamsListFetch.value?.streams]);
return wiredStreams?.filter((d) => isDescendantOf(definition.stream.name, d.name));
}, [definition, wiredStreams]);
if (definition && childrenStreams?.length === 1) {
if (definition && childrenStreams?.length === 0) {
return (
<EuiFlexItem grow>
<EuiFlexGroup alignItems="center" justifyContent="center">

View file

@ -0,0 +1,30 @@
/*
* 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 { isWiredStreamDefinition } from '@kbn/streams-schema';
import { useKibana } from './use_kibana';
import { useStreamsAppFetch } from './use_streams_app_fetch';
export const useWiredStreams = () => {
const {
dependencies: {
start: {
streams: { streamsRepositoryClient },
},
},
} = useKibana();
const result = useStreamsAppFetch(
async ({ signal }) => streamsRepositoryClient.fetch('GET /api/streams', { signal }),
[streamsRepositoryClient]
);
return {
wiredStreams: result.value?.streams.filter(isWiredStreamDefinition),
isLoading: result.loading,
};
};