🌊 Streams: Permission handling (#217353)

Currently, the streams UI doesn't deal well with partial permissions.
This PR improves that. As a lot of things come together in streams, we
could do even better, but I think it's OK to draw a line somewhere.

The logic is now as follows:
When reading a stream, the privileges of the current user are returned
along with the stream itself. These are grouped like this:
```
interface IngestStreamPrivileges {
  // User can change everything about the stream
  manage: boolean;
  // User can read stats (like size in bytes) about the stream
  monitor: boolean;
  // User can change the retention policy of the stream
  lifecycle: boolean;
  // User can simulate changes to the processing or the mapping of the stream
  simulate: boolean;
}
```

This is part of the definition response and is passed around to the
components and disabled buttons and similar in the places where this is
necessary.

The "advanced" tab is only shown when full `manage` permissions are
present - there constellations of permissions that would allow some
access but not all (e.g. having `read_pipelines` but not
`manage_index_templates`), but these should be rather rare and not worth
the additional effort.

## Conditions

In the following places privileges are checked:
* Overview
  * Without `monitor`, the overall stats are not shown
* Enrichment
  * Without `manage`, you can't save changes
  * Without `simulate`, the UI is readonly
* Partitioning
  * Without `manage`, you can't save changes
  * Without `simulate`, the UI is readonly
* Schema editor
  * Without `manage`, the UI is readonly
* Retention
  * Without `monitor`, the ingest stats are not shown
* Without `lifecycle`, the retention can't be changed and ILM breakdown
is not rendered
* Advanced
  * Without `manage`, the tab is hidden completely

## Drive-by fix

I noticed that we still register the app header action menu which adds
an empty bar on serverless, removed that code.

## Testing

Check
https://github.com/elastic/kibana/pull/217353/files#diff-d8f33d7021058bf90cbeea908bf399da2af50d8b8bfac8a07f160ddc0cdff12bR747
for which Elasticsearch level privileges you need for different
permutations. Then set up a role and a user and log in as that user.

Also test the different pre-defined roles on serverless.
This commit is contained in:
Joe Reuter 2025-04-08 15:42:29 +02:00 committed by GitHub
parent 970e9fe4a3
commit fd374463f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 384 additions and 235 deletions

BIN
.swn Normal file

Binary file not shown.

View file

@ -11,4 +11,10 @@ export const ingestStream = {
name: 'logs.nginx',
elasticsearch_assets: [],
stream: ingestStreamConfig,
privileges: {
manage: true,
monitor: true,
lifecycle: true,
simulate: true,
},
};

View file

@ -70,6 +70,17 @@ const ingestUpsertRequestSchema: z.Schema<IngestUpsertRequest> = z.union([
unwiredIngestUpsertRequestSchema,
]);
interface IngestStreamPrivileges {
// User can change everything about the stream
manage: boolean;
// User can read stats (like size in bytes) about the stream
monitor: boolean;
// User can change the retention policy of the stream
lifecycle: boolean;
// User can simulate changes to the processing or the mapping of the stream
simulate: boolean;
}
/**
* Stream get response
*/
@ -77,6 +88,7 @@ interface WiredStreamGetResponse extends StreamGetResponseBase {
stream: WiredStreamDefinition;
inherited_fields: InheritedFieldDefinition;
effective_lifecycle: WiredIngestStreamEffectiveLifecycle;
privileges: IngestStreamPrivileges;
}
interface UnwiredStreamGetResponse extends StreamGetResponseBase {
@ -84,6 +96,7 @@ interface UnwiredStreamGetResponse extends StreamGetResponseBase {
elasticsearch_assets?: ElasticsearchAssets;
data_stream_exists: boolean;
effective_lifecycle: UnwiredIngestStreamEffectiveLifecycle;
privileges: IngestStreamPrivileges;
}
type IngestStreamGetResponse = WiredStreamGetResponse | UnwiredStreamGetResponse;
@ -121,12 +134,20 @@ const ingestStreamUpsertRequestSchema: z.Schema<IngestStreamUpsertRequest> = z.u
unwiredStreamUpsertRequestSchema,
]);
const ingestStreamPrivilegesSchema: z.Schema<IngestStreamPrivileges> = z.object({
manage: z.boolean(),
monitor: z.boolean(),
lifecycle: z.boolean(),
simulate: z.boolean(),
});
const wiredStreamGetResponseSchema: z.Schema<WiredStreamGetResponse> = z.intersection(
streamGetResponseSchemaBase,
z.object({
stream: wiredStreamDefinitionSchema,
inherited_fields: inheritedFieldDefinitionSchema,
effective_lifecycle: wiredIngestStreamEffectiveLifecycleSchema,
privileges: ingestStreamPrivilegesSchema,
})
);
@ -137,6 +158,7 @@ const unwiredStreamGetResponseSchema: z.Schema<UnwiredStreamGetResponse> = z.int
elasticsearch_assets: z.optional(elasticsearchAssetsSchema),
data_stream_exists: z.boolean(),
effective_lifecycle: unwiredIngestStreamEffectiveLifecycleSchema,
privileges: ingestStreamPrivilegesSchema,
})
);

