mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[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:
parent
1e77571395
commit
a2898db525
16 changed files with 510 additions and 368 deletions
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
};
|
||||
|
|
|
@ -174,6 +174,7 @@ export const StepCreateForm: FC<StepCreateFormProps> = React.memo(
|
|||
|
||||
const { data: stats } = useGetTransformStats(
|
||||
transformId,
|
||||
false,
|
||||
progressBarRefetchEnabled,
|
||||
progressBarRefetchInterval
|
||||
);
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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');
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
|
|
|
@ -102,6 +102,7 @@ export const TransformManagement: FC = () => {
|
|||
error: transformsStatsErrorMessage,
|
||||
data: transformsStats,
|
||||
} = useGetTransformsStats({
|
||||
basic: true,
|
||||
enabled: !transformNodesInitialLoading && transformNodes > 0,
|
||||
});
|
||||
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue