[Streams 🌊] Move management page + update Streams template (#217487)

## 📓 Summary

Closes https://github.com/elastic/streams-program/issues/233

This work applies changes as follows:
- Move stream management section into a standalone page
- Update routing config to support nested breadcrumbs and keep shared
stream retrieval between detail <-> management
- Replace custom panels with EuiTemplate for stream pages. Remove
previous ad-hoc components
- Improve stream detail view validation (add redirect) for unknown
stream detail sections
This commit is contained in:
Marco Antonio Ghiani 2025-04-09 10:19:11 +02:00 committed by GitHub
parent 99d6c85e02
commit 4302da3b6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 416 additions and 626 deletions

View file

@ -45044,8 +45044,6 @@
"xpack.streams.filter.startsWith": "commence par",
"xpack.streams.filter.value": "Valeur",
"xpack.streams.filter.valuePlaceholder": "Valeur",
"xpack.streams.notFound.calloutLabel": "La page actuelle est introuvable.",
"xpack.streams.notFound.callOutTitle": "Page introuvable",
"xpack.streams.resultPanel.euiDataGrid.previewLabel": "Aperçu",
"xpack.streams.routingStreamEntry.euiPanel.dragHandleLabel": "Faire glisser la poignée",
"xpack.streams.samplePreviewTable.errorTitle": "Une erreur a été rencontrée lors de la simulation de ces modifications de mappage avec un échantillon de documents",
@ -45115,7 +45113,6 @@
"xpack.streams.streamDetailView.enrichmentTab": "Extraire le champ",
"xpack.streams.streamDetailView.indexTemplate": "Modèle d'index",
"xpack.streams.streamDetailView.ingestPipeline": "Pipeline d'ingestion",
"xpack.streams.streamDetailView.managementTab": "Gestion",
"xpack.streams.streamDetailView.managementTab.bottomBar.cancel": "Annuler les modifications",
"xpack.streams.streamDetailView.managementTab.bottomBar.confirm": "Enregistrer les modifications",
"xpack.streams.streamDetailView.managementTab.enrichment.editProcessorAction": "Modifier le processeur {type}",

View file

@ -45011,8 +45011,6 @@
"xpack.streams.filter.startsWith": "で始まる",
"xpack.streams.filter.value": "値",
"xpack.streams.filter.valuePlaceholder": "値",
"xpack.streams.notFound.calloutLabel": "現在のページが見つかりません。",
"xpack.streams.notFound.callOutTitle": "ページが見つかりません",
"xpack.streams.resultPanel.euiDataGrid.previewLabel": "プレビュー",
"xpack.streams.routingStreamEntry.euiPanel.dragHandleLabel": "ハンドルをドラッグ",
"xpack.streams.samplePreviewTable.errorTitle": "ドキュメントのサンプルを使用してこれらのマッピング変更をシミュレーションしているときにエラーが発生しました",
@ -45082,7 +45080,6 @@
"xpack.streams.streamDetailView.enrichmentTab": "フィールドを抽出",
"xpack.streams.streamDetailView.indexTemplate": "インデックステンプレート",
"xpack.streams.streamDetailView.ingestPipeline": "パイプラインを投入",
"xpack.streams.streamDetailView.managementTab": "管理",
"xpack.streams.streamDetailView.managementTab.bottomBar.cancel": "変更をキャンセル",
"xpack.streams.streamDetailView.managementTab.bottomBar.confirm": "変更を保存",
"xpack.streams.streamDetailView.managementTab.enrichment.editProcessorAction": "{type}プロセッサーを編集",

View file

@ -45082,8 +45082,6 @@
"xpack.streams.filter.startsWith": "开头为",
"xpack.streams.filter.value": "值",
"xpack.streams.filter.valuePlaceholder": "值",
"xpack.streams.notFound.calloutLabel": "找不到当前页面。",
"xpack.streams.notFound.callOutTitle": "未找到页面",
"xpack.streams.resultPanel.euiDataGrid.previewLabel": "预览",
"xpack.streams.routingStreamEntry.euiPanel.dragHandleLabel": "拖动手柄",
"xpack.streams.samplePreviewTable.errorTitle": "通过文档样例模拟这些映射更改时出错",
@ -45153,7 +45151,6 @@
"xpack.streams.streamDetailView.enrichmentTab": "提取字段",
"xpack.streams.streamDetailView.indexTemplate": "索引模板",
"xpack.streams.streamDetailView.ingestPipeline": "采集管道",
"xpack.streams.streamDetailView.managementTab": "管理",
"xpack.streams.streamDetailView.managementTab.bottomBar.cancel": "取消更改",
"xpack.streams.streamDetailView.managementTab.bottomBar.confirm": "保存更改",
"xpack.streams.streamDetailView.managementTab.enrichment.editProcessorAction": "编辑 {type} 处理器",

View file

@ -21,11 +21,10 @@ export const FieldParent = ({
<EuiBadge color="hollow">
<EuiLink
data-test-subj="streamsAppFieldParentLink"
href={router.link('/{key}/{tab}/{subtab}', {
href={router.link('/{key}/management/{tab}', {
path: {
key: parent,
tab: 'management',
subtab: 'schemaEditor',
tab: 'schemaEditor',
},
})}
target="_blank"

View file

@ -102,11 +102,10 @@ export const FieldSummary = (props: FieldSummaryProps) => {
size="s"
color="primary"
iconType="popout"
href={router.link('/{key}/{tab}/{subtab}', {
href={router.link('/{key}/management/{tab}', {
path: {
key: field.parent,
tab: 'management',
subtab: 'schemaEditor',
tab: 'schemaEditor',
},
})}
>

View file

@ -15,10 +15,12 @@ import { ManagementTabs, Wrapper } from './wrapper';
import { StreamDetailLifecycle } from '../stream_detail_lifecycle';
import { UnmanagedElasticsearchAssets } from './unmanaged_elasticsearch_assets';
type ManagementSubTabs = 'enrich' | 'advanced' | 'lifecycle';
const classicStreamManagementSubTabs = ['enrich', 'advanced', 'lifecycle'] as const;
function isValidManagementSubTab(value: string): value is ManagementSubTabs {
return ['enrich', 'advanced', 'lifecycle'].includes(value);
type ClassicStreamManagementSubTab = (typeof classicStreamManagementSubTabs)[number];
function isValidManagementSubTab(value: string): value is ClassicStreamManagementSubTab {
return classicStreamManagementSubTabs.includes(value as ClassicStreamManagementSubTab);
}
export function ClassicStreamDetailManagement({
@ -29,8 +31,8 @@ export function ClassicStreamDetailManagement({
refreshDefinition: () => void;
}) {
const {
path: { key, subtab },
} = useStreamsAppParams('/{key}/{tab}/{subtab}');
path: { key, tab },
} = useStreamsAppParams('/{key}/management/{tab}');
if (!definition.data_stream_exists) {
return (
@ -89,14 +91,9 @@ export function ClassicStreamDetailManagement({
};
}
if (!isValidManagementSubTab(subtab)) {
return (
<RedirectTo
path="/{key}/{tab}/{subtab}"
params={{ path: { key, tab: 'management', subtab: 'enrich' } }}
/>
);
if (!isValidManagementSubTab(tab)) {
return <RedirectTo path="/{key}/management/{tab}" params={{ path: { key, tab: 'enrich' } }} />;
}
return <Wrapper tabs={tabs} streamId={key} subtab={subtab} />;
return <Wrapper tabs={tabs} streamId={key} tab={tab} />;
}

View file

@ -5,24 +5,17 @@
* 2.0.
*/
import React from 'react';
import { IngestStreamGetResponse, isWiredStreamGetResponse } from '@kbn/streams-schema';
import { isWiredStreamGetResponse } from '@kbn/streams-schema';
import { useStreamDetail } from '../../../hooks/use_stream_detail';
import { WiredStreamDetailManagement } from './wired';
import { ClassicStreamDetailManagement } from './classic';
export function StreamDetailManagement({
definition,
refreshDefinition,
}: {
definition: IngestStreamGetResponse;
refreshDefinition: () => void;
}) {
export function StreamDetailManagement() {
const { definition, refresh } = useStreamDetail();
if (isWiredStreamGetResponse(definition)) {
return (
<WiredStreamDetailManagement definition={definition} refreshDefinition={refreshDefinition} />
);
return <WiredStreamDetailManagement definition={definition} refreshDefinition={refresh} />;
}
return (
<ClassicStreamDetailManagement definition={definition} refreshDefinition={refreshDefinition} />
);
return <ClassicStreamDetailManagement definition={definition} refreshDefinition={refresh} />;
}

View file

@ -15,10 +15,12 @@ import { StreamDetailSchemaEditor } from '../stream_detail_schema_editor';
import { StreamDetailLifecycle } from '../stream_detail_lifecycle';
import { Wrapper } from './wrapper';
type ManagementSubTabs = 'route' | 'enrich' | 'schemaEditor' | 'lifecycle';
const wiredStreamManagementSubTabs = ['route', 'enrich', 'schemaEditor', 'lifecycle'] as const;
function isValidManagementSubTab(value: string): value is ManagementSubTabs {
return ['route', 'enrich', 'schemaEditor', 'lifecycle'].includes(value);
type WiredStreamManagementSubTab = (typeof wiredStreamManagementSubTabs)[number];
function isValidManagementSubTab(value: string): value is WiredStreamManagementSubTab {
return wiredStreamManagementSubTabs.includes(value as WiredStreamManagementSubTab);
}
export function WiredStreamDetailManagement({
@ -29,8 +31,8 @@ export function WiredStreamDetailManagement({
refreshDefinition: () => void;
}) {
const {
path: { key, subtab },
} = useStreamsAppParams('/{key}/{tab}/{subtab}');
path: { key, tab },
} = useStreamsAppParams('/{key}/management/{tab}');
const tabs = {
route: {
@ -38,7 +40,7 @@ export function WiredStreamDetailManagement({
<StreamDetailRouting definition={definition} refreshDefinition={refreshDefinition} />
),
label: i18n.translate('xpack.streams.streamDetailView.routingTab', {
defaultMessage: 'Streams Partitioning',
defaultMessage: 'Partitioning',
}),
},
enrich: {
@ -67,14 +69,9 @@ export function WiredStreamDetailManagement({
},
};
if (!isValidManagementSubTab(subtab)) {
return (
<RedirectTo
path="/{key}/{tab}/{subtab}"
params={{ path: { key, tab: 'management', subtab: 'route' } }}
/>
);
if (!isValidManagementSubTab(tab)) {
return <RedirectTo path="/{key}/management/{tab}" params={{ path: { key, tab: 'route' } }} />;
}
return <Wrapper tabs={tabs} streamId={key} subtab={subtab} />;
return <Wrapper tabs={tabs} streamId={key} tab={tab} />;
}

View file

@ -5,10 +5,14 @@
* 2.0.
*/
import { EuiButtonGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { EuiBadgeGroup, EuiFlexGroup } from '@elastic/eui';
import React from 'react';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import { isUnwiredStreamDefinition } from '@kbn/streams-schema';
import { useStreamDetail } from '../../../hooks/use_stream_detail';
import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router';
import { StreamsAppPageTemplate } from '../../streams_app_page_template';
import { ClassicStreamBadge, LifecycleBadge } from '../../stream_badges';
export type ManagementTabs = Record<
string,
@ -21,47 +25,55 @@ export type ManagementTabs = Record<
export function Wrapper({
tabs,
streamId,
subtab,
tab,
}: {
tabs: ManagementTabs;
streamId: string;
subtab: string;
tab: string;
}) {
const router = useStreamsAppRouter();
const { definition } = useStreamDetail();
const tabMap = Object.fromEntries(
Object.entries(tabs).map(([tabName, currentTab]) => {
return [
tabName,
{
href: router.link('/{key}/management/{tab}', {
path: { key: streamId, tab: tabName },
}),
label: currentTab.label,
content: currentTab.content,
},
];
})
);
return (
<EuiFlexGroup
direction="column"
gutterSize="m"
className={css`
max-width: 100%;
`}
>
{Object.keys(tabs).length > 1 && (
<EuiFlexItem grow={false}>
<EuiButtonGroup
legend="Management tabs"
idSelected={subtab}
onChange={(optionId) => {
router.push('/{key}/{tab}/{subtab}', {
path: { key: streamId, subtab: optionId, tab: 'management' },
query: {},
});
}}
options={Object.keys(tabs).map((id) => ({
id,
label: tabs[id].label,
}))}
/>
</EuiFlexItem>
)}
<EuiFlexItem
className={css`
overflow: auto;
`}
grow
>
{tabs[subtab].content}
</EuiFlexItem>
</EuiFlexGroup>
<>
<StreamsAppPageTemplate.Header
bottomBorder="extended"
pageTitle={
<EuiFlexGroup gutterSize="s" alignItems="center">
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.manageStreamTitle', {
defaultMessage: 'Manage stream {streamId}',
values: { streamId },
})}
<EuiBadgeGroup gutterSize="s">
{isUnwiredStreamDefinition(definition.stream) && <ClassicStreamBadge />}
<LifecycleBadge lifecycle={definition.effective_lifecycle} />
</EuiBadgeGroup>
</EuiFlexGroup>
}
tabs={Object.entries(tabMap).map(([tabKey, { label, href }]) => {
return {
label,
href,
isSelected: tab === tabKey,
};
})}
/>
<StreamsAppPageTemplate.Body>{tabs[tab].content}</StreamsAppPageTemplate.Body>
</>
);
}

View file

@ -130,11 +130,10 @@ export function ControlBar() {
data-test-subj="streamsAppSaveOrUpdateChildrenOpenStreamInNewTabButton"
size="s"
target="_blank"
href={router.link('/{key}/{tab}/{subtab}', {
href={router.link('/{key}/management/{tab}', {
path: {
key: routingAppState.childUnderEdit?.child.destination!,
tab: 'management',
subtab: 'route',
tab: 'route',
},
})}
>

View file

@ -21,11 +21,10 @@ export function CurrentStreamEntry({ definition }: { definition: WiredStreamGetR
text: parentId,
href: isBreadcrumbsTail
? undefined
: router.link('/{key}/{tab}/{subtab}', {
: router.link('/{key}/management/{tab}', {
path: {
key: parentId,
tab: 'management',
subtab: 'route',
tab: 'route',
},
}),
};

View file

@ -93,8 +93,8 @@ export function RoutingStreamEntry({
)}
<EuiFlexItem grow={false}>
<EuiLink
href={router.link('/{key}/{tab}/{subtab}', {
path: { key: child.destination, tab: 'management', subtab: 'route' },
href={router.link('/{key}/management/{tab}', {
path: { key: child.destination, tab: 'route' },
})}
data-test-subj="streamsAppRoutingStreamEntryButton"
>

View file

@ -1,194 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiBadge } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { css } from '@emotion/css';
import { ILM_LOCATOR_ID, IlmLocatorParams } from '@kbn/index-lifecycle-management-common-shared';
import {
IngestStreamEffectiveLifecycle,
IngestStreamGetResponse,
isDslLifecycle,
isErrorLifecycle,
isIlmLifecycle,
isUnwiredStreamDefinition,
} from '@kbn/streams-schema';
import { useStreamsAppBreadcrumbs } from '../../hooks/use_streams_app_breadcrumbs';
import { useStreamsAppRouter } from '../../hooks/use_streams_app_router';
import { EntityOverviewTabList } from '../entity_overview_tab_list';
import { LoadingPanel } from '../loading_panel';
import { StreamsAppPageBody } from '../streams_app_page_body';
import { StreamsAppPageHeader } from '../streams_app_page_header';
import { StreamsAppPageHeaderTitle } from '../streams_app_page_header/streams_app_page_header_title';
import { useKibana } from '../../hooks/use_kibana';
export interface EntityViewTab {
name: string;
label: string;
content: React.ReactElement;
background: boolean;
}
export function EntityDetailViewWithoutParams({
selectedTab,
tabs,
entity,
definition,
}: {
selectedTab: string;
tabs: EntityViewTab[];
entity: {
displayName?: string;
id: string;
};
definition?: IngestStreamGetResponse;
}) {
const router = useStreamsAppRouter();
useStreamsAppBreadcrumbs(() => {
if (!entity.displayName) {
return [];
}
return [
{
title: entity.displayName,
path: `/{key}`,
params: { path: { key: entity.id } },
} as const,
];
}, [entity.displayName, entity.id]);
if (!entity.displayName) {
return <LoadingPanel />;
}
const tabMap = Object.fromEntries(
tabs.map((tab) => {
return [
tab.name,
{
href: router.link('/{key}/{tab}', {
path: { key: entity.id, tab: tab.name },
}),
label: tab.label,
content: tab.content,
background: tab.background,
},
];
})
);
const selectedTabObject = tabMap[selectedTab];
return (
<EuiFlexGroup
direction="column"
gutterSize="none"
className={css`
max-width: 100%;
`}
>
<EuiFlexItem grow={false}>
<StreamsAppPageHeader
title={
<StreamsAppPageHeaderTitle
title={
<EuiFlexGroup gutterSize="s" alignItems="center">
{entity.displayName}
{definition && isUnwiredStreamDefinition(definition.stream) ? (
<>
{' '}
<EuiBadge>
{i18n.translate(
'xpack.streams.entityDetailViewWithoutParams.unmanagedBadgeLabel',
{ defaultMessage: 'Classic' }
)}
</EuiBadge>
</>
) : null}
{definition && <LifecycleBadge lifecycle={definition.effective_lifecycle} />}
</EuiFlexGroup>
}
/>
}
>
<EntityOverviewTabList
tabs={Object.entries(tabMap).map(([tabKey, { label, href }]) => {
return {
name: tabKey,
label,
href,
selected: selectedTab === tabKey,
};
})}
/>
</StreamsAppPageHeader>
</EuiFlexItem>
<StreamsAppPageBody background={selectedTabObject.background}>
{selectedTabObject.content}
</StreamsAppPageBody>
</EuiFlexGroup>
);
}
function LifecycleBadge({ lifecycle }: { lifecycle: IngestStreamEffectiveLifecycle }) {
const {
dependencies: {
start: { share },
},
} = useKibana();
const ilmLocator = share.url.locators.get<IlmLocatorParams>(ILM_LOCATOR_ID);
if (isIlmLifecycle(lifecycle)) {
return (
<EuiBadge color="hollow">
<EuiLink
data-test-subj="streamsAppLifecycleBadgeIlmPolicyNameLink"
color="text"
href={ilmLocator?.getRedirectUrl({
page: 'policy_edit',
policyName: lifecycle.ilm.policy,
})}
>
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.ilmBadgeLabel', {
defaultMessage: 'ILM Policy: {name}',
values: { name: lifecycle.ilm.policy },
})}
</EuiLink>
</EuiBadge>
);
}
if (isErrorLifecycle(lifecycle)) {
return (
<EuiBadge color="hollow">
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.errorBadgeLabel', {
defaultMessage: 'Error: {message}',
values: { message: lifecycle.error.message },
})}
</EuiBadge>
);
}
if (isDslLifecycle(lifecycle)) {
return (
<EuiBadge color="hollow">
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.dslBadgeLabel', {
defaultMessage: 'Retention: {retention}',
values: { retention: lifecycle.dsl.data_retention || '∞' },
})}
</EuiBadge>
);
}
return (
<EuiBadge color="hollow">
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.disabledLifecycleBadgeLabel', {
defaultMessage: 'Retention: Disabled',
})}
</EuiBadge>
);
}

View file

@ -1,30 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiText } from '@elastic/eui';
import { css } from '@emotion/css';
import React from 'react';
export function EntityDetailViewHeaderSection({
title,
children,
}: {
title: React.ReactNode;
children: React.ReactNode;
}) {
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiText
className={css`
font-weight: 600;
`}
>
{title}
</EuiText>
{children}
</EuiFlexGroup>
);
}

View file

@ -1,32 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
export function EntityOverviewTabList<
T extends { name: string; label: string; href: string; selected: boolean }
>({ tabs }: { tabs: T[] }) {
const theme = useEuiTheme().euiTheme;
return (
<EuiTabs
size="m"
className={css`
padding: 0 ${theme.size.l};
`}
>
{tabs.map((tab) => {
return (
<EuiTab key={tab.name} href={tab.href} isSelected={tab.selected}>
{tab.label}
</EuiTab>
);
})}
</EuiTabs>
);
}

View file

@ -1,24 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiCallOut } from '@elastic/eui';
import React from 'react';
import { i18n } from '@kbn/i18n';
export function NotFound() {
return (
<EuiCallOut
color="danger"
title={i18n.translate('xpack.streams.notFound.callOutTitle', {
defaultMessage: 'Page not found',
})}
>
{i18n.translate('xpack.streams.notFound.calloutLabel', {
defaultMessage: 'The current page can not be found.',
})}
</EuiCallOut>
);
}

View file

@ -0,0 +1,86 @@
/*
* 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 { EuiBadge, EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { IlmLocatorParams, ILM_LOCATOR_ID } from '@kbn/index-lifecycle-management-common-shared';
import {
IngestStreamEffectiveLifecycle,
isIlmLifecycle,
isErrorLifecycle,
isDslLifecycle,
} from '@kbn/streams-schema';
import React from 'react';
import { useKibana } from '../../hooks/use_kibana';
export function ClassicStreamBadge() {
return (
<EuiBadge>
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.unmanagedBadgeLabel', {
defaultMessage: 'Classic',
})}
</EuiBadge>
);
}
export function LifecycleBadge({ lifecycle }: { lifecycle: IngestStreamEffectiveLifecycle }) {
const {
dependencies: {
start: { share },
},
} = useKibana();
const ilmLocator = share.url.locators.get<IlmLocatorParams>(ILM_LOCATOR_ID);
if (isIlmLifecycle(lifecycle)) {
return (
<EuiBadge color="hollow">
<EuiLink
data-test-subj="streamsAppLifecycleBadgeIlmPolicyNameLink"
color="text"
href={ilmLocator?.getRedirectUrl({
page: 'policy_edit',
policyName: lifecycle.ilm.policy,
})}
>
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.ilmBadgeLabel', {
defaultMessage: 'ILM Policy: {name}',
values: { name: lifecycle.ilm.policy },
})}
</EuiLink>
</EuiBadge>
);
}
if (isErrorLifecycle(lifecycle)) {
return (
<EuiBadge color="hollow">
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.errorBadgeLabel', {
defaultMessage: 'Error: {message}',
values: { message: lifecycle.error.message },
})}
</EuiBadge>
);
}
if (isDslLifecycle(lifecycle)) {
return (
<EuiBadge color="hollow">
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.dslBadgeLabel', {
defaultMessage: 'Retention: {retention}',
values: { retention: lifecycle.dsl.data_retention || '∞' },
})}
</EuiBadge>
);
}
return (
<EuiBadge color="hollow">
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.disabledLifecycleBadgeLabel', {
defaultMessage: 'Retention: Disabled',
})}
</EuiBadge>
);
}

View file

@ -83,11 +83,10 @@ export function StreamDeleteModal({
<li key={stream}>
<EuiListGroupItem
target="_blank"
href={router.link('/{key}/{tab}/{subtab}', {
href={router.link('/{key}/management/{tab}', {
path: {
key: stream,
tab: 'management',
subtab: 'route',
tab: 'route',
},
})}
label={stream}

View file

@ -55,11 +55,10 @@ export function ChildStreamList({ definition }: { definition?: IngestStreamGetRe
<EuiButton
data-test-subj="streamsAppChildStreamListCreateChildStreamButton"
iconType="plusInCircle"
href={router.link('/{key}/{tab}/{subtab}', {
href={router.link('/{key}/management/{tab}', {
path: {
key: definition.stream.name,
tab: 'management',
subtab: 'route',
tab: 'route',
},
})}
>

View file

@ -4,87 +4,107 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { Outlet } from '@kbn/typed-react-router-config';
import React from 'react';
import { useKibana } from '../../hooks/use_kibana';
import { StreamDetailContextProvider, useStreamDetail } from '../../hooks/use_stream_detail';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiBadgeGroup, EuiButton } from '@elastic/eui';
import { IngestStreamGetResponse, isUnwiredStreamDefinition } from '@kbn/streams-schema';
import { useStreamsAppParams } from '../../hooks/use_streams_app_params';
import { StreamDetailManagement } from '../data_management/stream_detail_management';
import { EntityDetailViewWithoutParams, EntityViewTab } from '../entity_detail_view';
import { RedirectTo } from '../redirect_to';
import { StreamDetailDashboardsView } from '../stream_detail_dashboards_view';
import { StreamDetailOverview } from '../stream_detail_overview';
import { useStreamDetail } from '../../hooks/use_stream_detail';
import { ClassicStreamBadge, LifecycleBadge } from '../stream_badges';
import { StreamsAppPageTemplate } from '../streams_app_page_template';
import { StatefulStreamsAppRouter, useStreamsAppRouter } from '../../hooks/use_streams_app_router';
import { RedirectTo } from '../redirect_to';
export function StreamDetailView() {
const { streamsRepositoryClient } = useKibana().dependencies.start.streams;
const {
path: { key: name },
} = useStreamsAppParams('/{key}/{tab}', true);
return (
<StreamDetailContextProvider name={name} streamsRepositoryClient={streamsRepositoryClient}>
<Outlet />
</StreamDetailContextProvider>
);
}
export function StreamDetailViewContent() {
const params1 = useStreamsAppParams('/{key}/{tab}', true);
const params2 = useStreamsAppParams('/{key}/{tab}/{subtab}', true);
const name = params1?.path?.key || params2.path.key;
const tab = params1?.path?.tab || 'management';
const { definition, refresh } = useStreamDetail();
const entity = {
id: name,
displayName: name,
};
if (params2?.path?.subtab && tab !== 'management') {
// only management tab has subtabs
return <RedirectTo path="/{key}/{tab}" params={{ path: { tab } }} />;
}
if (!params2?.path?.subtab && tab === 'management') {
// management tab requires a subtab
return <RedirectTo path="/{key}/{tab}/{subtab}" params={{ path: { tab, subtab: 'route' } }} />;
}
const tabs: EntityViewTab[] = [
{
name: 'overview',
const getStreamDetailTabs = ({
definition,
router,
}: {
definition: IngestStreamGetResponse;
router: StatefulStreamsAppRouter;
}) =>
({
overview: {
href: router.link('/{key}/{tab}', {
path: { key: definition.stream.name, tab: 'overview' },
}),
background: false,
content: <StreamDetailOverview definition={definition} />,
label: i18n.translate('xpack.streams.streamDetailView.overviewTab', {
defaultMessage: 'Overview',
}),
background: false,
},
{
name: 'dashboards',
dashboards: {
href: router.link('/{key}/{tab}', {
path: { key: definition.stream.name, tab: 'dashboards' },
}),
background: true,
content: <StreamDetailDashboardsView definition={definition} />,
label: i18n.translate('xpack.streams.streamDetailView.dashboardsTab', {
defaultMessage: 'Dashboards',
}),
background: true,
},
{
name: 'management',
content: <StreamDetailManagement definition={definition} refreshDefinition={refresh} />,
label: i18n.translate('xpack.streams.streamDetailView.managementTab', {
defaultMessage: 'Management',
}),
background: true,
},
];
} as const);
export type StreamDetailTabs = ReturnType<typeof getStreamDetailTabs>;
export type StreamDetailTabName = keyof StreamDetailTabs;
function isValidStreamDetailTab(value: string): value is StreamDetailTabName {
return ['overview', 'dashboards'].includes(value as StreamDetailTabName);
}
export function StreamDetailView() {
const router = useStreamsAppRouter();
const { path } = useStreamsAppParams('/{key}/{tab}', true);
const { key, tab } = path;
const { definition } = useStreamDetail();
if (!isValidStreamDetailTab(tab)) {
return <RedirectTo path="/{key}/{tab}" params={{ path: { key, tab: 'overview' } }} />;
}
const tabs = getStreamDetailTabs({ definition, router });
const selectedTabObject = tabs[tab as StreamDetailTabName];
return (
<EntityDetailViewWithoutParams
tabs={tabs}
entity={entity}
definition={definition}
selectedTab={tab}
/>
<>
<StreamsAppPageTemplate.Header
bottomBorder="extended"
pageTitle={
<EuiFlexGroup gutterSize="s" alignItems="center">
{key}
<EuiBadgeGroup gutterSize="s">
{isUnwiredStreamDefinition(definition.stream) && <ClassicStreamBadge />}
<LifecycleBadge lifecycle={definition.effective_lifecycle} />
</EuiBadgeGroup>
</EuiFlexGroup>
}
tabs={Object.entries(tabs).map(([tabName, { label, href }]) => {
return {
label,
href,
isSelected: tab === tabName,
};
})}
rightSideItems={[
<EuiButton
iconType="gear"
href={router.link('/{key}/management/{tab}', {
path: { key, tab: 'route' },
})}
>
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.manageStreamLabel', {
defaultMessage: 'Manage stream',
})}
</EuiButton>,
]}
/>
<StreamsAppPageTemplate.Body color={selectedTabObject.background ? 'plain' : 'subdued'}>
{selectedTabObject.content}
</StreamsAppPageTemplate.Body>
</>
);
}

View file

@ -7,14 +7,12 @@
import React from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiBetaBadge } from '@elastic/eui';
import { EuiFlexGroup, EuiBetaBadge } from '@elastic/eui';
import { useKibana } from '../../hooks/use_kibana';
import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch';
import { StreamsAppPageHeader } from '../streams_app_page_header';
import { StreamsAppPageHeaderTitle } from '../streams_app_page_header/streams_app_page_header_title';
import { StreamsAppPageBody } from '../streams_app_page_body';
import { StreamsTreeTable } from './tree_table';
import { StreamsEmptyPrompt } from './empty_prompt';
import { StreamsAppPageTemplate } from '../streams_app_page_template';
export function StreamListView() {
const {
@ -36,45 +34,35 @@ export function StreamListView() {
);
return (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<StreamsAppPageHeader
title={
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<StreamsAppPageHeaderTitle
title={i18n.translate('xpack.streams.streamsListView.pageHeaderTitle', {
defaultMessage: 'Streams',
})}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiBetaBadge
label={i18n.translate('xpack.streams.streamsListView.betaBadgeLabel', {
defaultMessage: 'Technical Preview',
})}
tooltipContent={i18n.translate(
'xpack.streams.streamsListView.betaBadgeDescription',
{
defaultMessage:
'This functionality is experimental and not supported. It may change or be removed at any time.',
}
)}
alignment="middle"
size="s"
/>
</EuiFlexItem>
</EuiFlexGroup>
}
/>
</EuiFlexItem>
<StreamsAppPageBody background>
<>
<StreamsAppPageTemplate.Header
bottomBorder="extended"
pageTitle={
<EuiFlexGroup alignItems="center" gutterSize="m">
{i18n.translate('xpack.streams.streamsListView.pageHeaderTitle', {
defaultMessage: 'Streams',
})}
<EuiBetaBadge
label={i18n.translate('xpack.streams.streamsListView.betaBadgeLabel', {
defaultMessage: 'Technical Preview',
})}
tooltipContent={i18n.translate('xpack.streams.streamsListView.betaBadgeDescription', {
defaultMessage:
'This functionality is experimental and not supported. It may change or be removed at any time.',
})}
alignment="middle"
size="s"
/>
</EuiFlexGroup>
}
/>
<StreamsAppPageTemplate.Body grow>
{!streamsListFetch.loading && !streamsListFetch.value?.length ? (
<StreamsEmptyPrompt />
) : (
<StreamsTreeTable loading={streamsListFetch.loading} streams={streamsListFetch.value} />
)}
</StreamsAppPageBody>
</EuiFlexGroup>
</StreamsAppPageTemplate.Body>
</>
);
}

View file

@ -0,0 +1,31 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { useStreamsAppParams } from '../../hooks/use_streams_app_params';
import { StreamDetailManagement } from '../data_management/stream_detail_management';
import { useStreamsAppBreadcrumbs } from '../../hooks/use_streams_app_breadcrumbs';
export function StreamManagementView() {
const {
path: { key, tab },
} = useStreamsAppParams('/{key}/management/{tab}', true);
useStreamsAppBreadcrumbs(() => {
return [
{
title: i18n.translate('xpack.streams.streamManagementView.title', {
defaultMessage: 'Manage stream',
}),
path: `/{key}/management/{tab}`,
params: { path: { key, tab } },
} as const,
];
}, [key, tab]);
return <StreamDetailManagement />;
}

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { StreamDetailContextProvider } from '../../hooks/use_stream_detail';
import { useStreamsAppBreadcrumbs } from '../../hooks/use_streams_app_breadcrumbs';
import { useStreamsAppParams } from '../../hooks/use_streams_app_params';
import { useKibana } from '../../hooks/use_kibana';
export function StreamDetailRoot({ children }: { children: React.ReactNode }) {
const { streamsRepositoryClient } = useKibana().dependencies.start.streams;
const {
path: { key },
} = useStreamsAppParams('/{key}', true);
useStreamsAppBreadcrumbs(() => {
return [
{
title: key,
path: `/{key}`,
params: { path: { key } },
},
];
}, [key]);
return (
<StreamDetailContextProvider name={key} streamsRepositoryClient={streamsRepositoryClient}>
{children}
</StreamDetailContextProvider>
);
}

View file

@ -1,36 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiPanel, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
export function StreamsAppPageBody({
children,
background,
}: {
children: React.ReactNode;
background: boolean;
}) {
const theme = useEuiTheme().euiTheme;
return (
<EuiPanel
hasBorder={false}
hasShadow={false}
className={css`
border-top: 1px solid ${theme.colors.lightShade};
border-radius: 0px;
display: flex;
overflow-y: auto;
padding-top: ${theme.size.base};
${!background ? `background-color: transparent;` : ''}
`}
paddingSize="l"
>
{children}
</EuiPanel>
);
}

View file

@ -1,32 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiPageHeader, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/css';
import React from 'react';
export function StreamsAppPageHeader({
title,
children,
}: {
title: React.ReactNode;
children?: React.ReactNode;
}) {
const theme = useEuiTheme().euiTheme;
return (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiPageHeader
className={css`
padding: ${theme.size.l} ${theme.size.l} ${theme.size.m};
`}
>
{title}
</EuiPageHeader>
{children}
</EuiFlexGroup>
);
}

View file

@ -1,16 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiTitle } from '@elastic/eui';
import React from 'react';
export function StreamsAppPageHeaderTitle({ title }: { title: React.ReactNode }) {
return (
<EuiTitle size="l">
<h1>{title}</h1>
</EuiTitle>
);
}

View file

@ -4,9 +4,10 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { css } from '@emotion/css';
import React from 'react';
import { EuiPanel } from '@elastic/eui';
import { EuiPageSectionProps, EuiPageTemplate } from '@elastic/eui';
import { css } from '@emotion/react';
import { useKibana } from '../../hooks/use_kibana';
export function StreamsAppPageTemplate({ children }: { children: React.ReactNode }) {
@ -14,20 +15,29 @@ export function StreamsAppPageTemplate({ children }: { children: React.ReactNode
services: { PageTemplate },
} = useKibana();
return (
<PageTemplate>
<EuiPanel
paddingSize="none"
color="subdued"
hasShadow={false}
hasBorder={false}
className={css`
display: flex;
max-width: 100%;
`}
>
{children}
</EuiPanel>
</PageTemplate>
);
/**
* This template wrapper only serves the purpose of adding the o11y sidebar to the app.
* Due to the dependency inversion used to get the template and the constrain on the dependencies imports,
* we cannot get the right types for this template unless its definition gets moved into a more generic package.
*/
return <PageTemplate>{children}</PageTemplate>;
}
StreamsAppPageTemplate.Header = EuiPageTemplate.Header;
StreamsAppPageTemplate.EmptyPrompt = EuiPageTemplate.EmptyPrompt;
StreamsAppPageTemplate.Body = (props: EuiPageSectionProps) => (
<EuiPageTemplate.Section
grow
css={css`
overflow-y: auto;
`}
contentProps={{
css: css`
display: flex;
flex-direction: column;
height: 100%;
`,
}}
{...props}
/>
);

View file

@ -11,7 +11,7 @@ import type { StreamsAppRouter, StreamsAppRoutes } from '../routes/config';
import { streamsAppRouter } from '../routes/config';
import { useKibana } from './use_kibana';
interface StatefulStreamsAppRouter extends StreamsAppRouter {
export interface StatefulStreamsAppRouter extends StreamsAppRouter {
push<T extends PathsOf<StreamsAppRoutes>>(
path: T,
...params: TypeAsArgs<TypeOf<StreamsAppRoutes, T>>

View file

@ -8,11 +8,13 @@ import { i18n } from '@kbn/i18n';
import { createRouter, Outlet, RouteMap } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import React from 'react';
import { StreamDetailView, StreamDetailViewContent } from '../components/stream_detail_view';
import { StreamDetailView } from '../components/stream_detail_view';
import { StreamsAppPageTemplate } from '../components/streams_app_page_template';
import { StreamsAppRouterBreadcrumb } from '../components/streams_app_router_breadcrumb';
import { RedirectTo } from '../components/redirect_to';
import { StreamListView } from '../components/stream_list_view';
import { StreamManagementView } from '../components/stream_management_view';
import { StreamDetailRoot } from '../components/stream_root';
/**
* The array of route definitions to be used when the application
@ -33,8 +35,15 @@ const streamsAppRoutes = {
</StreamsAppRouterBreadcrumb>
),
children: {
'/': {
element: <StreamListView />,
},
'/{key}': {
element: <Outlet />,
element: (
<StreamDetailRoot>
<Outlet />
</StreamDetailRoot>
),
params: t.type({
path: t.type({
key: t.string,
@ -51,31 +60,22 @@ const streamsAppRoutes = {
tab: t.string,
}),
}),
children: {
'/{key}/{tab}/{subtab}': {
element: <StreamDetailViewContent />,
params: t.type({
path: t.type({
subtab: t.string,
tab: t.string,
}),
}),
},
'/{key}/{tab}': {
element: <StreamDetailViewContent />,
params: t.type({
path: t.type({
tab: t.string,
}),
}),
},
},
},
'/{key}/management': {
element: (
<RedirectTo path="/{key}/management/{tab}" params={{ path: { tab: 'route' } }} />
),
},
'/{key}/management/{tab}': {
element: <StreamManagementView />,
params: t.type({
path: t.type({
tab: t.string,
}),
}),
},
},
},
'/': {
element: <StreamListView />,
},
},
},
} satisfies RouteMap;

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { css } from '@emotion/css';
import { css } from '@emotion/react';
import React from 'react';
import useObservable from 'react-use/lib/useObservable';
import { ObservabilitySharedPluginStart } from '@kbn/observability-shared-plugin/public';
@ -26,21 +26,20 @@ export const createObservabilityStreamsAppPageTemplate =
return (
<PageTemplate
pageSectionProps={{
className: css`
color: 'subdued',
css: css`
max-height: calc(
100vh - var(--euiFixedHeadersOffset, 0)
${isSolutionNavEnabled
? `-
var(--kbnProjectHeaderAppActionMenuHeight, 48px)`
: ''}
${isSolutionNavEnabled ? `- var(--kbnProjectHeaderAppActionMenuHeight, 48px)` : ''}
);
overflow: auto;
padding-inline: 0px;
`,
contentProps: {
className: css`
css: css`
padding-block: 0px;
display: flex;
flex-direction: column;
height: 100%;
`,
},