[Search] [Onboarding] Hosted Quick Stats (#207925)

## Summary

This PR updates the `search_indices` Index Details page to support
quicks stats specific to stateful indices.

### Demo


https://github.com/user-attachments/assets/5584f0b4-a7cb-4802-8aef-6708642a4629

### Checklist

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ] [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

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Rodney Norris 2025-01-29 14:24:38 -06:00 committed by GitHub
parent 5be4d61e9f
commit 95d863bc8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 726 additions and 168 deletions

View file

@ -47,6 +47,7 @@ export const SearchIndexDetailsPage = () => {
const tabId = decodeURIComponent(useParams<{ tabId: string }>().tabId);
const {
cloud,
console: consolePlugin,
docLinks,
application,
@ -290,7 +291,12 @@ export const SearchIndexDetailsPage = () => {
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup>
<QuickStats indexDocuments={indexDocuments} index={index} mappings={mappings} />
<QuickStats
isStateless={cloud?.isServerlessEnabled ?? false}
indexDocuments={indexDocuments}
index={index}
mappings={mappings}
/>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -0,0 +1,84 @@
/*
* 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 { useEuiTheme } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SetupAISearchButton } from './setup_ai_search_button';
import { VectorFieldTypes } from './mappings_convertor';
import { QuickStat } from './quick_stat';
export interface AISearchQuickStatProps {
mappingStats: VectorFieldTypes;
vectorFieldCount: number;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const AISearchQuickStat = ({
mappingStats,
vectorFieldCount,
open,
setOpen,
}: AISearchQuickStatProps) => {
const { euiTheme } = useEuiTheme();
return (
<QuickStat
open={open}
setOpen={setOpen}
icon="sparkles"
iconColor={euiTheme.colors.fullShade}
title={i18n.translate('xpack.searchIndices.quickStats.ai_search_heading', {
defaultMessage: 'AI Search',
})}
data-test-subj="QuickStatsAIMappings"
secondaryTitle={
vectorFieldCount > 0
? i18n.translate('xpack.searchIndices.quickStats.total_count', {
defaultMessage: '{value, plural, one {# Field} other {# Fields}}',
values: {
value: vectorFieldCount,
},
})
: i18n.translate('xpack.searchIndices.quickStats.no_vector_fields', {
defaultMessage: 'Not configured',
})
}
content={vectorFieldCount === 0 ? <SetupAISearchButton /> : undefined}
stats={[
{
title: i18n.translate('xpack.searchIndices.quickStats.sparse_vector', {
defaultMessage: 'Sparse Vector',
}),
description: i18n.translate('xpack.searchIndices.quickStats.sparse_vector_count', {
defaultMessage: '{value, plural, one {# Field} other {# Fields}}',
values: { value: mappingStats.sparse_vector },
}),
},
{
title: i18n.translate('xpack.searchIndices.quickStats.dense_vector', {
defaultMessage: 'Dense Vector',
}),
description: i18n.translate('xpack.searchIndices.quickStats.dense_vector_count', {
defaultMessage: '{value, plural, one {# Field} other {# Fields}}',
values: { value: mappingStats.dense_vector },
}),
},
{
title: i18n.translate('xpack.searchIndices.quickStats.semantic_text', {
defaultMessage: 'Semantic Text',
}),
description: i18n.translate('xpack.searchIndices.quickStats.semantic_text_count', {
defaultMessage: '{value, plural, one {# Field} other {# Fields}}',
values: { value: mappingStats.semantic_text },
}),
},
]}
/>
);
};

View file

@ -0,0 +1,52 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiI18nNumber, useEuiTheme } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { QuickStat } from './quick_stat';
import { AliasesContentStyle } from './styles';
export interface AliasesStatProps {
aliases: string[];
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const AliasesStat = ({ aliases, open, setOpen }: AliasesStatProps) => {
const { euiTheme } = useEuiTheme();
return (
<QuickStat
open={open}
setOpen={setOpen}
icon="symlink"
iconColor={euiTheme.colors.fullShade}
title={i18n.translate('xpack.searchIndices.quickStats.aliases_heading', {
defaultMessage: 'Aliases',
})}
data-test-subj="QuickStatsAliases"
secondaryTitle={<EuiI18nNumber value={aliases.length} />}
content={
<EuiFlexGroup
direction="column"
gutterSize="s"
css={AliasesContentStyle}
className="eui-yScroll"
>
{aliases.map((alias, i) => (
<EuiFlexItem key={`alias.${i}`}>
<EuiText size="s" color="subdued">
{alias}
</EuiText>
</EuiFlexItem>
))}
</EuiFlexGroup>
}
stats={[]}
/>
);
};

View file

@ -0,0 +1,40 @@
/*
* 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 { i18n } from '@kbn/i18n';
export const DOCUMENT_COUNT_LABEL = i18n.translate(
'xpack.searchIndices.quickStats.document_count_heading',
{
defaultMessage: 'Document count',
}
);
export const TOTAL_COUNT_LABEL = i18n.translate(
'xpack.searchIndices.quickStats.documents.totalTitle',
{
defaultMessage: 'Total',
}
);
export const DELETED_COUNT_LABEL = i18n.translate(
'xpack.searchIndices.quickStats.documents.deletedTitle',
{
defaultMessage: 'Deleted',
}
);
export const INDEX_SIZE_LABEL = i18n.translate(
'xpack.searchIndices.quickStats.documents.indexSize',
{
defaultMessage: 'Index Size',
}
);
export const DOCUMENT_COUNT_TOOLTIP = i18n.translate(
'xpack.searchIndices.quickStats.documentCountTooltip',
{
defaultMessage:
'This excludes nested documents, which Elasticsearch uses internally to store chunks of vectors.',
}
);

View file

@ -0,0 +1,111 @@
/*
* 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, { useMemo } from 'react';
import { type IndicesStatsIndexMetadataState } from '@elastic/elasticsearch/lib/api/types';
import type { Index } from '@kbn/index-management-shared-types';
import { i18n } from '@kbn/i18n';
import { QuickStat, type QuickStatDefinition } from './quick_stat';
import {
indexHealthToHealthColor,
HealthStatusStrings,
normalizeHealth,
} from '../../utils/indices';
export const healthTitleMap: Record<HealthStatusStrings, string> = {
red: i18n.translate('xpack.searchIndices.quickStats.indexHealth.red', { defaultMessage: 'Red' }),
green: i18n.translate('xpack.searchIndices.quickStats.indexHealth.green', {
defaultMessage: 'Green',
}),
yellow: i18n.translate('xpack.searchIndices.quickStats.indexHealth.yellow', {
defaultMessage: 'Yellow',
}),
unavailable: i18n.translate('xpack.searchIndices.quickStats.indexHealth.unavailable', {
defaultMessage: 'Unavailable',
}),
};
export const statusDescriptionMap: Record<IndicesStatsIndexMetadataState | 'undefined', string> = {
open: i18n.translate('xpack.searchIndices.quickStats.indexStatus.open', {
defaultMessage: 'Index available',
}),
close: i18n.translate('xpack.searchIndices.quickStats.indexStatus.close', {
defaultMessage: 'Index unavailable',
}),
undefined: i18n.translate('xpack.searchIndices.quickStats.indexStatus.undefined', {
defaultMessage: 'Unknown',
}),
};
export interface IndexStatusStatProps {
index: Index;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
function safelyParseShardCount(count: string | number) {
if (typeof count === 'number') return count;
const parsedValue = parseInt(count, 10);
if (!isNaN(parsedValue)) return parsedValue;
return undefined;
}
export const IndexStatusStat = ({ index, open, setOpen }: IndexStatusStatProps) => {
const { replicaShards, stats: indexStats } = useMemo(() => {
let primaryShardCount: number | undefined;
let replicaShardCount: number | undefined;
const stats: QuickStatDefinition[] = [
{
title: i18n.translate('xpack.searchIndices.quickStats.indexStatus.title', {
defaultMessage: 'Status',
}),
description: statusDescriptionMap[index.status ?? 'undefined'],
},
];
if (index.primary) {
primaryShardCount = safelyParseShardCount(index.primary);
stats.push({
title: i18n.translate('xpack.searchIndices.quickStats.indexStatus.primary', {
defaultMessage: 'Primary shards',
}),
description: index.primary,
});
}
if (index.replica) {
replicaShardCount = safelyParseShardCount(index.replica);
stats.push({
title: i18n.translate('xpack.searchIndices.quickStats.indexStatus.replica', {
defaultMessage: 'Replica shards',
}),
description: index.replica,
});
}
return { stats, primaryShards: primaryShardCount, replicaShards: replicaShardCount };
}, [index]);
return (
<QuickStat
open={open}
setOpen={setOpen}
icon="dot"
iconColor={indexHealthToHealthColor(index.health)}
title={healthTitleMap[normalizeHealth(index.health ?? 'unavailable')]}
secondaryTitle={
index.replica &&
i18n.translate('xpack.searchIndices.quickStats.indexStatus.replicaTitle', {
defaultMessage: '{replicaShards, plural, one {# Replica} other {# Replicas}}',
values: {
replicaShards,
},
})
}
data-test-subj="QuickStatsIndexStatus"
stats={indexStats}
statsColumnWidths={[2, 2]}
/>
);
};

View file

@ -8,7 +8,7 @@
import type { MappingProperty, MappingPropertyBase } from '@elastic/elasticsearch/lib/api/types';
import type { Mappings } from '../../types';
interface VectorFieldTypes {
export interface VectorFieldTypes {
semantic_text: number;
dense_vector: number;
sparse_vector: number;

View file

@ -6,7 +6,6 @@
*/
import React from 'react';
import {
EuiAccordion,
EuiDescriptionList,
@ -21,20 +20,22 @@ import {
EuiIconTip,
} from '@elastic/eui';
interface BaseQuickStatProps {
export interface BaseQuickStatProps {
icon: string;
iconColor: string;
title: string;
secondaryTitle: React.ReactNode;
secondaryTitle?: React.ReactNode;
open: boolean;
content?: React.ReactNode;
stats: Array<{
title: string;
description: NonNullable<React.ReactNode>;
}>;
stats: QuickStatDefinition[];
setOpen: (open: boolean) => void;
first?: boolean;
tooltipContent?: string;
statsColumnWidths?: [string | number, string | number] | undefined;
}
export interface QuickStatDefinition {
title: string;
description: NonNullable<React.ReactNode>;
}
export const QuickStat: React.FC<BaseQuickStatProps> = ({
@ -43,11 +44,11 @@ export const QuickStat: React.FC<BaseQuickStatProps> = ({
stats,
open,
setOpen,
first,
secondaryTitle,
iconColor,
content,
tooltipContent,
statsColumnWidths,
...rest
}) => {
const { euiTheme } = useEuiTheme();
@ -60,6 +61,7 @@ export const QuickStat: React.FC<BaseQuickStatProps> = ({
return (
<EuiAccordion
forceState={open ? 'open' : 'closed'}
data-test-subj={id}
onToggle={() => setOpen(!open)}
paddingSize="none"
id={id}
@ -67,8 +69,6 @@ export const QuickStat: React.FC<BaseQuickStatProps> = ({
arrowDisplay="right"
{...rest}
css={{
borderLeft: euiTheme.border.thin,
...(first ? { borderLeftWidth: 0 } : {}),
'.euiAccordion__arrow': {
marginRight: euiTheme.size.s,
},
@ -76,7 +76,6 @@ export const QuickStat: React.FC<BaseQuickStatProps> = ({
background: euiTheme.colors.emptyShade,
},
'.euiAccordion__children': {
borderTop: euiTheme.border.thin,
padding: euiTheme.size.m,
},
}}
@ -84,21 +83,25 @@ export const QuickStat: React.FC<BaseQuickStatProps> = ({
<EuiPanel hasShadow={false} hasBorder={false} paddingSize="s">
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type={icon} color={iconColor} />
<span>
<EuiIcon type={icon} color={iconColor} />
</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<h4>{title}</h4>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{secondaryTitle}
</EuiText>
</EuiFlexItem>
{secondaryTitle && (
<EuiFlexItem grow={false}>
<EuiText size="s" color="subdued">
{secondaryTitle}
</EuiText>
</EuiFlexItem>
)}
{tooltipContent && (
<EuiFlexItem>
<EuiIconTip content={tooltipContent} />
<EuiFlexItem grow={false}>
<EuiIconTip content={tooltipContent} display="block" />
</EuiFlexItem>
)}
</EuiFlexGroup>
@ -113,7 +116,7 @@ export const QuickStat: React.FC<BaseQuickStatProps> = ({
<EuiDescriptionList
type="column"
listItems={stats}
columnWidths={[3, 1]}
columnWidths={statsColumnWidths ?? [3, 1]}
compressed
descriptionProps={{
color: 'subdued',

View file

@ -8,168 +8,93 @@
import React, { useMemo, useState } from 'react';
import type { Index } from '@kbn/index-management-shared-types';
import {
EuiFlexGroup,
EuiFlexItem,
EuiI18nNumber,
EuiPanel,
EuiText,
useEuiTheme,
EuiButton,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiPanel, useEuiTheme } from '@elastic/eui';
import { Mappings } from '../../types';
import { countVectorBasedTypesFromMappings } from './mappings_convertor';
import { QuickStat } from './quick_stat';
import { useKibana } from '../../hooks/use_kibana';
import { IndexDocuments } from '../../hooks/api/use_document_search';
import { AISearchQuickStat } from './ai_search_stat';
import { AliasesStat } from './aliases_quick_stat';
import { StatefulDocumentCountStat } from './stateful_document_count_stat';
import { StatefulIndexStorageStat } from './stateful_storage_stat';
import { IndexStatusStat } from './index_status_stat';
import { QuickStatsContainer } from './quick_stats_container';
import { countVectorBasedTypesFromMappings } from './mappings_convertor';
import { StatelessDocumentCountStat } from './stateless_document_cout_stat';
import { StatelessQuickStats } from './stateless_quick_stats';
import { QuickStatsPanelStyle } from './styles';
export interface QuickStatsProps {
index: Index;
mappings: Mappings;
indexDocuments: IndexDocuments;
tooltipContent?: string;
isStateless: boolean;
}
export const SetupAISearchButton: React.FC = () => {
const {
services: { docLinks },
} = useKibana();
return (
<EuiPanel hasBorder={false} hasShadow={false} color="transparent">
<EuiFlexGroup gutterSize="s" direction="column" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText>
<h6>
{i18n.translate('xpack.searchIndices.quickStats.setup_ai_search_description', {
defaultMessage: 'Build AI-powered search experiences with Elastic',
})}
</h6>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
href={docLinks.links.enterpriseSearch.semanticSearch}
target="_blank"
data-test-subj="setupAISearchButton"
>
{i18n.translate('xpack.searchIndices.quickStats.setup_ai_search_button', {
defaultMessage: 'Set up now',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};
export const QuickStats: React.FC<QuickStatsProps> = ({ index, mappings, indexDocuments }) => {
export const QuickStats: React.FC<QuickStatsProps> = ({
index,
mappings,
indexDocuments,
isStateless,
}) => {
const [open, setOpen] = useState<boolean>(false);
const { euiTheme } = useEuiTheme();
const mappingStats = useMemo(() => countVectorBasedTypesFromMappings(mappings), [mappings]);
const vectorFieldCount =
mappingStats.sparse_vector + mappingStats.dense_vector + mappingStats.semantic_text;
const docCount = indexDocuments?.results._meta.page.total ?? 0;
const { mappingStats, vectorFieldCount } = useMemo(() => {
const stats = countVectorBasedTypesFromMappings(mappings);
const vectorFields = stats.sparse_vector + stats.dense_vector + stats.semantic_text;
return { mappingStats: stats, vectorFieldCount: vectorFields };
}, [mappings]);
const stats = isStateless
? [
<StatelessDocumentCountStat
index={index}
documentCount={indexDocuments?.results._meta.page.total ?? 0}
open={open}
setOpen={setOpen}
/>,
...(Array.isArray(index.aliases) && index.aliases.length > 0
? [<AliasesStat aliases={index.aliases} open={open} setOpen={setOpen} />]
: []),
<AISearchQuickStat
mappingStats={mappingStats}
vectorFieldCount={vectorFieldCount}
open={open}
setOpen={setOpen}
/>,
]
: [
<IndexStatusStat index={index} open={open} setOpen={setOpen} />,
<StatefulIndexStorageStat index={index} open={open} setOpen={setOpen} />,
<StatefulDocumentCountStat
open={open}
setOpen={setOpen}
index={index}
mappingStats={mappingStats}
/>,
...(Array.isArray(index.aliases) && index.aliases.length > 0
? [<AliasesStat aliases={index.aliases} open={open} setOpen={setOpen} />]
: []),
<AISearchQuickStat
mappingStats={mappingStats}
vectorFieldCount={vectorFieldCount}
open={open}
setOpen={setOpen}
/>,
];
return (
<EuiPanel
paddingSize="none"
data-test-subj="quickStats"
hasShadow={false}
css={() => ({
border: euiTheme.border.thin,
background: euiTheme.colors.lightestShade,
overflow: 'hidden',
})}
css={QuickStatsPanelStyle(euiTheme)}
>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem>
<QuickStat
open={open}
setOpen={setOpen}
icon="documents"
iconColor={euiTheme.colors.fullShade}
title={i18n.translate('xpack.searchIndices.quickStats.document_count_heading', {
defaultMessage: 'Document count',
})}
data-test-subj="QuickStatsDocumentCount"
secondaryTitle={<EuiI18nNumber value={docCount ?? 0} />}
stats={[
{
title: i18n.translate('xpack.searchIndices.quickStats.documents.totalTitle', {
defaultMessage: 'Total',
}),
description: <EuiI18nNumber value={docCount ?? 0} />,
},
{
title: i18n.translate('xpack.searchIndices.quickStats.documents.indexSize', {
defaultMessage: 'Index Size',
}),
description: index.size ?? '0b',
},
]}
tooltipContent={i18n.translate('xpack.searchIndices.quickStats.documentCountTooltip', {
defaultMessage:
'This excludes nested documents, which Elasticsearch uses internally to store chunks of vectors.',
})}
first
/>
</EuiFlexItem>
<EuiFlexItem>
<QuickStat
open={open}
setOpen={setOpen}
icon="sparkles"
iconColor={euiTheme.colors.fullShade}
title={i18n.translate('xpack.searchIndices.quickStats.ai_search_heading', {
defaultMessage: 'AI Search',
})}
data-test-subj="QuickStatsAIMappings"
secondaryTitle={
vectorFieldCount > 0
? i18n.translate('xpack.searchIndices.quickStats.total_count', {
defaultMessage: '{value, plural, one {# Field} other {# Fields}}',
values: {
value: vectorFieldCount,
},
})
: i18n.translate('xpack.searchIndices.quickStats.no_vector_fields', {
defaultMessage: 'Not configured',
})
}
content={vectorFieldCount === 0 && <SetupAISearchButton />}
stats={[
{
title: i18n.translate('xpack.searchIndices.quickStats.sparse_vector', {
defaultMessage: 'Sparse Vector',
}),
description: i18n.translate('xpack.searchIndices.quickStats.sparse_vector_count', {
defaultMessage: '{value, plural, one {# Field} other {# Fields}}',
values: { value: mappingStats.sparse_vector },
}),
},
{
title: i18n.translate('xpack.searchIndices.quickStats.dense_vector', {
defaultMessage: 'Dense Vector',
}),
description: i18n.translate('xpack.searchIndices.quickStats.dense_vector_count', {
defaultMessage: '{value, plural, one {# Field} other {# Fields}}',
values: { value: mappingStats.dense_vector },
}),
},
{
title: i18n.translate('xpack.searchIndices.quickStats.semantic_text', {
defaultMessage: 'Semantic Text',
}),
description: i18n.translate('xpack.searchIndices.quickStats.semantic_text_count', {
defaultMessage: '{value, plural, one {# Field} other {# Fields}}',
values: { value: mappingStats.semantic_text },
}),
},
]}
/>
</EuiFlexItem>
</EuiFlexGroup>
{isStateless ? (
<StatelessQuickStats>{stats}</StatelessQuickStats>
) : (
<QuickStatsContainer>{stats}</QuickStatsContainer>
)}
</EuiPanel>
);
};

View file

@ -0,0 +1,26 @@
/*
* 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 { EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { StatsGridContainerStyle, StatsItemStyle } from './styles';
export const QuickStatsContainer = ({ children }: { children: React.ReactNode[] }) => {
const { euiTheme } = useEuiTheme();
return (
<div css={StatsGridContainerStyle}>
{children.map((item, i) =>
item ? (
<EuiFlexItem key={`quickstat.${i}`} css={StatsItemStyle(euiTheme)}>
{item}
</EuiFlexItem>
) : null
)}
</div>
);
};

View file

@ -0,0 +1,44 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText, EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../hooks/use_kibana';
export const SetupAISearchButton: React.FC = () => {
const {
services: { docLinks },
} = useKibana();
return (
<EuiPanel hasBorder={false} hasShadow={false} color="transparent">
<EuiFlexGroup gutterSize="s" direction="column" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText>
<h6>
{i18n.translate('xpack.searchIndices.quickStats.setup_ai_search_description', {
defaultMessage: 'Build AI-powered search experiences with Elastic',
})}
</h6>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
href={docLinks.links.enterpriseSearch.semanticSearch}
target="_blank"
data-test-subj="setupAISearchButton"
>
{i18n.translate('xpack.searchIndices.quickStats.setup_ai_search_button', {
defaultMessage: 'Set up now',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
};

View file

@ -0,0 +1,58 @@
/*
* 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 type { Index } from '@kbn/index-management-shared-types';
import { EuiI18nNumber, useEuiTheme } from '@elastic/eui';
import { QuickStat } from './quick_stat';
import {
DELETED_COUNT_LABEL,
DOCUMENT_COUNT_LABEL,
DOCUMENT_COUNT_TOOLTIP,
TOTAL_COUNT_LABEL,
} from './constants';
import { VectorFieldTypes } from './mappings_convertor';
export interface StatefulDocumentCountStatProps {
index: Index;
mappingStats: VectorFieldTypes;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const StatefulDocumentCountStat = ({
index,
open,
setOpen,
mappingStats,
}: StatefulDocumentCountStatProps) => {
const { euiTheme } = useEuiTheme();
return (
<QuickStat
open={open}
setOpen={setOpen}
icon="documents"
iconColor={euiTheme.colors.fullShade}
title={DOCUMENT_COUNT_LABEL}
data-test-subj="QuickStatsDocumentCount"
secondaryTitle={<EuiI18nNumber value={index.documents ?? 0} />}
stats={[
{
title: TOTAL_COUNT_LABEL,
description: <EuiI18nNumber value={index.documents ?? 0} />,
},
{
title: DELETED_COUNT_LABEL,
description: <EuiI18nNumber value={index.documents_deleted ?? 0} />,
},
]}
tooltipContent={mappingStats.semantic_text > 0 ? DOCUMENT_COUNT_TOOLTIP : undefined}
/>
);
};

View file

@ -0,0 +1,54 @@
/*
* 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 type { Index } from '@kbn/index-management-shared-types';
import { useEuiTheme } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { QuickStat } from './quick_stat';
import { INDEX_SIZE_LABEL } from './constants';
export interface StatefulIndexStorageStatProps {
index: Index;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const StatefulIndexStorageStat = ({
index,
open,
setOpen,
}: StatefulIndexStorageStatProps) => {
const { euiTheme } = useEuiTheme();
return (
<QuickStat
open={open}
setOpen={setOpen}
icon="storage"
iconColor={euiTheme.colors.fullShade}
title={i18n.translate('xpack.searchIndices.quickStats.storage_heading', {
defaultMessage: 'Storage',
})}
data-test-subj="QuickStatsStorage"
secondaryTitle={index.size ?? '0b'}
stats={[
{
title: INDEX_SIZE_LABEL,
description: index.size ?? '0b',
},
{
title: i18n.translate('xpack.searchIndices.quickStats.primarySize_title', {
defaultMessage: 'Primary Size',
}),
description: index.primary_size ?? '0b',
},
]}
/>
);
};

View file

@ -0,0 +1,57 @@
/*
* 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 type { Index } from '@kbn/index-management-shared-types';
import { EuiI18nNumber, useEuiTheme } from '@elastic/eui';
import { QuickStat } from './quick_stat';
import {
DOCUMENT_COUNT_LABEL,
DOCUMENT_COUNT_TOOLTIP,
INDEX_SIZE_LABEL,
TOTAL_COUNT_LABEL,
} from './constants';
export interface StatelessDocumentCountStatProps {
index: Index;
documentCount: number;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const StatelessDocumentCountStat = ({
index,
documentCount,
open,
setOpen,
}: StatelessDocumentCountStatProps) => {
const { euiTheme } = useEuiTheme();
return (
<QuickStat
open={open}
setOpen={setOpen}
icon="documents"
iconColor={euiTheme.colors.fullShade}
title={DOCUMENT_COUNT_LABEL}
data-test-subj="QuickStatsDocumentCount"
secondaryTitle={<EuiI18nNumber value={documentCount} />}
stats={[
{
title: TOTAL_COUNT_LABEL,
description: <EuiI18nNumber value={documentCount} />,
},
{
title: INDEX_SIZE_LABEL,
description: index.size ?? '0b',
},
]}
tooltipContent={DOCUMENT_COUNT_TOOLTIP}
/>
);
};

View file

@ -0,0 +1,28 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { StatsItemStyle } from './styles';
export interface StatelessQuickStatsProps {
children: React.ReactNode[];
}
export const StatelessQuickStats = ({ children }: StatelessQuickStatsProps) => {
const { euiTheme } = useEuiTheme();
return (
<EuiFlexGroup gutterSize="none" wrap>
{children.map((stat, i) => (
<EuiFlexItem key={`stat.${i}`} css={StatsItemStyle(euiTheme)}>
{stat}
</EuiFlexItem>
))}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,29 @@
/*
* 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 { css } from '@emotion/react';
import { type UseEuiTheme } from '@elastic/eui';
export const QuickStatsPanelStyle = (euiTheme: UseEuiTheme['euiTheme']) => css`
background: ${euiTheme.colors.lightestShade};
overflow: hidden;
`;
export const StatsGridContainerStyle = css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
`;
export const StatsItemStyle = (euiTheme: UseEuiTheme['euiTheme']) => css`
border: ${euiTheme.border.thin};
min-width: 250px;
`;
export const AliasesContentStyle = css`
max-height: 100px;
`;

View file

@ -5,6 +5,10 @@
* 2.0.
*/
import type { HealthStatus } from '@elastic/elasticsearch/lib/api/types';
import type { IconColor } from '@elastic/eui';
// see https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html for the current rules
export function isValidIndexName(name: string) {
@ -44,3 +48,19 @@ export function getFirstNewIndexName(startingIndexNames: string[], currentIndexN
}
return undefined;
}
export type HealthStatusStrings = 'red' | 'green' | 'yellow' | 'unavailable';
export const healthColorsMap: Record<HealthStatusStrings, IconColor> = {
red: 'danger',
green: 'success',
yellow: 'warning',
unavailable: '',
};
export const normalizeHealth = (health: HealthStatusStrings | HealthStatus): HealthStatusStrings =>
health.toLowerCase() as HealthStatusStrings;
export const indexHealthToHealthColor = (
health: HealthStatus | 'unavailable' = 'unavailable'
): IconColor => {
return healthColorsMap[normalizeHealth(health)] ?? healthColorsMap.unavailable;
};

View file

@ -50,12 +50,26 @@ export function SearchIndexDetailPageProvider({ getService }: FtrProviderContext
'QuickStatsDocumentCount'
);
expect(await quickStatsDocumentElem.getVisibleText()).to.contain('Document count\n0');
expect(await quickStatsDocumentElem.getVisibleText()).not.to.contain('Index Size\n0b');
expect(await quickStatsDocumentElem.getVisibleText()).not.to.contain('Total\n0');
await quickStatsDocumentElem.click();
expect(await quickStatsDocumentElem.getVisibleText()).to.contain('Index Size\n227b');
expect(await quickStatsDocumentElem.getVisibleText()).to.contain('Total\n0\nDeleted\n0');
},
async expectQuickStatsToHaveIndexStatus() {
await testSubjects.existOrFail('QuickStatsIndexStatus');
},
async expectQuickStatsToHaveIndexStorage(size?: string) {
await testSubjects.existOrFail('QuickStatsStorage');
if (!size) return;
const quickStatsElem = await testSubjects.find('quickStats');
const quickStatsStorageElem = await quickStatsElem.findByTestSubject('QuickStatsStorage');
expect(await quickStatsStorageElem.getVisibleText()).to.contain(`Storage\n${size}`);
},
async expectQuickStatsToHaveDocumentCount(count: number) {
await testSubjects.existOrFail('QuickStatsDocumentCount');
const quickStatsElem = await testSubjects.find('quickStats');
const quickStatsDocumentElem = await quickStatsElem.findByTestSubject(
'QuickStatsDocumentCount'
@ -65,6 +79,7 @@ export function SearchIndexDetailPageProvider({ getService }: FtrProviderContext
async expectQuickStatsAIMappings() {
await testSubjects.existOrFail('quickStats', { timeout: 2000 });
await testSubjects.existOrFail('QuickStatsAIMappings');
const quickStatsElem = await testSubjects.find('quickStats');
const quickStatsAIMappingsElem = await quickStatsElem.findByTestSubject(
'QuickStatsAIMappings'

View file

@ -112,6 +112,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it('should have quick stats', async () => {
await pageObjects.searchIndexDetailsPage.expectQuickStats();
await pageObjects.searchIndexDetailsPage.expectQuickStatsToHaveIndexStatus();
await pageObjects.searchIndexDetailsPage.expectQuickStatsToHaveIndexStorage('227b');
await pageObjects.searchIndexDetailsPage.expectQuickStatsAIMappings();
await es.indices.putMapping({
index: indexName,
@ -187,6 +189,10 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
it('should be able to delete document', async () => {
await pageObjects.searchIndexDetailsPage.changeTab('dataTab');
await pageObjects.searchIndexDetailsPage.clickFirstDocumentDeleteAction();
// re-open page to refresh queries for test (these will auto-refresh,
// but waiting for that will make this test flakey)
await pageObjects.searchNavigation.navigateToIndexDetailPage(indexName);
await pageObjects.searchIndexDetailsPage.expectAddDocumentCodeExamples();
await pageObjects.searchIndexDetailsPage.expectQuickStatsToHaveDocumentCount(0);
});