View file

@ -416,6 +416,47 @@ export class StreamsClient {
});
}
/**
* Checks whether the user has the required privileges to manage the stream.
* Managing a stream means updating the stream properties. It does not
* include the dashboard links.
*/
async getPrivileges(name: string) {
const privileges =
await this.dependencies.scopedClusterClient.asCurrentUser.security.hasPrivileges({
cluster: [
'manage_index_templates',
'manage_ingest_pipelines',
'manage_pipeline',
'read_pipeline',
],
index: [
{
names: [name],
privileges: [
'read',
'write',
'create',
'manage',
'monitor',
'manage_data_stream_lifecycle',
'manage_ilm',
],
},
],
});
return {
manage:
Object.values(privileges.cluster).every((privilege) => privilege === true) &&
Object.values(privileges.index[name]).every((privilege) => privilege === true),
monitor: privileges.index[name].monitor,
lifecycle:
privileges.index[name].manage_data_stream_lifecycle && privileges.index[name].manage_ilm,
simulate: privileges.cluster.read_pipeline && privileges.index[name].create,
};
}
/**
* Creates an on-the-fly ingest stream definition
* from a concrete data stream.

View file

@ -59,7 +59,7 @@ export async function readStream({
}
// These queries are only relavant for IngestStreams
const [ancestors, dataStream] = await Promise.all([
const [ancestors, dataStream, privileges] = await Promise.all([
streamsClient.getAncestors(name),
streamsClient.getDataStream(name).catch((e) => {
if (e.statusCode === 404) {
@ -67,17 +67,20 @@ export async function readStream({
}
throw e;
}),
streamsClient.getPrivileges(name),
]);
if (isUnwiredStreamDefinition(streamDefinition)) {
return {
stream: streamDefinition,
elasticsearch_assets: dataStream
? await getUnmanagedElasticsearchAssets({
dataStream,
scopedClusterClient,
})
: undefined,
privileges,
elasticsearch_assets:
dataStream && privileges.manage
? await getUnmanagedElasticsearchAssets({
dataStream,
scopedClusterClient,
})
: undefined,
data_stream_exists: !!dataStream,
effective_lifecycle: getDataStreamLifecycle(dataStream),
dashboards,
@ -89,6 +92,7 @@ export async function readStream({
const body: WiredStreamGetResponse = {
stream: streamDefinition,
dashboards,
privileges,
queries,
effective_lifecycle: findInheritedLifecycle(streamDefinition, ancestors),
inherited_fields: getInheritedFieldsFromAncestors(ancestors),

View file

@ -13,12 +13,10 @@ import {
RouteRenderer,
RouterProvider,
} from '@kbn/typed-react-router-config';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { StreamsAppContextProvider } from '../streams_app_context_provider';
import { streamsAppRouter } from '../../routes/config';
import { StreamsAppStartDependencies } from '../../types';
import { StreamsAppServices } from '../../services/types';
import { HeaderMenuPortal } from '../header_menu';
import { TimeFilterProvider } from '../../hooks/use_timefilter';
export function AppRoot({
@ -53,28 +51,9 @@ export function AppRoot({
<BreadcrumbsContextProvider>
<RouteRenderer />
</BreadcrumbsContextProvider>
<StreamsAppHeaderActionMenu appMountParameters={appMountParameters} />
</RouterProvider>
</TimeFilterProvider>
</RedirectAppLinks>
</StreamsAppContextProvider>
);
}
export function StreamsAppHeaderActionMenu({
appMountParameters,
}: {
appMountParameters: AppMountParameters;
}) {
const { setHeaderActionMenu, theme$ } = appMountParameters;
return (
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu} theme$={theme$}>
<EuiFlexGroup responsive={false} gutterSize="s">
<EuiFlexItem>
<></>
</EuiFlexItem>
</EuiFlexGroup>
</HeaderMenuPortal>
);
}

View file

@ -6,13 +6,14 @@
*/
import React from 'react';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup } from '@elastic/eui';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useDiscardConfirm } from '../../../hooks/use_discard_confirm';
interface ManagementBottomBarProps {
confirmButtonText?: string;
disabled?: boolean;
insufficientPrivileges?: boolean;
isLoading?: boolean;
onCancel: () => void;
onConfirm: () => void;
@ -22,6 +23,7 @@ export function ManagementBottomBar({
confirmButtonText = defaultConfirmButtonText,
disabled = false,
isLoading = false,
insufficientPrivileges = false,
onCancel,
onConfirm,
}: ManagementBottomBarProps) {
@ -46,18 +48,31 @@ export function ManagementBottomBar({
defaultMessage: 'Cancel changes',
})}
</EuiButtonEmpty>
<EuiButton
data-test-subj="streamsAppManagementBottomBarButton"
disabled={disabled}
color="primary"
fill
size="s"
iconType="check"
onClick={onConfirm}
isLoading={isLoading}
<EuiToolTip
content={
insufficientPrivileges
? i18n.translate(
'xpack.streams.streamDetailView.managementTab.bottomBar.onlySimulate',
{
defaultMessage: "You don't have sufficient privileges to save changes.",
}
)
: undefined
}
>
{confirmButtonText}
</EuiButton>
<EuiButton
data-test-subj="streamsAppManagementBottomBarButton"
disabled={disabled || insufficientPrivileges}
color="primary"
fill
size="s"
iconType="check"
onClick={onConfirm}
isLoading={isLoading}
>
{confirmButtonText}
</EuiButton>
</EuiToolTip>
</EuiFlexGroup>
);
}

