Skip to content

Commit

Permalink
[Inventory][ECO] Show empty state when no entities are found. (#193755)
Browse files Browse the repository at this point in the history
## Summary

closes #193336 


- Introduce route `GET /internal/inventory/has_data` that check if there
are any entities for hosts, services and containers
- Show empty state
- Refactor a bit the button to add data to reuse it

**Note**:
- I used the illustration we had for the new service inventory as it had
the dark version.
- The callout will be shown by default. (I dismissed before starting the
recording)

### without entities

https://github.com/user-attachments/assets/6c5f7f38-a31c-4801-9e3d-3c91edd88f1d

### with entities


https://github.com/user-attachments/assets/19de8215-1c11-4c36-bae4-00432f205855

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
kpatticha and kibanamachine authored Sep 24, 2024
1 parent b023182 commit 476b9f5
Show file tree
Hide file tree
Showing 9 changed files with 336 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,18 @@ import {
EuiIcon,
EuiPopover,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import {
OBSERVABILITY_ONBOARDING_LOCATOR,
ObservabilityOnboardingLocatorParams,
} from '@kbn/deeplinks-observability';
import { useKibana } from '../../../hooks/use_kibana';
import type { InventoryAddDataParams } from '../../../services/telemetry/types';

const addDataTitle = i18n.translate('xpack.inventory.addDataContextMenu.link', {
defaultMessage: 'Add data',
});
const addDataItem = i18n.translate('xpack.inventory.add.apm.agent.button.', {
defaultMessage: 'Add data',
});

const associateServiceLogsItem = i18n.translate('xpack.inventory.associate.service.logs.button', {
defaultMessage: 'Associate existing service logs',
});

const ASSOCIATE_LOGS_LINK = 'https://ela.st/new-experience-associate-service-logs';
import {
ASSOCIATE_LOGS_LINK,
addDataItem,
addDataTitle,
associateServiceLogsItem,
} from '../../shared/add_data_buttons/buttons';

export function AddDataContextMenu() {
const [popoverOpen, setPopoverOpen] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/*
* 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 {
COLOR_MODES_STANDARD,
EuiCallOut,
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiImage,
EuiLink,
EuiText,
EuiTextColor,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { dashboardsLight, dashboardsDark } from '@kbn/shared-svg';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { AddData, AssociateServiceLogs } from '../shared/add_data_buttons/buttons';
import { useKibana } from '../../hooks/use_kibana';
import { InventoryAddDataParams } from '../../services/telemetry/types';

export function EmptyState() {
const { services } = useKibana();

const [isDismissed, setDismissed] = useLocalStorage<boolean>(
'inventory.emptyStateDismissed',
false
);

function reportButtonClick(journey: InventoryAddDataParams['journey']) {
services.telemetry.reportInventoryAddData({
view: 'empty_state',
journey,
});
}

const { colorMode } = useEuiTheme();

return (
<EuiFlexGroup direction="column">
{!isDismissed && (
<EuiFlexItem>
<EuiCallOut
css={{ textAlign: 'left' }}
onDismiss={() => setDismissed(true)}
title={i18n.translate('xpack.inventory.noEntitiesEmptyState.callout.title', {
defaultMessage: 'Trying for the first time?',
})}
>
<p>
{i18n.translate('xpack.inventory.noEntitiesEmptyState.description', {
defaultMessage:
'It can take a couple of minutes for your entities to show. Try refreshing in a minute or two.',
})}
</p>
<EuiLink
external
target="_blank"
data-test-subj="inventoryEmptyStateLink"
href="https://ela.st/inventory-first-time"
>
{i18n.translate('xpack.inventory.noEntitiesEmptyState.learnMore.link', {
defaultMessage: 'Learn more',
})}
</EuiLink>
</EuiCallOut>
</EuiFlexItem>
)}
<EuiFlexItem>
<EuiEmptyPrompt
hasShadow={false}
hasBorder={false}
id="inventoryEmptyState"
icon={
<EuiImage
size="fullWidth"
src={colorMode === COLOR_MODES_STANDARD.dark ? dashboardsDark : dashboardsLight}
alt=""
/>
}
title={
<h2>
{i18n.translate('xpack.inventory.noEntitiesEmptyState.title', {
defaultMessage: 'No entities available',
})}
</h2>
}
layout={'horizontal'}
color="plain"
body={
<>
<p>
{i18n.translate('xpack.inventory.noEntitiesEmptyState.body.description', {
defaultMessage:
'See all of your observed entities in one place by collecting some data.',
})}
</p>
<EuiHorizontalRule margin="m" />
<EuiText textAlign="left">
<h5>
<EuiTextColor color="default">
{i18n.translate('xpack.inventory.noEntitiesEmptyState.actions.title', {
defaultMessage: 'Start observing your entities:',
})}
</EuiTextColor>
</h5>
</EuiText>
</>
}
actions={
<EuiFlexGroup responsive={false} wrap gutterSize="xl" direction="column">
<EuiFlexGroup direction="row" gutterSize="xs">
<AddData
onClick={() => {
reportButtonClick('add_data');
}}
/>
<AssociateServiceLogs
onClick={() => {
reportButtonClick('associate_existing_service_logs');
}}
/>
</EuiFlexGroup>
</EuiFlexGroup>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,24 @@
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiEmptyPrompt, EuiLoadingLogo } from '@elastic/eui';
import { useKibana } from '../../hooks/use_kibana';
import { SearchBar } from '../search_bar';
import { getEntityManagerEnablement } from './no_data_config';
import { useEntityManager } from '../../hooks/use_entity_manager';
import { Welcome } from '../entity_enablement/welcome_modal';
import { useInventoryAbortableAsync } from '../../hooks/use_inventory_abortable_async';
import { EmptyState } from '../empty_states/empty_state';

const pageTitle = {
pageTitle: i18n.translate('xpack.inventory.inventoryPageHeaderLabel', {
defaultMessage: 'Inventory',
}),
};

export function InventoryPageTemplate({ children }: { children: React.ReactNode }) {
const {
services: { observabilityShared },
services: { observabilityShared, inventoryAPIClient },
} = useKibana();

const { PageTemplate: ObservabilityPageTemplate } = observabilityShared.navigation;
Expand All @@ -32,30 +41,47 @@ export function InventoryPageTemplate({ children }: { children: React.ReactNode
toggleWelcomedModal();
};

const { value = { hasData: false }, loading: hasDataLoading } = useInventoryAbortableAsync(
({ signal }) => {
return inventoryAPIClient.fetch('GET /internal/inventory/has_data', {
signal,
});
},
[inventoryAPIClient]
);

if (isEnablementLoading || hasDataLoading) {
return (
<ObservabilityPageTemplate pageHeader={pageTitle}>
<EuiEmptyPrompt icon={<EuiLoadingLogo logo="logoObservability" size="xl" />} />
</ObservabilityPageTemplate>
);
}

return (
<ObservabilityPageTemplate
noDataConfig={getEntityManagerEnablement({
enabled: isEntityManagerEnabled,
loading: isEnablementLoading,
onSuccess: handleSuccess,
})}
pageHeader={{
pageTitle: i18n.translate('xpack.inventory.inventoryPageHeaderLabel', {
defaultMessage: 'Inventory',
}),
}}
pageHeader={pageTitle}
>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<SearchBar />
</EuiFlexItem>
<EuiFlexItem>
{children}
{showWelcomedModal ? (
<Welcome onClose={toggleWelcomedModal} onConfirm={toggleWelcomedModal} />
) : null}
</EuiFlexItem>
</EuiFlexGroup>
{value.hasData ? (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<SearchBar />
</EuiFlexItem>
<EuiFlexItem>
{children}
{showWelcomedModal ? (
<Welcome onClose={toggleWelcomedModal} onConfirm={toggleWelcomedModal} />
) : null}
</EuiFlexItem>
</EuiFlexGroup>
) : (
<EmptyState />
)}
</ObservabilityPageTemplate>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* 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.
*/

// Disabling it for now until the EUI team fixes it

/* eslint-disable @elastic/eui/href-or-on-click */
import React from 'react';
import {
OBSERVABILITY_ONBOARDING_LOCATOR,
ObservabilityOnboardingLocatorParams,
} from '@kbn/deeplinks-observability';
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../../hooks/use_kibana';

export const addDataTitle = i18n.translate('xpack.inventory.addDataContextMenu.link', {
defaultMessage: 'Add data',
});
export const addDataItem = i18n.translate('xpack.inventory.add.apm.agent.button.', {
defaultMessage: 'Add data',
});

export const associateServiceLogsItem = i18n.translate(
'xpack.inventory.associate.service.logs.button',
{
defaultMessage: 'Associate existing service logs',
}
);

export const ASSOCIATE_LOGS_LINK = 'https://ela.st/new-experience-associate-service-logs';

export function AssociateServiceLogs({ onClick }: { onClick?: () => void }) {
return (
<EuiButton
data-test-subj="associateServiceLogsButton"
size="s"
onClick={onClick}
href={ASSOCIATE_LOGS_LINK}
target="_blank"
iconType="popout"
iconSide="right"
>
{associateServiceLogsItem}
</EuiButton>
);
}

export function AddData({ onClick }: { onClick?: () => void }) {
const {
services: { share },
} = useKibana();
const onboardingLocator = share.url.locators.get<ObservabilityOnboardingLocatorParams>(
OBSERVABILITY_ONBOARDING_LOCATOR
);
return (
<EuiButton
iconType="plusInCircle"
data-test-subj="addDataButton"
size="s"
onClick={onClick}
color="primary"
fill
href={onboardingLocator?.getRedirectUrl({ category: '' })}
>
{addDataItem}
</EuiButton>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface TelemetryServiceSetupParams {
}

export interface InventoryAddDataParams {
view: 'add_data_button';
view: 'add_data_button' | 'empty_state';
journey?: 'add_data' | 'associate_existing_service_logs';
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
*/

import { entitiesRoutes } from './entities/route';
import { hasDataRoutes } from './has_data/route';

export function getGlobalInventoryServerRouteRepository() {
return {
...entitiesRoutes,
...hasDataRoutes,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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 { Logger } from '@kbn/core/server';
import { esqlResultToPlainObjects } from '@kbn/observability-utils/es/utils/esql_result_to_plain_objects';
import { type ObservabilityElasticsearchClient } from '@kbn/observability-utils/es/client/create_observability_es_client';
import {
getEntityDefinitionIdWhereClause,
getEntityTypesWhereClause,
} from '../entities/query_helper';
import { ENTITIES_LATEST_ALIAS } from '../../../common/entities';

export async function getHasData({
inventoryEsClient,
logger,
}: {
inventoryEsClient: ObservabilityElasticsearchClient;
logger: Logger;
}) {
try {
const esqlResults = await inventoryEsClient.esql('get_has_data', {
query: `FROM ${ENTITIES_LATEST_ALIAS}
| ${getEntityDefinitionIdWhereClause()}
| ${getEntityTypesWhereClause()}
| STATS _count = COUNT(*)
| LIMIT 1`,
});

const totalCount = esqlResultToPlainObjects(esqlResults)?.[0]._count ?? 0;

return { hasData: totalCount > 0 };
} catch (e) {
logger.error(e);
return { hasData: false };
}
}
Loading

0 comments on commit 476b9f5

Please sign in to comment.