[Infrastructure UI] Add link to k8s dashboard and refactor Inventory UI (#150198)

## Summary

closes #149626

This PR adds a new experimental link to the Inventory UI, that will
redirect the user to the list of managed Kubernetes Dashboards.

- Before the user interacts with the link
<img width="829" alt="image"
src="https://user-images.githubusercontent.com/2767137/216944804-9b6b7462-feef-4770-b49d-86d48eef2dc1.png">

<img width="811" alt="image"
src="https://user-images.githubusercontent.com/2767137/216944862-aa4f997c-5736-4c08-a0d2-a235df5fab56.png">


- After the user interacts with the link
<img width="1359" alt="image"
src="https://user-images.githubusercontent.com/2767137/216944639-27ad8c07-d9ba-4217-89a1-855583e2aa76.png">


### How to test it
pre: Make sure you have `Kubernetes` integration package installed 

- Go to `Observability > Inventory`
- If the button hasn't been clicked yet, the link should be pink and the
badge should be present
- Click on the badge and/or the link
- The list of managed Kubernetes dashboards will render (if you haven't
installed the integration package, the list will be empty)
- Return to the Inventory UI
  - The link color will be blue and the badge will be hidden

We populate a localStorage key named `inventoryUI:k8sDashboardClicked`

### For maintainers

- I've also refactored the Inventory UI `layout.tsx` file, removing some
usages of the `AutoSizer` component in favor of css.
This commit is contained in:
Carlos Crespo 2023-02-07 11:20:10 +01:00 committed by GitHub
parent 8476ee1bbd
commit b249faed4e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 246 additions and 185 deletions

View file

@ -0,0 +1,77 @@
/*
* 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, EuiBetaBadge, EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { LinkDescriptor, useLinkProps } from '@kbn/observability-plugin/public';
import { css } from '@emotion/react';
import { EuiLinkColor } from '@elastic/eui';
import { ExperimentalBadge } from './experimental_badge';
interface Props {
color?: EuiLinkColor;
'data-test-subj'?: string;
experimental?: boolean;
label: string;
link: LinkDescriptor;
hideBadge?: boolean;
onClick?: () => void;
}
export const TryItButton = ({
label,
link,
color = 'primary',
experimental = false,
hideBadge = false,
onClick,
...props
}: Props) => {
const linkProps = useLinkProps({ ...link });
return (
<EuiFlexGroup responsive={false} alignItems="center" gutterSize="m">
{!hideBadge && (
<EuiFlexItem grow={false}>
<EuiLink
data-test-subj={`${props['data-test-subj']}-badge`}
{...linkProps}
onClick={onClick}
>
<EuiBetaBadge
css={css`
cursor: pointer;
`}
color={'accent'}
label={i18n.translate('xpack.infra.layout.tryIt', {
defaultMessage: 'Try it',
})}
/>
</EuiLink>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiLink
data-test-subj={props['data-test-subj']}
{...linkProps}
color={color}
onClick={onClick}
>
<EuiFlexGroup wrap={false} responsive={false} gutterSize="m" alignItems="center">
{experimental && (
<EuiFlexItem grow={false}>
<ExperimentalBadge iconType="beaker" tooltipPosition="top" />
</EuiFlexItem>
)}
<EuiFlexItem>{label}</EuiFlexItem>
</EuiFlexGroup>
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -13,7 +13,7 @@ import { i18n } from '@kbn/i18n';
import { useTrackPageview } from '@kbn/observability-plugin/public';
import { MetricsPageTemplate } from '../../../page_template';
import hostsLandingBeta from './hosts_landing_beta.svg';
import { ExperimentalBadge } from '../experimental_badge';
import { ExperimentalBadge } from '../../../../../components/experimental_badge';
interface Props {
actions?: ReactNode;

View file

@ -21,7 +21,7 @@ import { MetricsDataViewProvider } from './hooks/use_data_view';
import { fullHeightContentStyles } from '../../../page_template.styles';
import { UnifiedSearchProvider } from './hooks/use_unified_search';
import { HostContainer } from './components/hosts_container';
import { ExperimentalBadge } from './components/experimental_badge';
import { ExperimentalBadge } from '../../../components/experimental_badge';
const HOSTS_FEEDBACK_LINK = 'https://ela.st/host-feedback';

View file

@ -5,11 +5,13 @@
* 2.0.
*/
import React, { useCallback, useState, useEffect } from 'react';
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiPanel } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { useUiTracker } from '@kbn/observability-plugin/public';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { TryItButton } from '../../../../components/try_it_button';
import { useWaffleOptionsContext } from '../hooks/use_waffle_options';
import { InfraFormatter } from '../../../../lib/lib';
import { Timeline } from './timeline/timeline';
@ -21,14 +23,41 @@ const hideHistory = i18n.translate('xpack.infra.hideHistory', {
defaultMessage: 'Hide history',
});
const TRANSITION_MS = 300;
export const BottomDrawer: React.FC<{
measureRef: (instance: HTMLElement | null) => void;
interface Props {
interval: string;
formatter: InfraFormatter;
width: number;
}> = ({ measureRef, width, interval, formatter, children }) => {
view: string;
}
const LOCAL_STORAGE_KEY = 'inventoryUI:k8sDashboardClicked';
const KubernetesButton = () => {
const [clicked, setClicked] = useLocalStorage<boolean>(LOCAL_STORAGE_KEY, false);
const clickedRef = useRef<boolean | undefined>(clicked);
return (
<TryItButton
color={clickedRef.current ? 'primary' : 'accent'}
label={i18n.translate('xpack.infra.bottomDrawer.kubernetesDashboardsLink', {
defaultMessage: 'Kubernetes dashboards',
})}
data-test-subj="inventory-kubernetesDashboard-link"
link={{
app: 'dashboards',
hash: '/list',
search: {
_g: '()',
s: 'kubernetes tag:(Managed)',
},
}}
onClick={() => {
if (!clickedRef.current) {
setClicked(true);
}
}}
hideBadge={clickedRef.current}
/>
);
};
export const BottomDrawer = ({ interval, formatter, view }: Props) => {
const { timelineOpen, changeTimelineOpen } = useWaffleOptionsContext();
const [isOpen, setIsOpen] = useState(Boolean(timelineOpen));
@ -44,45 +73,55 @@ export const BottomDrawer: React.FC<{
changeTimelineOpen(!isOpen);
}, [isOpen, trackDrawerOpen, changeTimelineOpen]);
return (
<BottomActionContainer ref={isOpen ? measureRef : null} isOpen={isOpen} outerWidth={width}>
<BottomActionTopBar ref={isOpen ? null : measureRef}>
<EuiFlexItem grow={false}>
<ShowHideButton
aria-expanded={isOpen}
iconType={isOpen ? 'arrowDown' : 'arrowRight'}
onClick={onClick}
data-test-subj="toggleTimelineButton"
>
{isOpen ? hideHistory : showHistory}
</ShowHideButton>
</EuiFlexItem>
</BottomActionTopBar>
<EuiFlexGroup style={{ marginTop: 0 }}>
return view === 'table' ? (
<BottomPanel hasBorder={false} hasShadow={false} borderRadius="none" paddingSize="s">
<KubernetesButton />
</BottomPanel>
) : (
<BottomActionContainer>
<StickyPanel borderRadius="none" paddingSize="s">
<EuiFlexGroup responsive={false} justifyContent="flexStart" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
aria-expanded={isOpen}
iconType={isOpen ? 'arrowDown' : 'arrowRight'}
onClick={onClick}
data-test-subj="toggleTimelineButton"
>
{isOpen ? hideHistory : showHistory}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<KubernetesButton />
</EuiFlexItem>
</EuiFlexGroup>
</StickyPanel>
<EuiFlexGroup
style={{
maxHeight: isOpen ? '224px' : 0,
transition: 'max-height 0.15s ease',
overflow: 'hidden',
}}
>
<Timeline isVisible={isOpen} interval={interval} yAxisFormatter={formatter} />
</EuiFlexGroup>
</BottomActionContainer>
);
};
const BottomActionContainer = euiStyled.div<{ isOpen: boolean; outerWidth: number }>`
padding: ${(props) => props.theme.eui.euiSizeM} 0;
position: fixed;
const BottomActionContainer = euiStyled.div`
position: sticky;
bottom: 0;
right: 0;
transition: transform ${TRANSITION_MS}ms;
transform: translateY(${(props) => (props.isOpen ? 0 : '224px')});
width: ${(props) => props.outerWidth + 34}px;
left: 0;
background: ${(props) => props.theme.eui.euiColorGhost};
width: calc(100% + ${(props) => props.theme.eui.euiSizeL} * 2);
margin-left: -${(props) => props.theme.eui.euiSizeL};
`; // Additional width comes from the padding on the EuiPageBody and inner nodes container
const BottomActionTopBar = euiStyled(EuiFlexGroup).attrs({
justifyContent: 'spaceBetween',
alignItems: 'center',
})`
margin-bottom: 0;
height: 48px;
const BottomPanel = euiStyled(EuiPanel)`
padding: ${(props) => props.theme.eui.euiSizeL} 0;
`;
const ShowHideButton = euiStyled(EuiButtonEmpty).attrs({ size: 's' })`
width: 140px;
const StickyPanel = euiStyled(EuiPanel)`
padding: 0 ${(props) => props.theme.eui.euiSizeL};
`;

View file

@ -1,53 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiBetaBadge, EuiLink } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useLinkProps } from '@kbn/observability-plugin/public';
import { css } from '@emotion/react';
import { ExperimentalBadge } from '../../hosts/components/experimental_badge';
export const HostViewIntroPanel = () => {
const link = useLinkProps({
app: 'metrics',
pathname: '/hosts',
});
return (
<EuiFlexGroup
responsive={false}
alignItems="center"
gutterSize="m"
css={css`
position: relative;
z-index: 100;
`}
>
<EuiFlexItem grow={false}>
<EuiBetaBadge
color={'accent'}
href={link.href ?? ''}
data-test-subj="inventory-hostsView-badge"
label={i18n.translate('xpack.infra.layout.tryIt', {
defaultMessage: 'Try it',
})}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ExperimentalBadge iconType="beaker" tooltipPosition="top" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink data-test-subj="inventory-hostsView-link" {...link}>
{i18n.translate('xpack.infra.layout.hostsLandingPageLink', {
defaultMessage: 'Introducing a new Hosts analysis experience',
})}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -7,9 +7,10 @@
import React, { useCallback, useEffect, useState } from 'react';
import useInterval from 'react-use/lib/useInterval';
import { css } from '@emotion/react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import { i18n } from '@kbn/i18n';
import { SnapshotNode } from '../../../../../common/http_api';
import { SavedView } from '../../../../containers/saved_view/saved_view';
import { AutoSizer } from '../../../../components/auto_sizer';
@ -31,7 +32,7 @@ import { createLegend } from '../lib/create_legend';
import { useWaffleViewState } from '../hooks/use_waffle_view_state';
import { BottomDrawer } from './bottom_drawer';
import { LegendControls } from './waffle/legend_controls';
import { HostViewIntroPanel } from './hosts_view_intro_panel';
import { TryItButton } from '../../../../components/try_it_button';
interface Props {
shouldLoadDefault: boolean;
@ -141,92 +142,83 @@ export const Layout = React.memo(
return (
<>
<PageContent>
<AutoSizer bounds>
{({ measureRef: pageMeasureRef, bounds: { width = 0 } }) => (
<MainContainer ref={pageMeasureRef}>
<AutoSizer bounds>
{({
measureRef: topActionMeasureRef,
bounds: { height: topActionHeight = 0 },
}) => (
<>
<TopActionContainer ref={topActionMeasureRef}>
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
gutterSize="m"
>
<Toolbar nodeType={nodeType} currentTime={currentTime} />
<EuiFlexGroup
responsive={false}
style={{ margin: 0, justifyContent: 'end' }}
>
{view === 'map' && (
<EuiFlexItem grow={false}>
<LegendControls
options={legend != null ? legend : DEFAULT_LEGEND}
dataBounds={dataBounds}
bounds={bounds}
autoBounds={autoBounds}
boundsOverride={boundsOverride}
onChange={handleLegendControlChange}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<ViewSwitcher view={view} onChange={changeView} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</TopActionContainer>
<HostViewIntroPanel />
<AutoSizer bounds>
{({ measureRef, bounds: { height = 0 } }) => (
<>
<NodesOverview
nodes={nodes}
options={options}
nodeType={nodeType}
loading={loading}
showLoading={showLoading}
reload={reload}
onDrilldown={applyFilterQuery}
currentTime={currentTime}
view={view}
autoBounds={autoBounds}
boundsOverride={boundsOverride}
formatter={formatter}
bottomMargin={height}
topMargin={topActionHeight}
/>
{view === 'map' && (
<BottomDrawer
measureRef={measureRef}
interval={interval}
formatter={formatter}
width={width}
/>
)}
</>
)}
</AutoSizer>
</>
<EuiFlexGroup direction="column" gutterSize="s">
<TopActionContainer grow={false}>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center" gutterSize="m">
<Toolbar nodeType={nodeType} currentTime={currentTime} />
<EuiFlexGroup
responsive={false}
css={css`
margin: 0;
justifycontent: 'end';
`}
>
{view === 'map' && (
<EuiFlexItem grow={false}>
<LegendControls
options={legend != null ? legend : DEFAULT_LEGEND}
dataBounds={dataBounds}
bounds={bounds}
autoBounds={autoBounds}
boundsOverride={boundsOverride}
onChange={handleLegendControlChange}
/>
</EuiFlexItem>
)}
</AutoSizer>
</MainContainer>
)}
</AutoSizer>
<EuiFlexItem grow={false}>
<ViewSwitcher view={view} onChange={changeView} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexGroup>
</TopActionContainer>
<EuiFlexItem grow={false}>
<TryItButton
data-test-subj="inventory-hostsView-link"
label={i18n.translate('xpack.infra.layout.hostsLandingPageLink', {
defaultMessage: 'Introducing a new Hosts analysis experience',
})}
link={{
app: 'metrics',
pathname: '/hosts',
}}
experimental
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
position: relative;
flex: 1 1 auto;
`}
>
<AutoSizer bounds>
{({ bounds: { height = 0 } }) => (
<NodesOverview
nodes={nodes}
options={options}
nodeType={nodeType}
loading={loading}
showLoading={showLoading}
reload={reload}
onDrilldown={applyFilterQuery}
currentTime={currentTime}
view={view}
autoBounds={autoBounds}
boundsOverride={boundsOverride}
formatter={formatter}
bottomMargin={height}
/>
)}
</AutoSizer>
</EuiFlexItem>
</EuiFlexGroup>
</PageContent>
<BottomDrawer interval={interval} formatter={formatter} view={view} />
</>
);
}
);
const MainContainer = euiStyled.div`
position: relative;
flex: 1 1 auto;
`;
const TopActionContainer = euiStyled.div`
padding: ${(props) => `12px ${props.theme.eui.euiSizeM}`};
const TopActionContainer = euiStyled(EuiFlexItem)`
padding: ${(props) => `${props.theme.eui.euiSizeM} 0`};
`;

View file

@ -38,7 +38,6 @@ interface Props {
autoBounds: boolean;
formatter: InfraFormatter;
bottomMargin: number;
topMargin: number;
showLoading: boolean;
}
@ -55,7 +54,6 @@ export const NodesOverview = ({
formatter,
onDrilldown,
bottomMargin,
topMargin,
showLoading,
}: Props) => {
const currentBreakpoint = useCurrentEuiBreakpoint();
@ -121,7 +119,7 @@ export const NodesOverview = ({
);
}
return (
<MapContainer top={topMargin} positionStatic={isStatic}>
<MapContainer positionStatic={isStatic}>
<Map
nodeType={nodeType}
nodes={nodes}
@ -148,10 +146,10 @@ const TableContainer = euiStyled.div`
padding: ${(props) => props.theme.eui.euiSizeL};
`;
const MapContainer = euiStyled.div<{ top: number; positionStatic: boolean }>`
const MapContainer = euiStyled.div<{ positionStatic: boolean }>`
position: ${(props) => (props.positionStatic ? 'static' : 'absolute')};
display: flex;
top: ${(props) => props.top}px;
top: 0;
right: 0;
bottom: 0;
left: 0;

View file

@ -115,5 +115,4 @@ const WaffleMapInnerContainer = euiStyled.div`
flex-wrap: wrap;
justify-content: center;
align-content: flex-start;
padding: 10px;
`;

View file

@ -9,6 +9,7 @@ import { EuiErrorBoundary } from '@elastic/eui';
import React from 'react';
import { useTrackPageview } from '@kbn/observability-plugin/public';
import { APP_WRAPPER_CLASS } from '@kbn/core/public';
import { css } from '@emotion/react';
import { FilterBar } from './components/filter_bar';
import { SourceErrorPage } from '../../../components/source_error_page';
import { SourceLoadingPage } from '../../../components/source_loading_page';
@ -64,7 +65,10 @@ export const SnapshotPage = () => {
}}
pageSectionProps={{
contentProps: {
css: fullHeightContentStyles,
css: css`
${fullHeightContentStyles};
padding-bottom: 0;
`,
},
}}
>

View file

@ -163,7 +163,12 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide
async closeTimeline() {
await testSubjects.click('toggleTimelineButton');
await testSubjects.existOrFail('timelineContainerClosed');
const timelineSelectorsVisible = await Promise.all([
testSubjects.exists('timelineContainerClosed'),
testSubjects.exists('timelineContainerOpen'),
]);
return timelineSelectorsVisible.every((visible) => !visible);
},
async openInvenotrySwitcher() {

View file

@ -16,7 +16,7 @@ export function InfraHostsViewProvider({ getService }: FtrProviderContext) {
},
async clickTryHostViewBadge() {
return await testSubjects.click('inventory-hostsView-badge');
return await testSubjects.click('inventory-hostsView-link-badge');
},
async getHostsLandingPageDisabled() {