diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/converters.test.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/converters.test.ts index 7dab87cc4e5bd..20b3a8bd4ed71 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/converters.test.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/helpers/converters.test.ts @@ -103,6 +103,7 @@ describe('Converter Helpers', () => { read_failure_store: true, manage_failure_store: true, view_index_metadata: true, + create_snapshot_repository: true, }, effective_failure_store: { disabled: {}, @@ -142,6 +143,7 @@ describe('Converter Helpers', () => { read_failure_store: true, manage_failure_store: true, view_index_metadata: true, + create_snapshot_repository: true, }, effective_lifecycle: { dsl: {}, diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/base.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/base.ts index dd8fd457b455f..ee437043e7c7e 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/base.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/base.ts @@ -37,6 +37,8 @@ interface IngestStreamPrivileges { read_failure_store: boolean; // User can manage failure store information manage_failure_store: boolean; + // User can create snapshot repositories (needed for frozen phase searchable snapshots) + create_snapshot_repository: boolean; } const ingestStreamPrivilegesSchema: z.Schema = z.object({ @@ -48,6 +50,7 @@ const ingestStreamPrivilegesSchema: z.Schema = z.object( text_structure: z.boolean(), read_failure_store: z.boolean(), manage_failure_store: z.boolean(), + create_snapshot_repository: z.boolean(), }); export interface IngestBase { diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/classic.test.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/classic.test.ts index 0f474d8ed7d34..d765e7b36a8de 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/classic.test.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/classic.test.ts @@ -120,6 +120,7 @@ describe('ClassicStream', () => { text_structure: true, read_failure_store: true, manage_failure_store: true, + create_snapshot_repository: true, view_index_metadata: true, }, data_stream_exists: true, @@ -157,6 +158,7 @@ describe('ClassicStream', () => { text_structure: true, failure_store: true, view_index_metadata: true, + create_snapshot_repository: true, }, data_stream_exists: true, dashboards: [], diff --git a/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/wired.test.ts b/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/wired.test.ts index 0d6498561dfa9..9573b2bef3e57 100644 --- a/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/wired.test.ts +++ b/x-pack/platform/packages/shared/kbn-streams-schema/src/models/ingest/wired.test.ts @@ -119,6 +119,7 @@ describe('WiredStream', () => { read_failure_store: true, manage_failure_store: true, view_index_metadata: true, + create_snapshot_repository: true, }, effective_lifecycle: { dsl: {}, @@ -165,6 +166,7 @@ describe('WiredStream', () => { text_structure: true, failure_store: true, view_index_metadata: true, + create_snapshot_repository: true, }, dashboards: [], queries: [], diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/client.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/client.ts index a0daed8a02d70..ba631f9937be3 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/client.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/client.ts @@ -546,6 +546,8 @@ export class StreamsClient { REQUIRED_MANAGE_PRIVILEGES.push('monitor_text_structure'); } + const CREATE_SNAPSHOT_REPOSITORY_CLUSTER_PRIVILEGE = 'cluster:admin/repository/put'; + const REQUIRED_INDEX_PRIVILEGES = [ 'read', 'write', @@ -563,7 +565,7 @@ export class StreamsClient { const privileges = await this.dependencies.scopedClusterClient.asCurrentUser.security.hasPrivileges({ - cluster: REQUIRED_MANAGE_PRIVILEGES, + cluster: [...REQUIRED_MANAGE_PRIVILEGES, CREATE_SNAPSHOT_REPOSITORY_CLUSTER_PRIVILEGE], index: [ { names, @@ -594,6 +596,8 @@ export class StreamsClient { text_structure: isServerless ? true : privileges.cluster.monitor_text_structure, read_failure_store: names.every((name) => privileges.index[name].read_failure_store), manage_failure_store: names.every((name) => privileges.index[name].manage_failure_store), + create_snapshot_repository: + privileges.cluster[CREATE_SNAPSHOT_REPOSITORY_CLUSTER_PRIVILEGE] === true, }; } diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/lifecycle/ilm_policies.test.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/lifecycle/ilm_policies.test.ts index ed395cea882e8..45879f10b586a 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/lifecycle/ilm_policies.test.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/lifecycle/ilm_policies.test.ts @@ -7,6 +7,15 @@ import { buildPolicyUsage, normalizeIlmPhases } from './ilm_policies'; +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object'; + +const getActions = (phase: unknown): Record | undefined => { + if (!isRecord(phase)) return undefined; + const actions = phase.actions; + return isRecord(actions) ? actions : undefined; +}; + describe('lifecycle helpers', () => { describe('buildPolicyUsage', () => { it('derives data streams from backing indices', () => { @@ -67,17 +76,40 @@ describe('lifecycle helpers', () => { expect(normalizeIlmPhases(undefined)).toEqual({}); }); - it('drops undefined phases', () => { + it('normalizes ES ILM phases into Streams ILM phases', () => { const normalized = normalizeIlmPhases({ - hot: { actions: { rollover: { max_age: '1d' } } }, - warm: { min_age: '2d' }, + hot: { + actions: { + rollover: { max_age: '1d' }, + set_priority: { priority: 100 }, + }, + }, + warm: { + min_age: '2d', + actions: { readonly: {} }, + }, frozen: undefined, } as unknown as Parameters[0]); - expect(normalized).toEqual({ - hot: { actions: { rollover: { max_age: '1d' } } }, - warm: { min_age: '2d' }, + expect(normalized.hot).toMatchObject({ + name: 'hot', + size_in_bytes: 0, + rollover: { max_age: '1d' }, }); + expect(getActions(normalized.hot)).toEqual({ + rollover: { max_age: '1d' }, + set_priority: { priority: 100 }, + }); + + expect(normalized.warm).toMatchObject({ + name: 'warm', + size_in_bytes: 0, + min_age: '2d', + readonly: true, + }); + expect(getActions(normalized.warm)).toEqual({ readonly: {} }); + + expect(normalized.frozen).toBeUndefined(); }); }); }); diff --git a/x-pack/platform/plugins/shared/streams/server/lib/streams/lifecycle/ilm_policies.ts b/x-pack/platform/plugins/shared/streams/server/lib/streams/lifecycle/ilm_policies.ts index 2158eb36774a5..3ec2fb9e412e4 100644 --- a/x-pack/platform/plugins/shared/streams/server/lib/streams/lifecycle/ilm_policies.ts +++ b/x-pack/platform/plugins/shared/streams/server/lib/streams/lifecycle/ilm_policies.ts @@ -5,16 +5,22 @@ * 2.0. */ +import type { + IlmPhases as EsIlmPhases, + IlmPhase as EsIlmPhase, +} from '@elastic/elasticsearch/lib/api/types'; import type { IlmPolicy, + IlmPolicyDeletePhase, + IlmPolicyHotPhase, IlmPolicyPhase, - IlmPolicyPhases, IlmPolicyUsage, + PhaseName, } from '@kbn/streams-schema'; interface IlmPolicyEntry { policy?: { - phases?: IlmPolicyPhases; + phases?: EsIlmPhases; _meta?: Record; deprecated?: boolean; }; @@ -30,6 +36,141 @@ export interface IlmPoliciesResponse { [policyName: string]: IlmPolicyEntry; } +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object'; + +const asRecord = (value: unknown): Record => (isRecord(value) ? value : {}); + +const durationToString = (value: unknown): string | undefined => { + if (typeof value === 'string') return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (value == null) return undefined; + return String(value); +}; + +type PhaseActions = Record; +type IlmPolicyPhaseWithActions = IlmPolicyPhase & { actions: PhaseActions }; +type IlmPolicyHotPhaseWithActions = IlmPolicyHotPhase & { actions: PhaseActions }; +type IlmPolicyDeletePhaseWithActions = IlmPolicyDeletePhase & { actions: PhaseActions }; + +const toStreamsDownsample = ( + phaseName: Exclude, + phaseMinAge: string | undefined, + actions: PhaseActions +): IlmPolicyPhase['downsample'] | undefined => { + const downsampleAction = asRecord(actions.downsample); + const fixedInterval = downsampleAction.fixed_interval; + if (typeof fixedInterval !== 'string' || fixedInterval.trim() === '') { + return undefined; + } + + const after = phaseName === 'hot' ? '0ms' : phaseMinAge ?? '0ms'; + return { after, fixed_interval: fixedInterval }; +}; + +const toStreamsSearchableSnapshot = (actions: PhaseActions): string | undefined => { + const searchableSnapshotAction = asRecord(actions.searchable_snapshot); + const repo = searchableSnapshotAction.snapshot_repository; + return typeof repo === 'string' && repo.trim() !== '' ? repo : undefined; +}; + +const toStreamsHotRollover = (actions: PhaseActions): IlmPolicyHotPhase['rollover'] => { + const rolloverAction = asRecord(actions.rollover); + + // ES can send `-1` to indicate "unset" for max_age. + const maxAgeRaw = rolloverAction.max_age; + const maxAge = + typeof maxAgeRaw === 'number' && maxAgeRaw === -1 ? undefined : durationToString(maxAgeRaw); + + const rollover: IlmPolicyHotPhase['rollover'] = {}; + + const maxSize = rolloverAction.max_size; + if (typeof maxSize === 'string' || typeof maxSize === 'number') { + rollover.max_size = maxSize; + } + + const maxPrimaryShardSize = rolloverAction.max_primary_shard_size; + if (typeof maxPrimaryShardSize === 'string' || typeof maxPrimaryShardSize === 'number') { + rollover.max_primary_shard_size = maxPrimaryShardSize; + } + + if (maxAge != null) rollover.max_age = maxAge; + + const maxDocs = rolloverAction.max_docs; + if (typeof maxDocs === 'number') { + rollover.max_docs = maxDocs; + } + + const maxPrimaryShardDocs = rolloverAction.max_primary_shard_docs; + if (typeof maxPrimaryShardDocs === 'number') { + rollover.max_primary_shard_docs = maxPrimaryShardDocs; + } + + return rollover; +}; + +function toStreamsNonDeletePhase( + phaseName: 'hot', + phase: EsIlmPhase | undefined +): IlmPolicyHotPhaseWithActions | undefined; +function toStreamsNonDeletePhase( + phaseName: Exclude, + phase: EsIlmPhase | undefined +): IlmPolicyPhaseWithActions | undefined; +function toStreamsNonDeletePhase( + phaseName: Exclude, + phase: EsIlmPhase | undefined +): IlmPolicyHotPhaseWithActions | IlmPolicyPhaseWithActions | undefined { + if (!phase) return undefined; + + const actions = { ...asRecord(phase.actions) }; + const minAge = durationToString(phase.min_age); + + const readonlyEnabled = Object.prototype.hasOwnProperty.call(actions, 'readonly'); + const searchableSnapshot = toStreamsSearchableSnapshot(actions); + const downsample = toStreamsDownsample(phaseName, minAge, actions); + + const base = { + name: phaseName, + size_in_bytes: 0, + ...(minAge ? { min_age: minAge } : {}), + ...(downsample ? { downsample } : {}), + ...(readonlyEnabled ? { readonly: true } : {}), + ...(searchableSnapshot ? { searchable_snapshot: searchableSnapshot } : {}), + actions, + }; + + if (phaseName === 'hot') { + return { + ...base, + name: 'hot', + rollover: toStreamsHotRollover(actions), + }; + } + + return base; +} + +const toStreamsDeletePhase = ( + phase: EsIlmPhase | undefined +): IlmPolicyDeletePhaseWithActions | undefined => { + if (!phase) return undefined; + + const actions = { ...asRecord(phase.actions) }; + const minAge = durationToString(phase.min_age) ?? ''; + const deleteAction = asRecord(actions.delete); + const deleteSearchableSnapshot = deleteAction.delete_searchable_snapshot; + + return { + name: 'delete', + min_age: minAge, + ...(typeof deleteSearchableSnapshot === 'boolean' + ? { delete_searchable_snapshot: deleteSearchableSnapshot } + : {}), + actions, + }; +}; + export const buildPolicyUsage = ( policyEntry: IlmPolicyEntry, dataStreamByBackingIndices: Record = {} @@ -45,13 +186,24 @@ export const buildPolicyUsage = ( return { in_use_by: { data_streams: dataStreams, indices } }; }; -export const normalizeIlmPhases = (phases?: IlmPolicyPhases): IlmPolicy['phases'] => { +export const normalizeIlmPhases = (phases?: EsIlmPhases): IlmPolicy['phases'] => { if (!phases) { return {}; } - const entries = Object.entries(phases) as Array<[string, IlmPolicyPhase]>; - return Object.fromEntries( - entries.filter(([, phase]) => phase !== undefined) - ) as IlmPolicy['phases']; + const hotPhase = toStreamsNonDeletePhase('hot', phases.hot); + const warmPhase = toStreamsNonDeletePhase('warm', phases.warm); + const coldPhase = toStreamsNonDeletePhase('cold', phases.cold); + const frozenPhase = toStreamsNonDeletePhase('frozen', phases.frozen); + const deletePhase = toStreamsDeletePhase(phases.delete); + + // Keep output stable: omit missing phases entirely. + const normalized: IlmPolicy['phases'] = {}; + if (hotPhase) normalized.hot = hotPhase; + if (warmPhase) normalized.warm = warmPhase; + if (coldPhase) normalized.cold = coldPhase; + if (frozenPhase) normalized.frozen = frozenPhase; + if (deletePhase) normalized.delete = deletePhase; + + return normalized; }; diff --git a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/lifecycle/route.ts b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/lifecycle/route.ts index 94694c9a2479a..f051544377b23 100644 --- a/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/lifecycle/route.ts +++ b/x-pack/platform/plugins/shared/streams/server/routes/internal/streams/lifecycle/route.ts @@ -223,9 +223,39 @@ const lifecycleIlmPoliciesUpdateRoute = createServerRoute({ }, }); +const lifecycleSnapshotRepositoriesRoute = createServerRoute({ + endpoint: 'GET /internal/streams/lifecycle/_snapshot_repositories', + options: { + access: 'internal', + }, + security: { + authz: { + requiredPrivileges: [STREAMS_API_PRIVILEGES.read], + }, + }, + params: z.object({}), + handler: async ({ + request, + getScopedClients, + }): Promise<{ repositories: Array<{ name: string; type: string }> }> => { + const { scopedClusterClient } = await getScopedClients({ request }); + const repositoriesByName = await scopedClusterClient.asCurrentUser.snapshot.getRepository({ + name: '*', + }); + + const repositories = Object.entries(repositoriesByName).map(([name, { type }]) => ({ + name, + type: type ?? '', + })); + + return { repositories }; + }, +}); + export const internalLifecycleRoutes = { ...lifecycleStatsRoute, ...lifecycleIlmExplainRoute, ...lifecycleIlmPoliciesRoute, ...lifecycleIlmPoliciesUpdateRoute, + ...lifecycleSnapshotRepositoriesRoute, }; diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/utils.test.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/utils.test.ts index 39845f068ad02..16f1e194017a7 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/utils.test.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/schema_editor/utils.test.ts @@ -99,6 +99,7 @@ const privileges = { text_structure: true, read_failure_store: true, manage_failure_store: true, + create_snapshot_repository: true, }; const buildWiredDefinition = (): Streams.WiredStream.GetResponse => ({ diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/mocks/stream_definitions.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/mocks/stream_definitions.ts index 7c2d1cdf9598c..1aa77b78c3d86 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/mocks/stream_definitions.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/shared/mocks/stream_definitions.ts @@ -39,6 +39,7 @@ export const createMockClassicStreamDefinition = ( read_failure_store: true, manage_failure_store: true, view_index_metadata: true, + create_snapshot_repository: true, }, data_stream_exists: true, effective_lifecycle: { dsl: {} }, @@ -81,6 +82,7 @@ export const createMockWiredStreamDefinition = ( read_failure_store: true, manage_failure_store: true, view_index_metadata: true, + create_snapshot_repository: true, }, inherited_fields: { 'attributes.inherited_field': { diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/utils.test.ts b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/utils.test.ts index 1cc1ff4696a9b..b30675c81825b 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/utils.test.ts +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_enrichment/utils.test.ts @@ -218,6 +218,7 @@ describe('utils', () => { text_structure: true, read_failure_store: true, manage_failure_store: true, + create_snapshot_repository: true, }); const createWiredDefinition = (): Streams.WiredStream.GetResponse => ({ diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.test.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.test.tsx index 2af7e551352bc..bb20bef47401d 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.test.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.test.tsx @@ -11,10 +11,16 @@ import { DataLifecycleSummary } from './data_lifecycle_summary'; import { type LifecyclePhase } from './lifecycle_types'; describe('DataLifecycleSummary', () => { + const defaultProps = { + model: { + phases: [], + }, + capabilities: { canManageLifecycle: true }, + showDownsampling: true, + }; describe('Loading State', () => { it('should show skeleton when data is being fetched', () => { - const phases: LifecyclePhase[] = []; - render(); + render(); expect(screen.getByTestId('dataLifecycleSummary-title')).toBeInTheDocument(); expect(screen.getByTestId('dataLifecycleSummary-skeleton')).toBeInTheDocument(); @@ -23,8 +29,7 @@ describe('DataLifecycleSummary', () => { describe('Empty State', () => { it('should render title with no phases when phases array is empty', () => { - const phases: LifecyclePhase[] = []; - render(); + render(); expect(screen.getByTestId('dataLifecycleSummary-title')).toBeInTheDocument(); expect(screen.queryByTestId('dataLifecycleSummary-skeleton')).not.toBeInTheDocument(); @@ -62,7 +67,7 @@ describe('DataLifecycleSummary', () => { }, ]; - render(); + render(); expect(screen.getByText('0d')).toBeInTheDocument(); expect(screen.getByTestId('lifecyclePhase-hot-name')).toBeInTheDocument(); @@ -93,7 +98,7 @@ describe('DataLifecycleSummary', () => { }, ]; - render(); + render(); expect(screen.getByText('0s')).toBeInTheDocument(); }); @@ -119,7 +124,7 @@ describe('DataLifecycleSummary', () => { }, ]; - render(); + render(); expect(screen.getByTestId('lifecyclePhase-Main phase-name')).toBeInTheDocument(); expect(screen.getByTestId('lifecyclePhase-Main phase-size')).toHaveTextContent('2.0 GB'); @@ -139,7 +144,7 @@ describe('DataLifecycleSummary', () => { }, ]; - render(); + render(); expect(screen.getByTestId('lifecyclePhase-Main phase-name')).toBeInTheDocument(); expect(screen.queryByTestId('dataLifecycle-delete-icon')).not.toBeInTheDocument(); @@ -156,7 +161,7 @@ describe('DataLifecycleSummary', () => { }, ]; - render(); + render(); expect(screen.getByText('∞')).toBeInTheDocument(); }); @@ -179,7 +184,7 @@ describe('DataLifecycleSummary', () => { }, ]; - render(); + render(); expect(screen.getByTestId('downsamplingPhase-1h-label')).toBeInTheDocument(); expect(screen.getByTestId('downsamplingPhase-1h-interval')).toHaveTextContent('1h'); @@ -208,16 +213,14 @@ describe('DataLifecycleSummary', () => { }, ]; - render(); + render(); expect(screen.getByTestId('downsamplingPhase-1h-label')).toBeInTheDocument(); expect(screen.getByTestId('downsamplingPhase-1h-interval')).toHaveTextContent('1h'); expect(screen.queryByTestId('downsamplingPhase-delete-label')).not.toBeInTheDocument(); }); - }); - describe('DSL Downsampling', () => { - it('should render multiple downsampling steps in a single bar for DSL', () => { + it('should not render downsampling bar when show downsampling is false', () => { const phases: LifecyclePhase[] = [ { color: '#FF0000', @@ -235,12 +238,38 @@ describe('DataLifecycleSummary', () => { render( ); + expect(screen.queryByTestId('downsamplingBar-label')).not.toBeInTheDocument(); + expect(screen.queryByTestId('downsamplingPhase-1d-label')).not.toBeInTheDocument(); + expect(screen.queryByText('20d')).not.toBeInTheDocument(); + expect(screen.queryByText('40d')).not.toBeInTheDocument(); + }); + }); + + describe('DSL Downsampling', () => { + it('should render multiple downsampling steps in a single bar for DSL', () => { + const phases: LifecyclePhase[] = [ + { + color: '#FF0000', + name: 'hot', + label: 'hot', + size: '1.0 MB', + grow: true, + }, + ]; + + const downsampleSteps = [ + { fixed_interval: '1d', after: '20d' }, + { fixed_interval: '5d', after: '40d' }, + ]; + + render(); + expect(screen.getByTestId('downsamplingPhase-1d-label')).toBeInTheDocument(); expect(screen.getByTestId('downsamplingPhase-1d-interval')).toHaveTextContent('1d'); expect(screen.getByTestId('downsamplingPhase-5d-label')).toBeInTheDocument(); @@ -261,7 +290,7 @@ describe('DataLifecycleSummary', () => { }, ]; - render(); + render(); expect(screen.queryByTestId('downsamplingPhase-1d-label')).not.toBeInTheDocument(); }); @@ -290,11 +319,10 @@ describe('DataLifecycleSummary', () => { render( ); @@ -308,4 +336,55 @@ describe('DataLifecycleSummary', () => { expect(screen.getByTestId('lifecyclePhase-warm-removeButton')).toBeInTheDocument(); }); }); + + describe('Edit flyout open behavior', () => { + it('should navigate to phase when edit flyout is open (no popover)', () => { + const onEditPhase = jest.fn(); + const phases: LifecyclePhase[] = [ + { grow: 5, name: 'hot', label: 'hot', color: '#FF0000', min_age: '0d' }, + { grow: 3, name: 'warm', label: 'warm', color: '#FFA500', min_age: '30d' }, + ]; + + render( + + ); + + fireEvent.click(screen.getByTestId('lifecyclePhase-warm-button')); + + expect(onEditPhase).toHaveBeenCalledWith(phases[1].label); + expect(screen.queryByTestId('lifecyclePhase-warm-popoverTitle')).not.toBeInTheDocument(); + }); + + it('should navigate to downsampling step when edit flyout is open (no popover)', () => { + const onEditDownsampleStep = jest.fn(); + const phases: LifecyclePhase[] = [ + { + color: '#FF0000', + name: 'hot', + label: 'hot', + grow: 5, + downsample: { after: '0d', fixed_interval: '1h' }, + }, + ]; + + render( + + ); + + fireEvent.click(screen.getByTestId('downsamplingPhase-1h-label')); + + expect(onEditDownsampleStep).toHaveBeenCalledWith(1, 'hot'); + expect(screen.queryByTestId('downsamplingPopover-step1-title')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.tsx index affb60695cae0..a672d09fb3084 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/data_lifecycle_summary.tsx @@ -28,37 +28,71 @@ import { LifecycleBar } from './lifecycle_bar'; import { DownsamplingBar } from './downsampling_bar'; import { type LifecyclePhase } from './lifecycle_types'; -interface DataLifecycleSummaryProps { +export interface DataLifecycleSummaryModel { phases: LifecyclePhase[]; loading?: boolean; - onPhaseClick?: (phase: LifecyclePhase, index: number) => void; downsampleSteps?: DownsampleStep[]; testSubjPrefix?: string; - isIlm?: boolean; +} + +export interface DataLifecycleSummaryCapabilities { + canManageLifecycle: boolean; +} + +export interface DataLifecycleSummaryPhaseActions { + onPhaseClick?: (phase: LifecyclePhase, index: number) => void; onRemovePhase?: (phaseName: string) => void; + onEditPhase?: (phaseName: string) => void; + showPhaseActions?: boolean; +} + +export interface DataLifecycleSummaryDownsamplingActions { onRemoveDownsampleStep?: (stepNumber: number) => void; - canManageLifecycle: boolean; + onEditDownsampleStep?: (stepNumber: number, phaseName?: string) => void; +} + +export interface DataLifecycleSummaryUiState { + editedPhaseName?: string; + isEditLifecycleFlyoutOpen?: boolean; +} + +interface DataLifecycleSummaryProps { + model: DataLifecycleSummaryModel; + showDownsampling: boolean; + capabilities: DataLifecycleSummaryCapabilities; + headerActions?: React.ReactNode; + phaseActions?: DataLifecycleSummaryPhaseActions; + downsamplingActions?: DataLifecycleSummaryDownsamplingActions; + uiState?: DataLifecycleSummaryUiState; } export const DataLifecycleSummary = ({ - phases, - loading = false, - onPhaseClick, - downsampleSteps, - testSubjPrefix, - isIlm, - onRemovePhase, - onRemoveDownsampleStep, - canManageLifecycle, + model, + showDownsampling, + capabilities, + headerActions, + phaseActions, + downsamplingActions, + uiState, }: DataLifecycleSummaryProps) => { + const { phases, downsampleSteps, loading = false, testSubjPrefix } = model; + const { canManageLifecycle } = capabilities; + const { editedPhaseName, isEditLifecycleFlyoutOpen = false } = uiState ?? {}; + + const showPhaseActions = + phaseActions?.showPhaseActions ?? + Boolean(phaseActions?.onEditPhase || phaseActions?.onRemovePhase); + const isRetentionInfinite = !phases.some((p) => p.isDelete); const showSkeleton = loading && phases.length === 0; - const hasDslDownsampling = Boolean(downsampleSteps?.length); + const hasDslDownsampling = showDownsampling && Boolean(downsampleSteps?.length); const dslSegments = hasDslDownsampling && downsampleSteps ? buildDslSegments(phases, downsampleSteps) : null; const timelineSegments = dslSegments?.timelineSegments ?? buildPhaseTimelineSegments(phases); - const downsamplingSegments = buildDownsamplingSegments(phases, dslSegments); + const downsamplingSegments = showDownsampling + ? buildDownsamplingSegments(phases, dslSegments) + : null; const gridTemplateColumns = getGridTemplateColumns(timelineSegments); const phaseColumnSpans = getPhaseColumnSpans(phases, timelineSegments); @@ -82,6 +116,7 @@ export const DataLifecycleSummary = ({ + {headerActions} @@ -106,17 +141,23 @@ export const DataLifecycleSummary = ({ phases={phases} gridTemplateColumns={gridTemplateColumns} phaseColumnSpans={phaseColumnSpans} - onPhaseClick={onPhaseClick} + onPhaseClick={phaseActions?.onPhaseClick} + showPhaseActions={showPhaseActions} + onRemovePhase={phaseActions?.onRemovePhase} + onEditPhase={phaseActions?.onEditPhase} + editedPhaseName={editedPhaseName} testSubjPrefix={testSubjPrefix} - isIlm={isIlm} - onRemovePhase={onRemovePhase} canManageLifecycle={canManageLifecycle} + isEditLifecycleFlyoutOpen={isEditLifecycleFlyoutOpen} /> void; + onEditStep?: (stepNumber: number, phaseName?: string) => void; + editedPhaseName?: string; canManageLifecycle: boolean; + isEditLifecycleFlyoutOpen?: boolean; } export const DownsamplingBar = ({ segments, gridTemplateColumns, onRemoveStep, + onEditStep, + editedPhaseName, canManageLifecycle, + isEditLifecycleFlyoutOpen, }: DownsamplingBarProps) => { const { euiTheme } = useEuiTheme(); const { getDownsamplingColor } = useDownsamplingColors(); @@ -117,7 +123,7 @@ export const DownsamplingBar = ({ responsive={false} css={{ gridTemplateColumns, - paddingRight: euiTheme.size.xs, + paddingInline: euiTheme.size.xxs, boxSizing: 'border-box', }} > @@ -148,7 +154,12 @@ export const DownsamplingBar = ({ phaseName={segment.phaseName} color={getDownsamplingColor(segment.stepIndex ?? index)} onRemoveStep={onRemoveStep} + onEditStep={onEditStep} + isBeingEdited={Boolean( + editedPhaseName && segment.phaseName && segment.phaseName === editedPhaseName + )} canManageLifecycle={canManageLifecycle} + isEditLifecycleFlyoutOpen={isEditLifecycleFlyoutOpen} /> ) : segment.isDelete ? ( { expect(screen.getByTestId('downsamplingPopover-step2-title')).toBeInTheDocument(); }); + + it('should navigate to the step when edit flyout is open (no popover)', () => { + const onEditStep = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByTestId('downsamplingPhase-1h-label')); + + expect(onEditStep).toHaveBeenCalledWith(1, 'hot'); + expect(screen.queryByTestId('downsamplingPopover-step1-title')).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/downsampling_phase.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/downsampling_phase.tsx index eefadbde0d20b..225f67dff6d40 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/downsampling_phase.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/downsampling_phase.tsx @@ -28,7 +28,10 @@ interface DownsamplingPhaseProps { phaseName?: string; color?: string; onRemoveStep?: (stepNumber: number) => void; + onEditStep?: (stepNumber: number, phaseName?: string) => void; + isBeingEdited?: boolean; canManageLifecycle: boolean; + isEditLifecycleFlyoutOpen?: boolean; } export const DownsamplingPhase = ({ @@ -37,17 +40,34 @@ export const DownsamplingPhase = ({ phaseName, color, onRemoveStep, + onEditStep, + isBeingEdited = false, canManageLifecycle, + isEditLifecycleFlyoutOpen = false, }: DownsamplingPhaseProps) => { const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const intervalLabel = downsample.fixed_interval; + const handleEditStep = () => { + onEditStep?.(stepNumber, phaseName); + setIsPopoverOpen(false); + }; + const handleRemoveStep = () => { onRemoveStep?.(stepNumber); setIsPopoverOpen(false); }; + const handleClick = () => { + if (isEditLifecycleFlyoutOpen) { + // When the flyout is open, navigate to the phase tab instead of showing the popover + onEditStep?.(stepNumber, phaseName); + return; + } + setIsPopoverOpen(!isPopoverOpen); + }; + const button = ( setIsPopoverOpen(!isPopoverOpen)} + onClick={handleClick} css={getInteractivePanelStyles({ euiTheme, backgroundColor: color ?? euiTheme.colors.backgroundBasePlain, - isPopoverOpen, + isPopoverOpen: isPopoverOpen || isBeingEdited, minHeight: '30px', fullSize: true, extraStyles: { @@ -104,7 +124,7 @@ export const DownsamplingPhase = ({ return ( setIsPopoverOpen(false)} anchorPosition="upCenter" > @@ -116,23 +136,48 @@ export const DownsamplingPhase = ({ values: { stepNumber }, })} - {onRemoveStep && canManageLifecycle && ( + {canManageLifecycle && (onEditStep || onRemoveStep) && ( - + {onEditStep && ( + + + + )} + + {onRemoveStep && ( + + + )} - data-test-subj={`downsamplingPopover-step${stepNumber}-removeButton`} - onClick={handleRemoveStep} - /> + )} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_bar.test.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_bar.test.tsx index b6f5b75684f22..1a05cf7992b44 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_bar.test.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_bar.test.tsx @@ -89,7 +89,7 @@ describe('LifecycleBar', () => { phases={phases} gridTemplateColumns="5fr 3fr" phaseColumnSpans={[1, 1]} - isIlm + showPhaseActions onRemovePhase={jest.fn()} canManageLifecycle /> diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_bar.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_bar.tsx index 4bcf3668fe38c..e0940f9def8c8 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_bar.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_bar.tsx @@ -11,24 +11,30 @@ import { i18n } from '@kbn/i18n'; import type { LifecyclePhase } from './lifecycle_types'; import { LifecyclePhase as LifecyclePhaseComponent } from './lifecycle_phase'; -interface LifecycleBarProps { +export interface LifecycleBarProps { phases: LifecyclePhase[]; gridTemplateColumns: string; phaseColumnSpans: number[]; onPhaseClick?: (phase: LifecyclePhase, index: number) => void; testSubjPrefix?: string; - isIlm?: boolean; + showPhaseActions?: boolean; onRemovePhase?: (phaseName: string) => void; + onEditPhase?: (phaseName: string) => void; + editedPhaseName?: string; canManageLifecycle: boolean; + isEditLifecycleFlyoutOpen?: boolean; } const renderLifecyclePhase = ( index: number, phase: LifecyclePhase, onPhaseClick?: (phase: LifecyclePhase, index: number) => void, - isIlm?: boolean, + showPhaseActions?: boolean, onRemovePhase?: (phaseName: string) => void, + onEditPhase?: (phaseName: string) => void, + editedPhaseName?: string, canManageLifecycle?: boolean, + isEditLifecycleFlyoutOpen?: boolean, testSubjPrefix?: string ) => { const commonProps = { @@ -40,11 +46,14 @@ const renderLifecyclePhase = ( onClick: () => { onPhaseClick?.(phase, index); }, - isIlm, + showActions: showPhaseActions, minAge: phase.min_age, testSubjPrefix, onRemovePhase, + onEditPhase, + isBeingEdited: Boolean(editedPhaseName && editedPhaseName === phase.label), canManageLifecycle: canManageLifecycle ?? false, + isEditLifecycleFlyoutOpen, }; return phase.isDelete ? ( @@ -61,16 +70,19 @@ const renderLifecyclePhase = ( ); }; -export const LifecycleBar = ({ +export const LifecycleBar: React.FC = ({ phases, gridTemplateColumns, phaseColumnSpans, onPhaseClick, testSubjPrefix, - isIlm, + showPhaseActions, onRemovePhase, + onEditPhase, + editedPhaseName, canManageLifecycle, -}: LifecycleBarProps) => { + isEditLifecycleFlyoutOpen, +}) => { const { euiTheme } = useEuiTheme(); return ( @@ -119,9 +131,12 @@ export const LifecycleBar = ({ index, phase, onPhaseClick, - isIlm, + showPhaseActions, onRemovePhase, + onEditPhase, + editedPhaseName, canManageLifecycle, + isEditLifecycleFlyoutOpen, testSubjPrefix )} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase.test.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase.test.tsx index 51ba0b9caafcc..678e28a872934 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase.test.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase.test.tsx @@ -86,6 +86,28 @@ describe('LifecyclePhase', () => { // Click the button again to close (toggle) fireEvent.click(button); }); + + it('should navigate to phase when edit flyout is open (no popover)', () => { + const onEditPhase = jest.fn(); + const onClick = jest.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button')); + + expect(onEditPhase).toHaveBeenCalledWith('warm'); + expect(onClick).not.toHaveBeenCalled(); + expect(screen.queryByTestId('lifecyclePhase-warm-popoverTitle')).not.toBeInTheDocument(); + }); }); describe('Popover content', () => { @@ -203,6 +225,20 @@ describe('LifecyclePhase', () => { expect(screen.queryByTestId('lifecyclePhase-hot-searchableSnapshot')).not.toBeInTheDocument(); }); + + it('should hide size label when edit flyout is open', () => { + render( + + ); + + expect(screen.queryByTestId('lifecyclePhase-hot-size')).not.toBeInTheDocument(); + }); }); describe('Remove actions', () => { @@ -211,7 +247,7 @@ describe('LifecyclePhase', () => { { { { { { void; + onEditPhase?: (phaseName: string) => void; + isBeingEdited?: boolean; canManageLifecycle: boolean; isRemoveDisabled?: boolean; removeDisabledReason?: string; + isEditLifecycleFlyoutOpen?: boolean; } interface DeleteLifecyclePhaseProps extends BaseLifecyclePhaseProps { @@ -71,11 +74,14 @@ export const LifecyclePhase = (props: LifecyclePhaseProps) => { size, sizeInBytes, testSubjPrefix, - isIlm = false, + showActions = false, onRemovePhase, + onEditPhase, + isBeingEdited = false, canManageLifecycle, isRemoveDisabled = false, removeDisabledReason, + isEditLifecycleFlyoutOpen = false, } = props; const isDelete = props.isDelete === true; const prefix = testSubjPrefix ? `${testSubjPrefix}-` : ''; @@ -83,6 +89,11 @@ export const LifecyclePhase = (props: LifecyclePhaseProps) => { const phaseColor = isDelete ? euiTheme.colors.backgroundBaseSubdued : color; const handleClick = () => { + if (isEditLifecycleFlyoutOpen) { + // When the flyout is open, navigate to this phase instead of showing the popover + onEditPhase?.(label); + return; + } setIsPopoverOpen(!isPopoverOpen); onClick?.(); }; @@ -103,14 +114,16 @@ export const LifecyclePhase = (props: LifecyclePhaseProps) => { euiTheme={euiTheme} isDelete={isDelete} isPopoverOpen={isPopoverOpen} + isBeingEdited={isBeingEdited} label={label} onClick={handleClick} phaseColor={phaseColor} size={size} testSubjPrefix={testSubjPrefix} + isEditLifecycleFlyoutOpen={isEditLifecycleFlyoutOpen} /> } - isOpen={isPopoverOpen} + isOpen={isPopoverOpen && !isEditLifecycleFlyoutOpen} closePopover={closePopover} anchorPosition="upCenter" > @@ -147,46 +160,74 @@ export const LifecyclePhase = (props: LifecyclePhaseProps) => { )} - {isIlm && label !== 'hot' && onRemovePhase && canManageLifecycle && ( + {showActions && canManageLifecycle && (onEditPhase || onRemovePhase) && ( - {isRemoveDisabled && removeDisabledReason ? ( - - + {onEditPhase && ( + + { + closePopover(); + onEditPhase(label ?? ''); + }} + /> + + )} + + {label !== 'hot' && onRemovePhase && ( + + {isRemoveDisabled && removeDisabledReason ? ( + + + + ) : ( + { + closePopover(); + onRemovePhase(label ?? ''); + }} + /> )} - data-test-subj={`lifecyclePhase-${label}-removeButton`} - /> - - ) : ( - { - closePopover(); - onRemovePhase(label ?? ''); - }} - /> - )} + + )} + )} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase_button.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase_button.tsx index 0a4b32efcc2df..45a63226daaba 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase_button.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/common/data_lifecycle/lifecycle_phase_button.tsx @@ -16,22 +16,26 @@ interface LifecyclePhaseButtonProps { euiTheme: EuiThemeComputed; isDelete: boolean; isPopoverOpen: boolean; + isBeingEdited?: boolean; label: string; onClick: () => void; phaseColor?: string; size?: string; testSubjPrefix?: string; + isEditLifecycleFlyoutOpen?: boolean; } export const LifecyclePhaseButton = ({ euiTheme, isDelete, isPopoverOpen, + isBeingEdited = false, label, onClick, phaseColor, size, testSubjPrefix, + isEditLifecycleFlyoutOpen = false, }: LifecyclePhaseButtonProps) => { const prefix = testSubjPrefix ? `${testSubjPrefix}-` : ''; @@ -60,7 +64,7 @@ export const LifecyclePhaseButton = ({ css={getInteractivePanelStyles({ euiTheme, backgroundColor: phaseColor ?? euiTheme.colors.backgroundBaseSubdued, - isPopoverOpen, + isPopoverOpen: isPopoverOpen || isBeingEdited, minHeight: '48px', ...(isDelete ? { @@ -83,6 +87,7 @@ export const LifecyclePhaseButton = ({ {capitalize(label)} - {size && ( - - {size} - - )} + + {size && !isEditLifecycleFlyoutOpen ? size : null} + )} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.stories.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.stories.tsx index c818731f4ba06..2043434f5a193 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.stories.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.stories.tsx @@ -147,6 +147,7 @@ export const Default: Story = { onSave={(next) => { action('onSave')(next); }} + isMetricsStream /> @@ -197,6 +198,7 @@ export const PreserveMsUnits: Story = { onSave={(next) => { action('onSave')(next); }} + isMetricsStream /> @@ -248,6 +250,7 @@ export const WarmAndDeletePhases: Story = { onSave={(next) => { action('onSave')(next); }} + isMetricsStream /> @@ -292,6 +295,58 @@ export const OnlyDeletePhase: Story = { onSave={(next) => { action('onSave')(next); }} + isMetricsStream + /> + + + ); + }; + return ; + }, +}; + +export const NoMetricsStream: Story = { + render: () => { + const StoryComponent = () => { + const initialPhases: IlmPolicyPhases = { + hot: { + name: 'hot', + size_in_bytes: 0, + rollover: {}, + }, + warm: { + name: 'warm', + size_in_bytes: 0, + min_age: '1500ms', + downsample: { after: '1500ms', fixed_interval: '1500ms' }, + }, + }; + const [selectedPhase, setSelectedPhase] = useState(() => + getInitialSelectedPhase(initialPhases) + ); + + return ( + + + + action('onRefreshSearchableSnapshots')() + } + onCreateSnapshotRepository={() => action('onCreateSnapshotRepository')()} + onClose={() => { + action('onClose')(); + }} + onChange={(next) => { + action('onChange')(next); + }} + onSave={(next) => { + action('onSave')(next); + }} + isMetricsStream={false} /> @@ -394,6 +449,7 @@ export const PhaseSyncing: Story = { setPhases(next); setIsOpen(false); }} + isMetricsStream /> ) : null} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.test.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.test.tsx index fb93ee45e66ee..4d42b7140c163 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.test.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.test.tsx @@ -86,6 +86,7 @@ const renderFlyout = ( onClose={onClose} onChange={onChange} onSave={onSave} + isMetricsStream={true} onChangeDebounceMs={0} {...props} /> @@ -344,6 +345,28 @@ describe('EditIlmPhasesFlyout', () => { expect(warmPanel.getByTestId(`${DATA_TEST_SUBJ}DownsamplingIntervalValue`)).toBeVisible(); }); + it('shows a warning when downsampling is enabled but not supported', async () => { + renderFlyout( + { + initialPhases: { + hot: { name: 'hot', size_in_bytes: 0, rollover: {} }, + warm: { name: 'warm', size_in_bytes: 0, min_age: '30d' }, + }, + isMetricsStream: false, + }, + { initialSelectedPhase: 'warm' } + ); + + await tick(); + + const warmPanel = withinPhase('warm'); + fireEvent.click(warmPanel.getByTestId(`${DATA_TEST_SUBJ}DownsamplingSwitch`)); + + expect( + await screen.findByTestId(`${DATA_TEST_SUBJ}DownsamplingNotSupportedCallout-warm`) + ).toBeInTheDocument(); + }); + it('defaults warm downsample interval to 2x the previous enabled downsample interval', async () => { const onChange = jest.fn(); renderFlyout({ diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.tsx index 410c69824e292..c87d5489d9f61 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/edit_ilm_phases_flyout.tsx @@ -47,6 +47,7 @@ export const EditIlmPhasesFlyout = ({ onClose, onChangeDebounceMs = 250, isSaving, + isMetricsStream, canCreateRepository = false, searchableSnapshotRepositories = [], isLoadingSearchableSnapshotRepositories, @@ -375,6 +376,7 @@ export const EditIlmPhasesFlyout = ({ isLoadingSearchableSnapshotRepositories={isLoadingSearchableSnapshotRepositories} onRefreshSearchableSnapshotRepositories={onRefreshSearchableSnapshotRepositories} onCreateSnapshotRepository={onCreateSnapshotRepository} + isMetricsStream={isMetricsStream} /> ))} diff --git a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/sections/downsample_field_section.tsx b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/sections/downsample_field_section.tsx index 5da1621de8a8e..c6d89fef0c6fb 100644 --- a/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/sections/downsample_field_section.tsx +++ b/x-pack/platform/plugins/shared/streams_app/public/components/data_management/stream_detail_lifecycle/downsampling/edit_ilm_phases_flyout/sections/downsample_field_section.tsx @@ -16,6 +16,7 @@ import { EuiIconTip, EuiSwitch, EuiTitle, + EuiCallOut, useGeneratedHtmlId, } from '@elastic/eui'; import type { DownsamplePhase, IlmPhasesFlyoutFormInternal } from '../form'; @@ -28,12 +29,14 @@ export interface DownsampleFieldSectionProps { form: FormHook; phaseName: DownsamplePhase; dataTestSubj: string; + isMetricsStream: boolean; } export const DownsampleFieldSection = ({ form, phaseName, dataTestSubj, + isMetricsStream, }: DownsampleFieldSectionProps) => { const enabledPath = `_meta.${phaseName}.downsampleEnabled`; const intervalValuePath = `_meta.${phaseName}.downsample.fixedIntervalValue`; @@ -166,6 +169,23 @@ export const DownsampleFieldSection = ({ + {!isMetricsStream && isEnabled && ( + + {i18n.translate('xpack.streams.editIlmPhasesFlyout.downsamplingNotSupportedBody', { + defaultMessage: + "Downsampling only works for time series streams. Configuring these settings won't effect how this stream's data is stored.", + })} + + )} +