[ML] Transforms: Use basic stats for transform list, call full stats only for expanded rows. (#180271)

## Summary

Fixes #178181.

This updates the transform list to fetch stats only with the option
`basic=true`. Only for certain tabs in the expanded rows we'll still
fetch the full stats.

If the full stats for a transform fail to load, we show a callout in the
affected tabs in the expanded row like this:

<img width="1067" alt="image"
src="e1d0eeff-7c97-4418-93eb-33832b070a17">

To reproduce this for testing, throw an error in the file
`x-pack/plugins/transform/server/routes/api/transforms_stats_single/route_handler.ts`
just like this:

```ts
export const routeHandler: RequestHandler<
  TransformIdParamSchema,
  GetTransformStatsQuerySchema,
  undefined,
  TransformRequestHandlerContext
> = async (ctx, req, res) => {
  const { transformId } = req.params;
  try {
    throw new Error('just testing error handling...');

    const basic = req.query.basic ?? false;

    const esClient = (await ctx.core).elasticsearch.client;
    const body = await esClient.asCurrentUser.transform.getTransformStats(
      {
...
```

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Walter Rafelsberger 2024-04-16 07:47:17 +02:00 committed by GitHub
parent 1e77571395
commit a2898db525
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 510 additions and 368 deletions

View file

@ -6,6 +6,7 @@
*/
import type { TypeOf } from '@kbn/config-schema';
import { schema } from '@kbn/config-schema';
import type { TransformStats } from '../types/transform_stats';
@ -15,6 +16,11 @@ export const getTransformsStatsRequestSchema = getTransformsRequestSchema;
export type GetTransformsStatsRequestSchema = TypeOf<typeof getTransformsStatsRequestSchema>;
export const getTransformStatsQuerySchema = schema.object({
basic: schema.maybe(schema.boolean()),
});
export type GetTransformStatsQuerySchema = TypeOf<typeof getTransformStatsQuerySchema>;
export interface GetTransformsStatsResponseSchema {
node_failures?: object;
count: number;

View file

@ -17,6 +17,7 @@ import { useAppDependencies } from '../app_dependencies';
export const useGetTransformStats = (
transformId: TransformId,
basic = false,
enabled?: boolean,
refetchInterval?: number | false
) => {
@ -28,6 +29,7 @@ export const useGetTransformStats = (
http.get<GetTransformsStatsResponseSchema>(
addInternalBasePath(`transforms/${transformId}/_stats`),
{
query: { basic },
version: '1',
signal,
}
@ -37,9 +39,11 @@ export const useGetTransformStats = (
};
export const useGetTransformsStats = ({
basic = false,
enabled,
refetchInterval,
}: {
basic?: boolean;
enabled?: boolean;
refetchInterval?: number | false;
}) => {
@ -49,6 +53,7 @@ export const useGetTransformsStats = ({
[TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORMS_STATS],
({ signal }) =>
http.get<GetTransformsStatsResponseSchema>(addInternalBasePath(`transforms/_stats`), {
query: { basic },
version: '1',
asSystemRequest: true,
signal,

View file

@ -7,15 +7,10 @@
import { useQueryClient } from '@tanstack/react-query';
import { TRANSFORM_REACT_QUERY_KEYS } from '../../../common/constants';
export const useRefreshTransformList = () => {
const queryClient = useQueryClient();
return () => {
queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM_NODES]);
queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORMS]);
queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORMS_STATS]);
queryClient.invalidateQueries([TRANSFORM_REACT_QUERY_KEYS.GET_TRANSFORM_AUDIT_MESSAGES]);
queryClient.invalidateQueries();
};
};

View file

@ -174,6 +174,7 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
const { data: stats } = useGetTransformStats(
transformId,
false,
progressBarRefetchEnabled,
progressBarRefetchInterval
);

View file

@ -7,6 +7,7 @@
import { render, fireEvent, screen, waitFor, within } from '@testing-library/react';
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import moment from 'moment-timezone';
import type { TransformListRow } from '../../../../common';
import { ExpandedRow } from './expanded_row';
@ -19,6 +20,8 @@ jest.mock('../../../../app_dependencies');
import { MlSharedContext } from '../../../../__mocks__/shared_context';
import { getMlSharedImports } from '../../../../../shared_imports';
const queryClient = new QueryClient();
describe('Transform: Transform List <ExpandedRow />', () => {
const onAlertEdit = jest.fn();
// Set timezone to US/Eastern for consistent test results.
@ -36,9 +39,11 @@ describe('Transform: Transform List <ExpandedRow />', () => {
const item: TransformListRow = transformListRow;
render(
<MlSharedContext.Provider value={mlShared}>
<ExpandedRow item={item} onAlertEdit={onAlertEdit} transformsStatsLoading={false} />
</MlSharedContext.Provider>
<QueryClientProvider client={queryClient}>
<MlSharedContext.Provider value={mlShared}>
<ExpandedRow item={item} onAlertEdit={onAlertEdit} />
</MlSharedContext.Provider>
</QueryClientProvider>
);
await waitFor(() => {

View file

@ -5,286 +5,31 @@
* 2.0.
*/
import React, { useMemo, type FC } from 'react';
import moment from 'moment-timezone';
import React, { type FC } from 'react';
import { css } from '@emotion/react';
import {
EuiButtonEmpty,
EuiLoadingSpinner,
EuiFlexGroup,
useEuiTheme,
EuiCallOut,
EuiFlexItem,
EuiTabbedContent,
} from '@elastic/eui';
import { EuiTabbedContent } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { formatHumanReadableDateTimeSeconds } from '@kbn/ml-date-utils';
import { stringHash } from '@kbn/ml-string-hash';
import { isDefined } from '@kbn/ml-is-defined';
import { FormattedMessage } from '@kbn/i18n-react';
import { mapEsHealthStatus2TransformHealthStatus } from '../../../../../../common/constants';
import { useEnabledFeatures } from '../../../../serverless_context';
import { isTransformListRowWithStats } from '../../../../common/transform_list';
import type { TransformHealthAlertRule } from '../../../../../../common/types/alerting';
import type { TransformListRow } from '../../../../common';
import type { SectionConfig, SectionItem } from './expanded_row_details_pane';
import { ExpandedRowDetailsPane } from './expanded_row_details_pane';
import { ExpandedRowJsonPane } from './expanded_row_json_pane';
import { ExpandedRowMessagesPane } from './expanded_row_messages_pane';
import { ExpandedRowPreviewPane } from './expanded_row_preview_pane';
import { ExpandedRowHealthPane } from './expanded_row_health_pane';
import { TransformHealthColoredDot } from './transform_health_colored_dot';
function getItemDescription(value: any) {
if (typeof value === 'object') {
return JSON.stringify(value);
}
return value.toString();
}
type Item = SectionItem;
import { ExpandedRowStatsPane } from './expanded_row_stats_pane';
interface Props {
item: TransformListRow;
onAlertEdit: (alertRule: TransformHealthAlertRule) => void;
transformsStatsLoading: boolean;
}
const NoStatsFallbackTabContent = ({
transformsStatsLoading,
}: {
transformsStatsLoading: boolean;
}) => {
const { euiTheme } = useEuiTheme();
const content = transformsStatsLoading ? (
<EuiLoadingSpinner />
) : (
<EuiFlexItem grow={true}>
<EuiCallOut
size="s"
color="warning"
iconType="iInCircle"
title={
<FormattedMessage
id="xpack.transform.transformList.noStatsAvailable"
defaultMessage="No stats available for this transform."
/>
}
/>
</EuiFlexItem>
);
return (
<EuiFlexGroup justifyContent="center" alignItems="center" css={{ height: euiTheme.size.xxxxl }}>
{content}
</EuiFlexGroup>
);
};
export const ExpandedRow: FC<Props> = ({ item, onAlertEdit, transformsStatsLoading }) => {
const { showNodeInfo } = useEnabledFeatures();
const stateItems: Item[] = [];
stateItems.push({
title: 'ID',
description: item.id,
});
const configItems = useMemo(() => {
const configs: Item[] = [
{
title: 'transform_id',
description: item.id,
},
{
title: 'transform_version',
description: item.config.version ?? '',
},
{
title: 'description',
description: item.config.description ?? '',
},
{
title: 'create_time',
description:
formatHumanReadableDateTimeSeconds(moment(item.config.create_time).unix() * 1000) ?? '',
},
{
title: 'source_index',
description: Array.isArray(item.config.source.index)
? item.config.source.index[0]
: item.config.source.index,
},
{
title: 'destination_index',
description: item.config.dest.index,
},
{
title: 'authorization',
description: item.config.authorization ? JSON.stringify(item.config.authorization) : '',
},
];
if (isDefined(item.config.settings?.num_failure_retries)) {
configs.push({
title: 'num_failure_retries',
description: item.config.settings?.num_failure_retries ?? '',
});
}
return configs;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item?.config]);
const checkpointingItems: Item[] = [];
if (isTransformListRowWithStats(item)) {
stateItems.push({
title: 'state',
description: item.stats.state,
});
if (showNodeInfo && item.stats.node !== undefined) {
stateItems.push({
title: 'node.name',
description: item.stats.node.name,
});
}
if (item.stats.health !== undefined) {
stateItems.push({
title: 'health',
description: (
<TransformHealthColoredDot
healthStatus={mapEsHealthStatus2TransformHealthStatus(item.stats.health.status)}
/>
),
});
}
if (item.stats.checkpointing.changes_last_detected_at !== undefined) {
checkpointingItems.push({
title: 'changes_last_detected_at',
description: formatHumanReadableDateTimeSeconds(
item.stats.checkpointing.changes_last_detected_at
),
});
}
if (item.stats.checkpointing.last !== undefined) {
checkpointingItems.push({
title: 'last.checkpoint',
description: item.stats.checkpointing.last.checkpoint,
});
if (item.stats.checkpointing.last.timestamp_millis !== undefined) {
checkpointingItems.push({
title: 'last.timestamp',
description: formatHumanReadableDateTimeSeconds(
item.stats.checkpointing.last.timestamp_millis
),
});
checkpointingItems.push({
title: 'last.timestamp_millis',
description: item.stats.checkpointing.last.timestamp_millis,
});
}
}
if (item.stats.checkpointing.last_search_time !== undefined) {
checkpointingItems.push({
title: 'last_search_time',
description: formatHumanReadableDateTimeSeconds(item.stats.checkpointing.last_search_time),
});
}
if (item.stats.checkpointing.next !== undefined) {
checkpointingItems.push({
title: 'next.checkpoint',
description: item.stats.checkpointing.next.checkpoint,
});
if (item.stats.checkpointing.next.checkpoint_progress !== undefined) {
checkpointingItems.push({
title: 'next.checkpoint_progress.total_docs',
description: item.stats.checkpointing.next.checkpoint_progress.total_docs,
});
checkpointingItems.push({
title: 'next.checkpoint_progress.docs_remaining',
description: item.stats.checkpointing.next.checkpoint_progress.docs_remaining,
});
checkpointingItems.push({
title: 'next.checkpoint_progress.percent_complete',
description: item.stats.checkpointing.next.checkpoint_progress.percent_complete,
});
}
}
if (item.stats.checkpointing.operations_behind !== undefined) {
checkpointingItems.push({
title: 'operations_behind',
description: item.stats.checkpointing.operations_behind,
});
}
}
const state: SectionConfig = {
title: 'State',
items: stateItems,
position: 'right',
};
const general: SectionConfig = {
title: 'General',
items: configItems,
position: 'left',
};
const alertRuleItems: Item[] | undefined = item.alerting_rules?.map((rule) => {
return {
title: (
<EuiButtonEmpty
iconType={'documentEdit'}
iconSide={'left'}
onClick={() => {
onAlertEdit(rule);
}}
flush="left"
size={'xs'}
iconSize={'s'}
>
{rule.name}
</EuiButtonEmpty>
),
description: rule.executionStatus.status,
};
});
const checkpointing: SectionConfig = {
title: 'Checkpointing',
items: checkpointingItems,
position: 'right',
};
const alertingRules: SectionConfig = {
title: i18n.translate('xpack.transform.transformList.transformDetails.alertRulesTitle', {
defaultMessage: 'Alert rules',
}),
items: alertRuleItems!,
position: 'right',
};
const stats: SectionConfig = {
title: 'Stats',
items: isTransformListRowWithStats(item)
? Object.entries(item.stats.stats).map((s) => {
return { title: s[0].toString(), description: getItemDescription(s[1]) };
})
: [],
position: 'left',
};
export const ExpandedRow: FC<Props> = ({ item, onAlertEdit }) => {
const tabId = stringHash(item.id);
const tabs = [
@ -297,17 +42,7 @@ export const ExpandedRow: FC<Props> = ({ item, onAlertEdit, transformsStatsLoadi
defaultMessage: 'Details',
}
),
content: (
<ExpandedRowDetailsPane
sections={[
general,
state,
checkpointing,
...(alertingRules.items ? [alertingRules] : []),
]}
dataTestSubj={'transformDetailsTabContent'}
/>
),
content: <ExpandedRowDetailsPane item={item} onAlertEdit={onAlertEdit} />,
},
{
id: `transform-stats-tab-${tabId}`,
@ -318,11 +53,7 @@ export const ExpandedRow: FC<Props> = ({ item, onAlertEdit, transformsStatsLoadi
defaultMessage: 'Stats',
}
),
content: isTransformListRowWithStats(item) ? (
<ExpandedRowDetailsPane sections={[stats]} dataTestSubj={'transformStatsTabContent'} />
) : (
<NoStatsFallbackTabContent transformsStatsLoading={transformsStatsLoading} />
),
content: <ExpandedRowStatsPane item={item} />,
},
{
id: `transform-json-tab-${tabId}`,

View file

@ -8,8 +8,8 @@
import React from 'react';
import { render } from '@testing-library/react';
import type { SectionConfig } from './expanded_row_details_pane';
import { ExpandedRowDetailsPane, Section } from './expanded_row_details_pane';
import type { SectionConfig } from './expanded_row_column_view';
import { ExpandedRowColumnView, Section } from './expanded_row_column_view';
const section: SectionConfig = {
title: 'the-section-title',
@ -24,7 +24,7 @@ const section: SectionConfig = {
describe('Transform: Job List Expanded Row <ExpandedRowDetailsPane />', () => {
test('Minimal initialization', () => {
const { container } = render(<ExpandedRowDetailsPane sections={[section]} />);
const { container } = render(<ExpandedRowColumnView sections={[section]} />);
expect(container.textContent).toContain('the-section-title');
expect(container.textContent).toContain('the-item-title');

View file

@ -0,0 +1,103 @@
/*
* 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 type { FC } from 'react';
import React, { Fragment } from 'react';
import {
EuiCallOut,
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
export interface SectionItem {
title: string | JSX.Element;
description: string | number | JSX.Element;
}
export interface SectionConfig {
title: string;
position: 'left' | 'right';
items: SectionItem[];
}
interface SectionProps {
section: SectionConfig;
}
export const Section: FC<SectionProps> = ({ section }) => {
if (section.items.length === 0) {
return null;
}
return (
<EuiPanel>
<EuiTitle size="xs">
<span>{section.title}</span>
</EuiTitle>
<EuiDescriptionList compressed type="column" listItems={section.items} />
</EuiPanel>
);
};
interface ExpandedRowDetailsPaneProps {
sections: SectionConfig[];
showErrorCallout?: boolean;
dataTestSubj?: string;
}
export const ExpandedRowColumnView: FC<ExpandedRowDetailsPaneProps> = ({
sections,
showErrorCallout = false,
dataTestSubj,
}) => {
return (
<div data-test-subj={dataTestSubj ?? 'transformDetailsTabContent'}>
{showErrorCallout && (
<>
<EuiSpacer size={'s'} />
<EuiCallOut color="warning" iconType="warning" size="m">
<p>
<FormattedMessage
id="xpack.transform.list.extendedStatsError"
defaultMessage="An error occurred fetching the extended stats for this transform. Basic stats are displayed instead."
/>
</p>
</EuiCallOut>
</>
)}
<EuiFlexGroup>
<EuiFlexItem style={{ width: '50%' }}>
{sections
.filter((s) => s.position === 'left')
.map((s) => (
<Fragment key={s.title}>
<EuiSpacer size="s" />
<Section section={s} />
</Fragment>
))}
</EuiFlexItem>
<EuiFlexItem style={{ width: '50%' }}>
{sections
.filter((s) => s.position === 'right')
.map((s) => (
<Fragment key={s.title}>
<EuiSpacer size="s" />
<Section section={s} />
</Fragment>
))}
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};

View file

@ -6,80 +6,246 @@
*/
import type { FC } from 'react';
import React, { Fragment } from 'react';
import React, { useMemo } from 'react';
import moment from 'moment-timezone';
import {
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
import { EuiButtonEmpty } from '@elastic/eui';
export interface SectionItem {
title: string | JSX.Element;
description: string | number | JSX.Element;
}
import { i18n } from '@kbn/i18n';
import { formatHumanReadableDateTimeSeconds } from '@kbn/ml-date-utils';
import { isDefined } from '@kbn/ml-is-defined';
export interface SectionConfig {
title: string;
position: 'left' | 'right';
items: SectionItem[];
}
import { mapEsHealthStatus2TransformHealthStatus } from '../../../../../../common/constants';
import { isTransformStats } from '../../../../../../common/types/transform_stats';
import type { TransformHealthAlertRule } from '../../../../../../common/types/alerting';
interface SectionProps {
section: SectionConfig;
}
import type { TransformListRow } from '../../../../common';
import { useEnabledFeatures } from '../../../../serverless_context';
import { isTransformListRowWithStats } from '../../../../common/transform_list';
import { useGetTransformStats } from '../../../../hooks';
export const Section: FC<SectionProps> = ({ section }) => {
if (section.items.length === 0) {
return null;
}
return (
<EuiPanel>
<EuiTitle size="xs">
<span>{section.title}</span>
</EuiTitle>
<EuiDescriptionList compressed type="column" listItems={section.items} />
</EuiPanel>
);
};
import { TransformHealthColoredDot } from './transform_health_colored_dot';
import type { SectionConfig, SectionItem } from './expanded_row_column_view';
import { ExpandedRowColumnView } from './expanded_row_column_view';
interface ExpandedRowDetailsPaneProps {
sections: SectionConfig[];
dataTestSubj?: string;
item: TransformListRow;
onAlertEdit: (alertRule: TransformHealthAlertRule) => void;
}
export const ExpandedRowDetailsPane: FC<ExpandedRowDetailsPaneProps> = ({
sections,
dataTestSubj,
}) => {
export const ExpandedRowDetailsPane: FC<ExpandedRowDetailsPaneProps> = ({ item, onAlertEdit }) => {
const { data: fullStats, isError, isLoading } = useGetTransformStats(item.id, false, true);
let displayStats = {};
if (fullStats !== undefined && !isLoading && !isError) {
displayStats = fullStats.transforms[0];
} else if (isTransformListRowWithStats(item)) {
displayStats = item.stats;
}
const { showNodeInfo } = useEnabledFeatures();
const stateItems: SectionItem[] = [
{
title: 'ID',
description: item.id,
},
];
const configItems = useMemo(() => {
const configs: SectionItem[] = [
{
title: 'transform_id',
description: item.id,
},
{
title: 'transform_version',
description: item.config.version ?? '',
},
{
title: 'description',
description: item.config.description ?? '',
},
{
title: 'create_time',
description:
formatHumanReadableDateTimeSeconds(moment(item.config.create_time).unix() * 1000) ?? '',
},
{
title: 'source_index',
description: Array.isArray(item.config.source.index)
? item.config.source.index[0]
: item.config.source.index,
},
{
title: 'destination_index',
description: item.config.dest.index,
},
{
title: 'authorization',
description: item.config.authorization ? JSON.stringify(item.config.authorization) : '',
},
];
if (isDefined(item.config.settings?.num_failure_retries)) {
configs.push({
title: 'num_failure_retries',
description: item.config.settings?.num_failure_retries ?? '',
});
}
return configs;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item?.config]);
const checkpointingItems: SectionItem[] = [];
if (isTransformStats(displayStats)) {
stateItems.push({
title: 'state',
description: displayStats.state,
});
if (showNodeInfo && displayStats.node !== undefined) {
stateItems.push({
title: 'node.name',
description: displayStats.node.name,
});
}
if (displayStats.health !== undefined) {
stateItems.push({
title: 'health',
description: (
<TransformHealthColoredDot
healthStatus={mapEsHealthStatus2TransformHealthStatus(displayStats.health.status)}
/>
),
});
}
if (displayStats.checkpointing.changes_last_detected_at !== undefined) {
checkpointingItems.push({
title: 'changes_last_detected_at',
description: formatHumanReadableDateTimeSeconds(
displayStats.checkpointing.changes_last_detected_at
),
});
}
if (displayStats.checkpointing.last !== undefined) {
checkpointingItems.push({
title: 'last.checkpoint',
description: displayStats.checkpointing.last.checkpoint,
});
if (displayStats.checkpointing.last.timestamp_millis !== undefined) {
checkpointingItems.push({
title: 'last.timestamp',
description: formatHumanReadableDateTimeSeconds(
displayStats.checkpointing.last.timestamp_millis
),
});
checkpointingItems.push({
title: 'last.timestamp_millis',
description: displayStats.checkpointing.last.timestamp_millis,
});
}
}
if (displayStats.checkpointing.last_search_time !== undefined) {
checkpointingItems.push({
title: 'last_search_time',
description: formatHumanReadableDateTimeSeconds(
displayStats.checkpointing.last_search_time
),
});
}
if (displayStats.checkpointing.next !== undefined) {
checkpointingItems.push({
title: 'next.checkpoint',
description: displayStats.checkpointing.next.checkpoint,
});
if (displayStats.checkpointing.next.checkpoint_progress !== undefined) {
checkpointingItems.push({
title: 'next.checkpoint_progress.total_docs',
description: displayStats.checkpointing.next.checkpoint_progress.total_docs,
});
checkpointingItems.push({
title: 'next.checkpoint_progress.docs_remaining',
description: displayStats.checkpointing.next.checkpoint_progress.docs_remaining,
});
checkpointingItems.push({
title: 'next.checkpoint_progress.percent_complete',
description: `${Math.round(
displayStats.checkpointing.next.checkpoint_progress.percent_complete
)}%`,
});
}
}
if (displayStats.checkpointing.operations_behind !== undefined) {
checkpointingItems.push({
title: 'operations_behind',
description: displayStats.checkpointing.operations_behind,
});
}
}
const state: SectionConfig = {
title: i18n.translate('xpack.transform.transformList.transformDetails.stateTitle', {
defaultMessage: 'State',
}),
items: stateItems,
position: 'right',
};
const general: SectionConfig = {
title: i18n.translate('xpack.transform.transformList.transformDetails.generalTitle', {
defaultMessage: 'General',
}),
items: configItems,
position: 'left',
};
const alertRuleItems: SectionItem[] | undefined = item.alerting_rules?.map((rule) => {
return {
title: (
<EuiButtonEmpty
iconType={'documentEdit'}
iconSide={'left'}
onClick={() => {
onAlertEdit(rule);
}}
flush="left"
size={'xs'}
iconSize={'s'}
>
{rule.name}
</EuiButtonEmpty>
),
description: rule.executionStatus.status,
};
});
const checkpointing: SectionConfig = {
title: i18n.translate('xpack.transform.transformList.transformDetails.checkpointTitle', {
defaultMessage: 'Checkpointing',
}),
items: checkpointingItems,
position: 'right',
};
const alertingRules: SectionConfig = {
title: i18n.translate('xpack.transform.transformList.transformDetails.alertRulesTitle', {
defaultMessage: 'Alert rules',
}),
items: alertRuleItems!,
position: 'right',
};
return (
<div data-test-subj={dataTestSubj ?? 'transformDetailsTabContent'}>
<EuiFlexGroup>
<EuiFlexItem style={{ width: '50%' }}>
{sections
.filter((s) => s.position === 'left')
.map((s) => (
<Fragment key={s.title}>
<EuiSpacer size="s" />
<Section section={s} />
</Fragment>
))}
</EuiFlexItem>
<EuiFlexItem style={{ width: '50%' }}>
{sections
.filter((s) => s.position === 'right')
.map((s) => (
<Fragment key={s.title}>
<EuiSpacer size="s" />
<Section section={s} />
</Fragment>
))}
</EuiFlexItem>
</EuiFlexGroup>
</div>
<ExpandedRowColumnView
sections={[general, state, checkpointing, ...(alertingRules.items ? [alertingRules] : [])]}
showErrorCallout={isError}
dataTestSubj={'transformDetailsTabContent'}
/>
);
};

View file

@ -0,0 +1,103 @@
/*
* 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, { type FC } from 'react';
import {
EuiLoadingSpinner,
EuiFlexGroup,
useEuiTheme,
EuiCallOut,
EuiFlexItem,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { TransformListRow } from '../../../../common';
import { isTransformListRowWithStats } from '../../../../common/transform_list';
import { useGetTransformStats } from '../../../../hooks';
import type { SectionConfig } from './expanded_row_column_view';
import { ExpandedRowColumnView } from './expanded_row_column_view';
const NoStatsFallbackTabContent = ({
transformsStatsLoading,
}: {
transformsStatsLoading: boolean;
}) => {
const { euiTheme } = useEuiTheme();
const content = transformsStatsLoading ? (
<EuiLoadingSpinner />
) : (
<EuiFlexItem grow={true}>
<EuiCallOut
size="s"
color="warning"
iconType="iInCircle"
title={
<FormattedMessage
id="xpack.transform.transformList.noStatsAvailable"
defaultMessage="No stats available for this transform."
/>
}
/>
</EuiFlexItem>
);
return (
<EuiFlexGroup justifyContent="center" alignItems="center" css={{ height: euiTheme.size.xxxxl }}>
{content}
</EuiFlexGroup>
);
};
function getItemDescription(value: any) {
if (typeof value === 'object') {
return JSON.stringify(value);
}
return value.toString();
}
interface ExpandedRowStatsPaneProps {
item: TransformListRow;
}
export const ExpandedRowStatsPane: FC<ExpandedRowStatsPaneProps> = ({ item }) => {
const { data: fullStats, isError, isLoading } = useGetTransformStats(item.id, false, true);
if (!isTransformListRowWithStats(item) && fullStats === undefined) {
return <NoStatsFallbackTabContent transformsStatsLoading={isLoading} />;
}
let displayStats = {};
if (fullStats !== undefined && !isLoading && !isError) {
displayStats = fullStats.transforms[0].stats;
} else if (isTransformListRowWithStats(item)) {
displayStats = item.stats?.stats;
}
const statsSection: SectionConfig = {
title: i18n.translate('xpack.transform.transformList.transformDetails.statsTitle', {
defaultMessage: 'Stats',
}),
items: Object.entries(displayStats).map((s) => {
return { title: s[0].toString(), description: getItemDescription(s[1]) };
}),
position: 'left',
};
return (
<ExpandedRowColumnView
sections={[statsSection]}
showErrorCallout={isError}
dataTestSubj={'transformStatsTabContent'}
/>
);
};

View file

@ -76,13 +76,7 @@ function getItemIdToExpandedRowMap(
return itemIds.reduce((m: ItemIdToExpandedRowMap, transformId: TransformId) => {
const item = transforms.find((transform) => transform.config.id === transformId);
if (item !== undefined) {
m[transformId] = (
<ExpandedRow
item={item}
onAlertEdit={onAlertEdit}
transformsStatsLoading={transformsStatsLoading}
/>
);
m[transformId] = <ExpandedRow item={item} onAlertEdit={onAlertEdit} />;
}
return m;
}, {} as ItemIdToExpandedRowMap);

View file

@ -102,6 +102,7 @@ export const TransformManagement: FC = () => {
error: transformsStatsErrorMessage,
data: transformsStats,
} = useGetTransformsStats({
basic: true,
enabled: !transformNodesInitialLoading && transformNodes > 0,
});

View file

@ -7,6 +7,10 @@
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import {
getTransformStatsQuerySchema,
type GetTransformStatsQuerySchema,
} from '../../../../common/api_schemas/transforms_stats';
import { addInternalBasePath } from '../../../../common/constants';
import type { RouteDependencies } from '../../../types';
@ -26,13 +30,23 @@ export function registerRoute({ router, license }: RouteDependencies) {
path: addInternalBasePath('transforms/_stats'),
access: 'internal',
})
.addVersion(
.addVersion<
estypes.TransformGetTransformStatsResponse,
GetTransformStatsQuerySchema,
undefined
>(
{
version: '1',
validate: false,
validate: {
request: {
query: getTransformStatsQuerySchema,
},
},
},
license.guardApiRoute<estypes.TransformGetTransformStatsResponse, undefined, undefined>(
routeHandler
)
license.guardApiRoute<
estypes.TransformGetTransformStatsResponse,
GetTransformStatsQuerySchema,
undefined
>(routeHandler)
);
}

View file

@ -9,22 +9,28 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { RequestHandler } from '@kbn/core/server';
import { type GetTransformStatsQuerySchema } from '../../../../common/api_schemas/transforms_stats';
import type { TransformRequestHandlerContext } from '../../../services/license';
import { wrapError, wrapEsError } from '../../utils/error_utils';
export const routeHandler: RequestHandler<
estypes.TransformGetTransformStatsResponse,
undefined,
GetTransformStatsQuerySchema,
undefined,
TransformRequestHandlerContext
> = async (ctx, req, res) => {
try {
const basic = req.query.basic ?? false;
const esClient = (await ctx.core).elasticsearch.client;
const body = await esClient.asCurrentUser.transform.getTransformStats(
{
size: 1000,
transform_id: '_all',
// @ts-expect-error `basic` query option not yet in @elastic/elasticsearch
basic,
},
{ maxRetries: 0 }
);

View file

@ -9,6 +9,10 @@ import {
transformIdParamSchema,
type TransformIdParamSchema,
} from '../../../../common/api_schemas/common';
import {
getTransformStatsQuerySchema,
type GetTransformStatsQuerySchema,
} from '../../../../common/api_schemas/transforms_stats';
import { addInternalBasePath } from '../../../../common/constants';
import type { RouteDependencies } from '../../../types';
@ -30,15 +34,18 @@ export function registerRoute({ router, license }: RouteDependencies) {
path: addInternalBasePath('transforms/{transformId}/_stats'),
access: 'internal',
})
.addVersion<TransformIdParamSchema, undefined, undefined>(
.addVersion<TransformIdParamSchema, GetTransformStatsQuerySchema, undefined>(
{
version: '1',
validate: {
request: {
params: transformIdParamSchema,
query: getTransformStatsQuerySchema,
},
},
},
license.guardApiRoute<TransformIdParamSchema, undefined, undefined>(routeHandler)
license.guardApiRoute<TransformIdParamSchema, GetTransformStatsQuerySchema, undefined>(
routeHandler
)
);
}

View file

@ -8,6 +8,7 @@
import type { RequestHandler } from '@kbn/core/server';
import type { TransformIdParamSchema } from '../../../../common/api_schemas/common';
import type { GetTransformStatsQuerySchema } from '../../../../common/api_schemas/transforms_stats';
import type { TransformRequestHandlerContext } from '../../../services/license';
@ -15,16 +16,20 @@ import { wrapError, wrapEsError } from '../../utils/error_utils';
export const routeHandler: RequestHandler<
TransformIdParamSchema,
undefined,
GetTransformStatsQuerySchema,
undefined,
TransformRequestHandlerContext
> = async (ctx, req, res) => {
const { transformId } = req.params;
try {
const basic = req.query.basic ?? false;
const esClient = (await ctx.core).elasticsearch.client;
const body = await esClient.asCurrentUser.transform.getTransformStats(
{
transform_id: transformId,
// @ts-expect-error `basic` query option not yet in @elastic/elasticsearch
basic,
},
{ maxRetries: 0 }
);