View file

@ -71,6 +71,9 @@ export function StreamDetailEnrichmentContentImpl() {
const { resetChanges, saveChanges } = useStreamEnrichmentEvents();
const hasChanges = useStreamsEnrichmentSelector((state) => state.can({ type: 'stream.update' }));
const canManage = useStreamsEnrichmentSelector(
(state) => state.context.definition.privileges.manage
);
const isSavingChanges = useStreamsEnrichmentSelector((state) =>
state.matches({ ready: { stream: 'updating' } })
);
@ -124,6 +127,7 @@ export function StreamDetailEnrichmentContentImpl() {
onConfirm={saveChanges}
isLoading={isSavingChanges}
disabled={!hasChanges}
insufficientPrivileges={!canManage}
/>
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
@ -134,6 +138,7 @@ const ProcessorsEditor = React.memo(() => {
const { euiTheme } = useEuiTheme();
const { reorderProcessors } = useStreamEnrichmentEvents();
const definition = useStreamsEnrichmentSelector((state) => state.context.definition);
const processorsRefs = useStreamsEnrichmentSelector((state) =>
state.context.processorsRefs.filter((processorRef) =>
@ -222,6 +227,7 @@ const ProcessorsEditor = React.memo(() => {
<SortableList onDragItem={handlerItemDrag}>
{processorsRefs.map((processorRef, idx) => (
<DraggableProcessorListItem
disableDrag={!definition.privileges.manage}
key={processorRef.id}
idx={idx}
processorRef={processorRef}
@ -230,7 +236,7 @@ const ProcessorsEditor = React.memo(() => {
))}
</SortableList>
)}
<AddProcessorPanel />
{definition.privileges.simulate && <AddProcessorPanel />}
</EuiPanel>
<EuiPanel paddingSize="m" hasShadow={false} grow={false}>
{!isEmpty(errors.ignoredFields) && (

View file

@ -207,6 +207,7 @@ export interface EditProcessorPanelProps {
export function EditProcessorPanel({ processorRef, processorMetrics }: EditProcessorPanelProps) {
const { euiTheme } = useEuiTheme();
const state = useSelector(processorRef, (s) => s);
const canEdit = useStreamsEnrichmentSelector((s) => s.context.definition.privileges.manage);
const previousProcessor = state.context.previousProcessor;
const processor = state.context.processor;
@ -343,6 +344,7 @@ export function EditProcessorPanel({ processorRef, processorMetrics }: EditProce
data-test-subj="streamsAppEditProcessorPanelButton"
onClick={handleOpen}
iconType="pencil"
disabled={!canEdit}
color="text"
size="xs"
aria-label={i18n.translate(

View file

@ -11,13 +11,15 @@ import { EditProcessorPanel, type EditProcessorPanelProps } from './processors';
export const DraggableProcessorListItem = ({
idx,
disableDrag,
...props
}: EditProcessorPanelProps & { idx: number }) => (
}: EditProcessorPanelProps & { idx: number; disableDrag: boolean }) => (
<EuiDraggable
index={idx}
spacing="m"
draggableId={props.processorRef.id}
hasInteractiveChildren
isDragDisabled={disableDrag}
css={{
paddingLeft: 0,
paddingRight: 0,

View file

@ -195,18 +195,20 @@ export function StreamDetailLifecycle({
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={2}>
<EuiPanel grow={true} hasShadow={false} hasBorder paddingSize="s">
<IngestionRate
definition={definition}
refreshStats={refreshStats}
isLoadingStats={isLoadingStats}
stats={stats}
/>
</EuiPanel>
</EuiFlexItem>
{definition.privileges.monitor && (
<EuiFlexItem grow={2}>
<EuiPanel grow={true} hasShadow={false} hasBorder paddingSize="s">
<IngestionRate
definition={definition}
refreshStats={refreshStats}
isLoadingStats={isLoadingStats}
stats={stats}
/>
</EuiPanel>
</EuiFlexItem>
)}
{isIlmLifecycle(definition.effective_lifecycle) ? (
{definition.privileges.lifecycle && isIlmLifecycle(definition.effective_lifecycle) ? (
<EuiFlexItem grow={3}>
<EuiPanel grow={true} hasShadow={false} hasBorder paddingSize="s">
<IlmSummary definition={definition} lifecycle={definition.effective_lifecycle} />

View file

@ -29,6 +29,7 @@ import {
EuiPanel,
EuiPopover,
EuiText,
EuiToolTip,
formatNumber,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
@ -37,6 +38,7 @@ import { IlmLink } from './ilm_link';
import { useStreamsAppRouter } from '../../../hooks/use_streams_app_router';
import { DataStreamStats } from './hooks/use_data_stream_stats';
import { formatIngestionRate } from './helpers/format_bytes';
import { PrivilegesWarningIconWrapper } from '../../insufficient_privileges/insufficient_privileges';
export function RetentionMetadata({
definition,
@ -61,16 +63,30 @@ export function RetentionMetadata({
lifecycleActions.length === 0 ? null : (
<EuiPopover
button={
<EuiButton
data-test-subj="streamsAppRetentionMetadataEditDataRetentionButton"
size="s"
fullWidth
onClick={toggleMenu}
<EuiToolTip
content={
!definition.privileges.lifecycle
? i18n.translate(
'xpack.streams.entityDetailViewWithoutParams.editDataRetention.insufficientPrivileges',
{
defaultMessage: "You don't have sufficient privileges to change retention.",
}
)
: undefined
}
>
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.editDataRetention', {
defaultMessage: 'Edit data retention',
})}
</EuiButton>
<EuiButton
data-test-subj="streamsAppRetentionMetadataEditDataRetentionButton"
size="s"
fullWidth
onClick={toggleMenu}
disabled={!definition.privileges.lifecycle}
>
{i18n.translate('xpack.streams.entityDetailViewWithoutParams.editDataRetention', {
defaultMessage: 'Edit data retention',
})}
</EuiButton>
</EuiToolTip>
}
isOpen={isMenuOpen}
closePopover={closeMenu}
@ -178,15 +194,20 @@ export function RetentionMetadata({
'Approximate average (stream total size divided by the number of days since creation).',
})}
value={
statsError ? (
'-'
) : isLoadingStats || !stats ? (
<EuiLoadingSpinner size="s" />
) : stats.bytesPerDay ? (
formatIngestionRate(stats.bytesPerDay)
) : (
'-'
)
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="ingestionRate"
>
{statsError ? (
'-'
) : isLoadingStats || !stats ? (
<EuiLoadingSpinner size="s" />
) : stats.bytesPerDay ? (
formatIngestionRate(stats.bytesPerDay)
) : (
'-'
)}
</PrivilegesWarningIconWrapper>
}
/>
<EuiHorizontalRule margin="s" />
@ -195,13 +216,18 @@ export function RetentionMetadata({
defaultMessage: 'Total doc count',
})}
value={
statsError ? (
'-'
) : isLoadingStats || !stats ? (
<EuiLoadingSpinner size="s" />
) : (
formatNumber(stats.totalDocs, '0,0')
)
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="totalDocCount"
>
{statsError ? (
'-'
) : isLoadingStats || !stats ? (
<EuiLoadingSpinner size="s" />
) : (
formatNumber(stats.totalDocs, '0,0')
)}
</PrivilegesWarningIconWrapper>
}
/>
</EuiPanel>

View file

@ -75,14 +75,19 @@ export function ClassicStreamDetailManagement({
};
}
tabs.advanced = {
content: (
<UnmanagedElasticsearchAssets definition={definition} refreshDefinition={refreshDefinition} />
),
label: i18n.translate('xpack.streams.streamDetailView.advancedTab', {
defaultMessage: 'Advanced',
}),
};
if (definition.privileges.manage) {
tabs.advanced = {
content: (
<UnmanagedElasticsearchAssets
definition={definition}
refreshDefinition={refreshDefinition}
/>
),
label: i18n.translate('xpack.streams.streamDetailView.advancedTab', {
defaultMessage: 'Advanced',
}),
};
}
if (!isValidManagementSubTab(subtab)) {
return (

View file

@ -13,6 +13,7 @@ import {
EuiDroppable,
EuiDraggable,
EuiButton,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/css';
@ -60,26 +61,39 @@ export function ChildStreamList({ availableStreams }: { availableStreams: string
defaultMessage: 'Routing rules',
})}
</EuiText>
<EuiFlexItem grow={false}>
<EuiButton
iconType="plus"
size="s"
data-test-subj="streamsAppStreamDetailRoutingAddRuleButton"
onClick={() => {
selectChildUnderEdit({
isNew: true,
child: {
destination: `${definition.stream.name}.child`,
if: cloneDeep(EMPTY_EQUALS_CONDITION),
},
});
}}
>
{i18n.translate('xpack.streams.streamDetailRouting.addRule', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiFlexItem>
{(definition.privileges.simulate || definition.privileges.manage) && (
<EuiFlexItem grow={false}>
<EuiToolTip
content={
definition.privileges.simulate
? i18n.translate('xpack.streams.streamDetailRouting.rules.onlySimulate', {
defaultMessage:
"You don't have sufficient privileges to create new streams, only simulate.",
})
: undefined
}
>
<EuiButton
iconType="plus"
size="s"
data-test-subj="streamsAppStreamDetailRoutingAddRuleButton"
onClick={() => {
selectChildUnderEdit({
isNew: true,
child: {
destination: `${definition.stream.name}.child`,
if: cloneDeep(EMPTY_EQUALS_CONDITION),
},
});
}}
>
{i18n.translate('xpack.streams.streamDetailRouting.addRule', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiToolTip>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexGroup
@ -98,6 +112,7 @@ export function ChildStreamList({ availableStreams }: { availableStreams: string
<EuiDraggable
key={child.destination}
index={i}
isDragDisabled={!definition.privileges.manage}
draggableId={child.destination}
hasInteractiveChildren={true}
customDragHandle={true}
@ -111,7 +126,9 @@ export function ChildStreamList({ availableStreams }: { availableStreams: string
>
<RoutingStreamEntry
draggableProvided={provided}
disableEditButton={hasChildStreamsOrderChanged}
disableEditButton={
hasChildStreamsOrderChanged || !definition.privileges.manage
}
child={
!childUnderEdit?.isNew &&
child.destination === childUnderEdit?.child.destination

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { EuiFlexGroup, EuiButton, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
import { EuiFlexGroup, EuiButton, EuiFlexItem, EuiButtonEmpty, EuiToolTip } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { toMountPoint } from '@kbn/react-kibana-mount';
import { IngestUpsertRequest } from '@kbn/streams-schema';
@ -169,7 +169,7 @@ export function ControlBar() {
<EuiButtonEmpty
color="danger"
size="s"
disabled={routingAppState.saveInProgress}
disabled={routingAppState.saveInProgress || !definition.privileges.manage}
data-test-subj="streamsAppRoutingStreamEntryRemoveButton"
onClick={() => {
routingAppState.setShowDeleteModal(true);
@ -194,19 +194,30 @@ export function ControlBar() {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
<EuiButton
isLoading={routingAppState.saveInProgress}
onClick={saveOrUpdateChildren}
data-test-subj="streamsAppStreamDetailRoutingSaveButton"
<EuiToolTip
content={
!definition.privileges.manage
? i18n.translate('xpack.streams.streamDetailRouting.onlySimulate', {
defaultMessage: "You don't have sufficient privileges to save changes.",
})
: undefined
}
>
{routingAppState.childUnderEdit && routingAppState.childUnderEdit.isNew
? i18n.translate('xpack.streams.streamDetailRouting.add', {
defaultMessage: 'Save',
})
: i18n.translate('xpack.streams.streamDetailRouting.change', {
defaultMessage: 'Change routing',
})}
</EuiButton>
<EuiButton
isLoading={routingAppState.saveInProgress}
disabled={routingAppState.saveInProgress || !definition.privileges.manage}
onClick={saveOrUpdateChildren}
data-test-subj="streamsAppStreamDetailRoutingSaveButton"
>
{routingAppState.childUnderEdit && routingAppState.childUnderEdit.isNew
? i18n.translate('xpack.streams.streamDetailRouting.add', {
defaultMessage: 'Save',
})
: i18n.translate('xpack.streams.streamDetailRouting.change', {
defaultMessage: 'Change routing',
})}
</EuiButton>
</EuiToolTip>
</EuiFlexGroup>
</EuiFlexGroup>
);

View file

@ -34,7 +34,7 @@ export const StreamDetailSchemaEditor = ({ definition, refreshDefinition }: Sche
onRefreshData={refreshFields}
withControls
withFieldSimulation
withTableActions={!isRootStreamDefinition(definition.stream)}
withTableActions={!isRootStreamDefinition(definition.stream) && definition.privileges.manage}
/>
);
};

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 { render } from '@testing-library/react';
import React from 'react';
import HeaderMenuPortal from './header_menu_portal';
import { themeServiceMock } from '@kbn/core/public/mocks';
describe('HeaderMenuPortal', () => {
describe('when unmounted', () => {
it('calls setHeaderActionMenu with undefined', () => {
const setHeaderActionMenu = jest.fn();
const theme$ = themeServiceMock.createTheme$();
const { unmount } = render(
<HeaderMenuPortal setHeaderActionMenu={setHeaderActionMenu} theme$={theme$}>
test
</HeaderMenuPortal>
);
unmount();
expect(setHeaderActionMenu).toHaveBeenCalledWith(undefined);
});
});
});

View file

@ -1,41 +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, { useEffect, useMemo, ReactNode } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
// FIXME use import { toMountPoint } from '@kbn/react-kibana-mount';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { AppMountParameters } from '@kbn/core/public';
export interface HeaderMenuPortalProps {
children: ReactNode;
setHeaderActionMenu: AppMountParameters['setHeaderActionMenu'];
theme$: AppMountParameters['theme$'];
}
// eslint-disable-next-line import/no-default-export
export default function HeaderMenuPortal({
children,
setHeaderActionMenu,
theme$,
}: HeaderMenuPortalProps) {
const portalNode = useMemo(() => createHtmlPortalNode(), []);
useEffect(() => {
setHeaderActionMenu((element) => {
const mount = toMountPoint(<OutPortal node={portalNode} />, { theme$ });
return mount(element);
});
return () => {
portalNode.unmount();
setHeaderActionMenu(undefined);
};
}, [portalNode, setHeaderActionMenu, theme$]);
return <InPortal node={portalNode}>{children}</InPortal>;
}

View file

@ -1,20 +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, { lazy, Suspense } from 'react';
import { EuiLoadingSpinner } from '@elastic/eui';
import type { HeaderMenuPortalProps } from './header_menu_portal';
const HeaderMenuPortalLazy = lazy(() => import('./header_menu_portal'));
export function HeaderMenuPortal(props: HeaderMenuPortalProps) {
return (
<Suspense fallback={<EuiLoadingSpinner />}>
<HeaderMenuPortalLazy {...props} />
</Suspense>
);
}

View file

@ -0,0 +1,82 @@
/*
* 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 useToggle from 'react-use/lib/useToggle';
import { i18n } from '@kbn/i18n';
import {
EuiButtonIcon,
EuiButtonIconProps,
EuiPopover,
EuiToolTip,
EuiIcon,
EuiFlexGroup,
EuiButtonIconPropsForButton,
} from '@elastic/eui';
const insufficientPrivilegesText = i18n.translate('xpack.streams.insufficientPrivilegesMessage', {
defaultMessage: "You don't have sufficient privileges to access this information.",
});
export const PrivilegesWarningIconWrapper = ({
hasPrivileges,
title,
mode = 'popover',
iconColor = 'warning',
popoverCss,
children,
}: {
hasPrivileges: boolean;
title: string;
mode?: 'tooltip' | 'popover';
iconColor?: EuiButtonIconPropsForButton['color'];
popoverCss?: EuiButtonIconProps['css'];
children: React.ReactNode;
}) => {
const [isPopoverOpen, togglePopover] = useToggle(false);
const handleButtonClick = togglePopover;
if (hasPrivileges) {
return <>{children}</>;
}
return mode === 'popover' ? (
<EuiPopover
css={popoverCss}
attachToAnchor={true}
anchorPosition="downCenter"
button={
<EuiButtonIcon
data-test-subj={`streamsInsufficientPrivileges-${title}`}
aria-label={insufficientPrivilegesText}
title={insufficientPrivilegesText}
iconType="warning"
color={iconColor}
onClick={handleButtonClick}
/>
}
isOpen={isPopoverOpen}
closePopover={togglePopover}
>
{insufficientPrivilegesText}
</EuiPopover>
) : (
<EuiToolTip content={insufficientPrivilegesText}>
<EuiFlexGroup alignItems="center">
<EuiIcon
data-test-subj={`streamsInsufficientPrivileges-${title}`}
aria-label={insufficientPrivilegesText}
title={insufficientPrivilegesText}
type="warning"
color={iconColor}
/>
{children}
</EuiFlexGroup>
</EuiToolTip>
);
};

View file

@ -50,23 +50,25 @@ export function ChildStreamList({ definition }: { definition?: IngestStreamGetRe
'Create sub streams to split out data with different retention policies, schemas, and more.',
})}
</EuiText>
<EuiFlexGroup justifyContent="center">
<EuiButton
data-test-subj="streamsAppChildStreamListCreateChildStreamButton"
iconType="plusInCircle"
href={router.link('/{key}/{tab}/{subtab}', {
path: {
key: definition.stream.name,
tab: 'management',
subtab: 'route',
},
})}
>
{i18n.translate('xpack.streams.entityDetailOverview.createChildStream', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiFlexGroup>
{definition.privileges.manage && (
<EuiFlexGroup justifyContent="center">
<EuiButton
data-test-subj="streamsAppChildStreamListCreateChildStreamButton"
iconType="plusInCircle"
href={router.link('/{key}/{tab}/{subtab}', {
path: {
key: definition.stream.name,
tab: 'management',
subtab: 'route',
},
})}
>
{i18n.translate('xpack.streams.entityDetailOverview.createChildStream', {
defaultMessage: 'Create child stream',
})}
</EuiButton>
</EuiFlexGroup>
)}
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -24,6 +24,7 @@ import {
formatIngestionRate,
} from '../../data_management/stream_detail_lifecycle/helpers/format_bytes';
import { useDataStreamStats } from '../../data_management/stream_detail_lifecycle/hooks/use_data_stream_stats';
import { PrivilegesWarningIconWrapper } from '../../insufficient_privileges/insufficient_privileges';
interface StreamStatsPanelProps {
definition: IngestStreamGetResponse;
@ -123,15 +124,25 @@ export function StreamStatsPanel({ definition }: StreamStatsPanelProps) {
<StatItem
label={documentCountLabel}
value={
dataStreamStats ? formatNumber(dataStreamStats.totalDocs || 0, 'decimal0') : '-'
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="totalDocCount"
>
{dataStreamStats ? formatNumber(dataStreamStats.totalDocs || 0, 'decimal0') : '-'}
</PrivilegesWarningIconWrapper>
}
/>
<StatItem
label={storageSizeLabel}
value={
dataStreamStats && dataStreamStats.sizeBytes
? formatBytes(dataStreamStats.sizeBytes)
: '-'
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="sizeBytes"
>
{dataStreamStats && dataStreamStats.sizeBytes
? formatBytes(dataStreamStats.sizeBytes)
: '-'}
</PrivilegesWarningIconWrapper>
}
withBorder
/>
@ -152,7 +163,14 @@ export function StreamStatsPanel({ definition }: StreamStatsPanelProps) {
</>
}
value={
dataStreamStats ? formatIngestionRate(dataStreamStats.bytesPerDay || 0, true) : '-'
<PrivilegesWarningIconWrapper
hasPrivileges={definition.privileges.monitor}
title="ingestionRate"
>
{dataStreamStats
? formatIngestionRate(dataStreamStats.bytesPerDay || 0, true)
: '-'}
</PrivilegesWarningIconWrapper>
}
withBorder
/>