[EPM] Update UI to handle package versions and updates (#64689)

* link to installed version of detail page

* add latestVersion property to EPM get package endpoint

* add updates available notices

* add update package button

* handle various states and send installedVersion from package endpoint

* fix type errors

* fix install error because not returning promises

* track version in state

* handle unsuccessful update attempt

* remove unused variable
This commit is contained in:
Sandra Gonzales 2020-04-29 14:06:08 -04:00 committed by GitHub
parent 3adab85138
commit 53ff22997a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 277 additions and 114 deletions

View file

@ -11,6 +11,7 @@ describe('Ingest Manager - packageToConfig', () => {
name: 'mock-package',
title: 'Mock package',
version: '0.0.0',
latestVersion: '0.0.0',
description: 'description',
type: 'mock',
categories: [],

View file

@ -204,6 +204,8 @@ export interface RegistryVarsEntry {
// internal until we need them
interface PackageAdditions {
title: string;
latestVersion: string;
installedVersion?: string;
assets: AssetsGroupedByServiceByType;
}

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiIcon } from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
export const StyledAlert = styled(EuiIcon)`
color: ${props => props.theme.eui.euiColorWarning};
padding: 0 5px;
`;
export const UpdateIcon = () => <StyledAlert type="alert" size="l" />;

View file

@ -30,9 +30,15 @@ export function PackageCard({
showInstalledBadge,
status,
icons,
...restProps
}: PackageCardProps) {
const { toDetailView } = useLinks();
const url = toDetailView({ name, version });
let urlVersion = version;
// if this is an installed package, link to the version installed
if ('savedObject' in restProps) {
urlVersion = restProps.savedObject.attributes.version || version;
}
const url = toDetailView({ name, version: urlVersion });
return (
<Card

View file

@ -11,6 +11,7 @@ import { NotificationsStart } from 'src/core/public';
import { toMountPoint } from '../../../../../../../../../src/plugins/kibana_react/public';
import { PackageInfo } from '../../../types';
import { sendInstallPackage, sendRemovePackage } from '../../../hooks';
import { useLinks } from '.';
import { InstallStatus } from '../../../types';
interface PackagesInstall {
@ -19,31 +20,55 @@ interface PackagesInstall {
interface PackageInstallItem {
status: InstallStatus;
version: string | null;
}
type InstallPackageProps = Pick<PackageInfo, 'name' | 'version' | 'title'>;
type InstallPackageProps = Pick<PackageInfo, 'name' | 'version' | 'title'> & {
fromUpdate?: boolean;
};
type SetPackageInstallStatusProps = Pick<PackageInfo, 'name'> & PackageInstallItem;
function usePackageInstall({ notifications }: { notifications: NotificationsStart }) {
const { toDetailView } = useLinks();
const [packages, setPackage] = useState<PackagesInstall>({});
const setPackageInstallStatus = useCallback(
({ name, status }: { name: PackageInfo['name']; status: InstallStatus }) => {
({ name, status, version }: SetPackageInstallStatusProps) => {
const packageProps: PackageInstallItem = {
status,
version,
};
setPackage((prev: PackagesInstall) => ({
...prev,
[name]: { status },
[name]: packageProps,
}));
},
[]
);
const getPackageInstallStatus = useCallback(
(pkg: string): PackageInstallItem => {
return packages[pkg];
},
[packages]
);
const installPackage = useCallback(
async ({ name, version, title }: InstallPackageProps) => {
setPackageInstallStatus({ name, status: InstallStatus.installing });
async ({ name, version, title, fromUpdate = false }: InstallPackageProps) => {
const currStatus = getPackageInstallStatus(name);
const newStatus = { ...currStatus, name, status: InstallStatus.installing };
setPackageInstallStatus(newStatus);
const pkgkey = `${name}-${version}`;
const res = await sendInstallPackage(pkgkey);
if (res.error) {
setPackageInstallStatus({ name, status: InstallStatus.notInstalled });
if (fromUpdate) {
// if there is an error during update, set it back to the previous version
// as handling of bad update is not implemented yet
setPackageInstallStatus({ ...currStatus, name });
} else {
setPackageInstallStatus({ name, status: InstallStatus.notInstalled, version });
}
notifications.toasts.addWarning({
title: toMountPoint(
<FormattedMessage
@ -61,8 +86,15 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar
iconType: 'alert',
});
} else {
setPackageInstallStatus({ name, status: InstallStatus.installed });
setPackageInstallStatus({ name, status: InstallStatus.installed, version });
if (fromUpdate) {
const settingsUrl = toDetailView({
name,
version,
panel: 'settings',
});
window.location.href = settingsUrl;
}
notifications.toasts.addSuccess({
title: toMountPoint(
<FormattedMessage
@ -81,24 +113,17 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar
});
}
},
[notifications.toasts, setPackageInstallStatus]
);
const getPackageInstallStatus = useCallback(
(pkg: string): InstallStatus => {
return packages[pkg].status;
},
[packages]
[getPackageInstallStatus, notifications.toasts, setPackageInstallStatus, toDetailView]
);
const uninstallPackage = useCallback(
async ({ name, version, title }: Pick<PackageInfo, 'name' | 'version' | 'title'>) => {
setPackageInstallStatus({ name, status: InstallStatus.uninstalling });
setPackageInstallStatus({ name, status: InstallStatus.uninstalling, version });
const pkgkey = `${name}-${version}`;
const res = await sendRemovePackage(pkgkey);
if (res.error) {
setPackageInstallStatus({ name, status: InstallStatus.installed });
setPackageInstallStatus({ name, status: InstallStatus.installed, version });
notifications.toasts.addWarning({
title: toMountPoint(
<FormattedMessage
@ -116,7 +141,7 @@ function usePackageInstall({ notifications }: { notifications: NotificationsStar
iconType: 'alert',
});
} else {
setPackageInstallStatus({ name, status: InstallStatus.notInstalled });
setPackageInstallStatus({ name, status: InstallStatus.notInstalled, version: null });
notifications.toasts.addSuccess({
title: toMountPoint(

View file

@ -50,7 +50,7 @@ export function Content(props: ContentProps) {
type ContentPanelProps = PackageInfo & Pick<DetailParams, 'panel'>;
export function ContentPanel(props: ContentPanelProps) {
const { panel, name, version, assets, title, removable } = props;
const { panel, name, version, assets, title, removable, latestVersion } = props;
switch (panel) {
case 'settings':
return (
@ -60,6 +60,7 @@ export function ContentPanel(props: ContentPanelProps) {
assets={assets}
title={title}
removable={removable}
latestVersion={latestVersion}
/>
);
case 'data-sources':

View file

@ -20,7 +20,7 @@ export const DataSourcesPanel = ({ name, version }: DataSourcesPanelProps) => {
const packageInstallStatus = getPackageInstallStatus(name);
// if they arrive at this page and the package is not installed, send them to overview
// this happens if they arrive with a direct url or they uninstall while on this tab
if (packageInstallStatus !== InstallStatus.installed)
if (packageInstallStatus.status !== InstallStatus.installed)
return (
<Redirect
to={toDetailView({

View file

@ -13,9 +13,9 @@ import { EPM_PATH } from '../../../../constants';
import { useCapabilities, useLink } from '../../../../hooks';
import { IconPanel } from '../../components/icon_panel';
import { NavButtonBack } from '../../components/nav_button_back';
import { Version } from '../../components/version';
import { useLinks } from '../../hooks';
import { CenterColumn, LeftColumn, RightColumn } from './layout';
import { UpdateIcon } from '../../components/icons';
const FullWidthNavRow = styled(EuiPage)`
/* no left padding so link is against column left edge */
@ -26,19 +26,14 @@ const Text = styled.span`
margin-right: ${props => props.theme.eui.euiSizeM};
`;
const StyledVersion = styled(Version)`
font-size: ${props => props.theme.eui.euiFontSizeS};
color: ${props => props.theme.eui.euiColorDarkShade};
`;
type HeaderProps = PackageInfo & { iconType?: IconType };
export function Header(props: HeaderProps) {
const { iconType, name, title, version } = props;
const { iconType, name, title, version, installedVersion, latestVersion } = props;
const hasWriteCapabilites = useCapabilities().write;
const { toListView } = useLinks();
const ADD_DATASOURCE_URI = useLink(`${EPM_PATH}/${name}-${version}/add-datasource`);
const updateAvailable = installedVersion && installedVersion < latestVersion ? true : false;
return (
<Fragment>
<FullWidthNavRow>
@ -59,7 +54,11 @@ export function Header(props: HeaderProps) {
<EuiTitle size="l">
<h1>
<Text>{title}</Text>
<StyledVersion version={version} />
<EuiTitle size="xs">
<span>
{version} {updateAvailable && <UpdateIcon />}
</span>
</EuiTitle>
</h1>
</EuiTitle>
</CenterColumn>

View file

@ -32,11 +32,12 @@ export function Detail() {
const packageInfo = response.data?.response;
const title = packageInfo?.title;
const name = packageInfo?.name;
const installedVersion = packageInfo?.installedVersion;
const status: InstallStatus = packageInfo?.status as any;
// track install status state
if (name) {
setPackageInstallStatus({ name, status });
setPackageInstallStatus({ name, status, version: installedVersion || null });
}
if (packageInfo) {
setInfo({ ...packageInfo, title: title || '' });
@ -64,7 +65,6 @@ type LayoutProps = PackageInfo & Pick<DetailParams, 'panel'> & Pick<EuiPageProps
export function DetailLayout(props: LayoutProps) {
const { name: packageName, version, icons, restrictWidth } = props;
const iconType = usePackageIconType({ packageName, version, icons });
return (
<Fragment>
<FullWidthHeader>

View file

@ -13,19 +13,21 @@ import { ConfirmPackageUninstall } from './confirm_package_uninstall';
import { ConfirmPackageInstall } from './confirm_package_install';
type InstallationButtonProps = Pick<PackageInfo, 'assets' | 'name' | 'title' | 'version'> & {
disabled: boolean;
disabled?: boolean;
isUpdate?: boolean;
};
export function InstallationButton(props: InstallationButtonProps) {
const { assets, name, title, version, disabled = true } = props;
const { assets, name, title, version, disabled = true, isUpdate = false } = props;
const hasWriteCapabilites = useCapabilities().write;
const installPackage = useInstallPackage();
const uninstallPackage = useUninstallPackage();
const getPackageInstallStatus = useGetPackageInstallStatus();
const installationStatus = getPackageInstallStatus(name);
const { status: installationStatus } = getPackageInstallStatus(name);
const isInstalling = installationStatus === InstallStatus.installing;
const isRemoving = installationStatus === InstallStatus.uninstalling;
const isInstalled = installationStatus === InstallStatus.installed;
const showUninstallButton = isInstalled || isRemoving;
const [isModalVisible, setModalVisible] = useState<boolean>(false);
const toggleModal = useCallback(() => {
setModalVisible(!isModalVisible);
@ -36,6 +38,10 @@ export function InstallationButton(props: InstallationButtonProps) {
toggleModal();
}, [installPackage, name, title, toggleModal, version]);
const handleClickUpdate = useCallback(() => {
installPackage({ name, version, title, fromUpdate: true });
}, [installPackage, name, title, version]);
const handleClickUninstall = useCallback(() => {
uninstallPackage({ name, version, title });
toggleModal();
@ -78,6 +84,15 @@ export function InstallationButton(props: InstallationButtonProps) {
</EuiButton>
);
const updateButton = (
<EuiButton iconType={'refresh'} isLoading={isInstalling} onClick={handleClickUpdate}>
<FormattedMessage
id="xpack.ingestManager.integrations.updatePackage.updatePackageButtonLabel"
defaultMessage="Update to latest version"
/>
</EuiButton>
);
const uninstallButton = (
<EuiButton
iconType={'trash'}
@ -129,7 +144,7 @@ export function InstallationButton(props: InstallationButtonProps) {
return hasWriteCapabilites ? (
<Fragment>
{isInstalled || isRemoving ? uninstallButton : installButton}
{isUpdate ? updateButton : showUninstallButton ? uninstallButton : installButton}
{isModalVisible && (isInstalled ? uninstallModal : installModal)}
</Fragment>
) : null;

View file

@ -8,11 +8,22 @@ import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { EuiSpacer } from '@elastic/eui';
import styled from 'styled-components';
import { InstallStatus, PackageInfo } from '../../../../types';
import { useGetDatasources } from '../../../../hooks';
import { DATASOURCE_SAVED_OBJECT_TYPE } from '../../../../constants';
import { useGetPackageInstallStatus } from '../../hooks';
import { InstallationButton } from './installation_button';
import { UpdateIcon } from '../../components/icons';
const SettingsTitleCell = styled.td`
padding-right: ${props => props.theme.eui.spacerSizes.xl};
padding-bottom: ${props => props.theme.eui.spacerSizes.m};
`;
const UpdatesAvailableMsgContainer = styled.span`
padding-left: ${props => props.theme.eui.spacerSizes.s};
`;
const NoteLabel = () => (
<FormattedMessage
@ -20,18 +31,37 @@ const NoteLabel = () => (
defaultMessage="Note:"
/>
);
const UpdatesAvailableMsg = () => (
<UpdatesAvailableMsgContainer>
<UpdateIcon />
<FormattedMessage
id="xpack.ingestManager.integrations.settings.versionInfo.updatesAvailable"
defaultMessage="Updates are available"
/>
</UpdatesAvailableMsgContainer>
);
export const SettingsPanel = (
props: Pick<PackageInfo, 'assets' | 'name' | 'title' | 'version' | 'removable'>
props: Pick<PackageInfo, 'assets' | 'name' | 'title' | 'version' | 'removable' | 'latestVersion'>
) => {
const { name, title, removable, latestVersion, version } = props;
const getPackageInstallStatus = useGetPackageInstallStatus();
const { data: datasourcesData } = useGetDatasources({
perPage: 0,
page: 1,
kuery: `${DATASOURCE_SAVED_OBJECT_TYPE}.package.name:${props.name}`,
});
const { name, title, removable } = props;
const packageInstallStatus = getPackageInstallStatus(name);
const { status: installationStatus, version: installedVersion } = getPackageInstallStatus(name);
const packageHasDatasources = !!datasourcesData?.total;
const updateAvailable = installedVersion && installedVersion < latestVersion ? true : false;
const isViewingOldPackage = version < latestVersion;
// hide install/remove options if the user has version of the package is installed
// and this package is out of date or if they do have a version installed but it's not this one
const hideInstallOptions =
(installationStatus === InstallStatus.notInstalled && isViewingOldPackage) ||
(installationStatus === InstallStatus.installed && installedVersion !== version);
const isUpdating = installationStatus === InstallStatus.installing && installedVersion;
return (
<EuiText>
<EuiTitle>
@ -43,14 +73,13 @@ export const SettingsPanel = (
</h3>
</EuiTitle>
<EuiSpacer size="s" />
{packageInstallStatus === InstallStatus.notInstalled ||
packageInstallStatus === InstallStatus.installing ? (
{installedVersion !== null && (
<div>
<EuiTitle>
<h4>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageInstallTitle"
defaultMessage="Install {title}"
id="xpack.ingestManager.integrations.settings.packageVersionTitle"
defaultMessage="{title} version"
values={{
title,
}}
@ -58,80 +87,143 @@ export const SettingsPanel = (
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<p>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageInstallDescription"
defaultMessage="Install this integration to setup Kibana and Elasticsearch assets designed for {title} data."
values={{
title,
}}
/>
</p>
<table>
<tbody>
<tr>
<SettingsTitleCell>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.versionInfo.installedVersion"
defaultMessage="Installed version"
/>
</SettingsTitleCell>
<td>
<EuiTitle size="xs">
<span>{installedVersion}</span>
</EuiTitle>
{updateAvailable && <UpdatesAvailableMsg />}
</td>
</tr>
<tr>
<SettingsTitleCell>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.versionInfo.latestVersion"
defaultMessage="Latest version"
/>
</SettingsTitleCell>
<td>
<EuiTitle size="xs">
<span>{latestVersion}</span>
</EuiTitle>
</td>
</tr>
</tbody>
</table>
{updateAvailable && (
<p>
<InstallationButton
{...props}
version={latestVersion}
disabled={false}
isUpdate={true}
/>
</p>
)}
</div>
) : (
)}
{!hideInstallOptions && !isUpdating && (
<div>
<EuiTitle>
<h4>
<EuiSpacer size="s" />
{installationStatus === InstallStatus.notInstalled ||
installationStatus === InstallStatus.installing ? (
<div>
<EuiTitle>
<h4>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageInstallTitle"
defaultMessage="Install {title}"
values={{
title,
}}
/>
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<p>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageInstallDescription"
defaultMessage="Install this integration to setup Kibana and Elasticsearch assets designed for {title} data."
values={{
title,
}}
/>
</p>
</div>
) : (
<div>
<EuiTitle>
<h4>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageUninstallTitle"
defaultMessage="Uninstall {title}"
values={{
title,
}}
/>
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<p>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageUninstallDescription"
defaultMessage="Remove Kibana and Elasticsearch assets that were installed by this Integration."
/>
</p>
</div>
)}
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<p>
<InstallationButton
{...props}
disabled={!datasourcesData || removable === false ? true : packageHasDatasources}
/>
</p>
</EuiFlexItem>
</EuiFlexGroup>
{packageHasDatasources && removable === true && (
<p>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageUninstallTitle"
defaultMessage="Uninstall {title}"
id="xpack.ingestManager.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail"
defaultMessage="{strongNote} {title} cannot be uninstalled because there are active agents that use this integration. To uninstall, remove all {title} data sources from your agent configurations."
values={{
title,
strongNote: (
<strong>
<NoteLabel />
</strong>
),
}}
/>
</h4>
</EuiTitle>
<EuiSpacer size="s" />
<p>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageUninstallDescription"
defaultMessage="Remove Kibana and Elasticsearch assets that were installed by this Integration."
/>
</p>
</p>
)}
{removable === false && (
<p>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail"
defaultMessage="{strongNote} The {title} integration is installed by default and cannot be removed."
values={{
title,
strongNote: (
<strong>
<NoteLabel />
</strong>
),
}}
/>
</p>
)}
</div>
)}
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<p>
<InstallationButton
{...props}
disabled={!datasourcesData || removable === false ? true : packageHasDatasources}
/>
</p>
</EuiFlexItem>
</EuiFlexGroup>
{packageHasDatasources && removable === true && (
<p>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageUninstallNoteDescription.packageUninstallNoteDetail"
defaultMessage="{strongNote} {title} cannot be uninstalled because there are active agents that use this integration. To uninstall, remove all {title} data sources from your agent configurations."
values={{
title,
strongNote: (
<strong>
<NoteLabel />
</strong>
),
}}
/>
</p>
)}
{removable === false && (
<p>
<FormattedMessage
id="xpack.ingestManager.integrations.settings.packageUninstallNoteDescription.packageUninstallUninstallableNoteDetail"
defaultMessage="{strongNote} The {title} integration is installed by default and cannot be removed."
values={{
title,
strongNote: (
<strong>
<NoteLabel />
</strong>
),
}}
/>
</p>
)}
</EuiText>
);
};

View file

@ -37,7 +37,7 @@ export function SideNavLinks({ name, version, active }: NavLinkProps) {
: p.theme.eui.euiFontWeightRegular};
`;
// don't display Data Sources tab if the package is not installed
if (packageInstallStatus !== InstallStatus.installed && panel === 'data-sources')
if (packageInstallStatus.status !== InstallStatus.installed && panel === 'data-sources')
return null;
return (

View file

@ -67,9 +67,10 @@ export async function getPackageInfo(options: {
pkgVersion: string;
}): Promise<PackageInfo> {
const { savedObjectsClient, pkgName, pkgVersion } = options;
const [item, savedObject, assets] = await Promise.all([
const [item, savedObject, latestPackage, assets] = await Promise.all([
Registry.fetchInfo(pkgName, pkgVersion),
getInstallationObject({ savedObjectsClient, pkgName }),
Registry.fetchFindLatestPackage(pkgName),
Registry.getArchiveInfo(pkgName, pkgVersion),
] as const);
// adding `as const` due to regression in TS 3.7.2
@ -79,6 +80,7 @@ export async function getPackageInfo(options: {
// add properties that aren't (or aren't yet) on Registry response
const updated = {
...item,
latestVersion: latestPackage.version,
title: item.title || nameAsTitle(item.name),
assets: Registry.groupPathsByService(assets || []),
};

View file

@ -43,6 +43,7 @@ export function createInstallableFrom<T>(
? {
...from,
status: InstallationStatus.installed,
installedVersion: savedObject.attributes.version,
savedObject,
}
: {

View file

@ -106,7 +106,7 @@ export async function installPackage(options: {
try {
await deleteKibanaSavedObjectsAssets(savedObjectsClient, installedPkg.attributes.installed);
} catch (err) {
// some assets may not exist if deleting during a failed update
// log these errors, some assets may not exist if deleted during a failed update
}
}

View file

@ -121,8 +121,12 @@ export async function deleteKibanaSavedObjectsAssets(
const deletePromises = installedObjects.map(({ id, type }) => {
const assetType = type as AssetType;
if (savedObjectTypes.includes(assetType)) {
savedObjectsClient.delete(assetType, id);
return savedObjectsClient.delete(assetType, id);
}
});
await Promise.all(deletePromises);
try {
await Promise.all(deletePromises);
} catch (err) {
throw new Error('error deleting saved object asset');
}
}