From 058b28aaade3be5e2eed100f6c35e52802ebf7f0 Mon Sep 17 00:00:00 2001 From: Hugo Kiyodi Oshiro Date: Wed, 6 Nov 2024 10:29:03 +0100 Subject: [PATCH] Plugins: Add install specific version feature in plugins version tab (#93922) --- public/app/features/plugins/admin/api.ts | 16 +- .../admin/components/PluginDetailsBody.tsx | 6 +- .../components/VersionInstallButton.test.tsx | 112 +++++++++++++ .../admin/components/VersionInstallButton.tsx | 150 ++++++++++++++++++ .../admin/components/VersionList.test.tsx | 67 ++++++++ .../plugins/admin/components/VersionList.tsx | 49 +++++- .../features/plugins/admin/state/actions.ts | 2 +- public/locales/en-US/grafana.json | 6 + public/locales/pseudo-LOCALE/grafana.json | 6 + 9 files changed, 403 insertions(+), 11 deletions(-) create mode 100644 public/app/features/plugins/admin/components/VersionInstallButton.test.tsx create mode 100644 public/app/features/plugins/admin/components/VersionInstallButton.tsx create mode 100644 public/app/features/plugins/admin/components/VersionList.test.tsx diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index e281d4675be..45f95e16263 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -156,13 +156,19 @@ export async function getProvisionedPlugins(): Promise { return provisionedPlugins.map((plugin) => ({ slug: plugin.type })); } -export async function installPlugin(id: string) { +export async function installPlugin(id: string, version?: string) { // This will install the latest compatible version based on the logic // on the backend. - return await getBackendSrv().post(`${API_ROOT}/${id}/install`, undefined, { - // Error is displayed in the page - showErrorAlert: false, - }); + return await getBackendSrv().post( + `${API_ROOT}/${id}/install`, + { + version, + }, + { + // Error is displayed in the page + showErrorAlert: false, + } + ); } export async function uninstallPlugin(id: string) { diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx index c9d8907a2b3..6bdcb492fc5 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -56,7 +56,11 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E if (pageId === PluginTabIds.VERSIONS) { return (
- +
); } diff --git a/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx b/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx new file mode 100644 index 00000000000..e052b0aa128 --- /dev/null +++ b/public/app/features/plugins/admin/components/VersionInstallButton.test.tsx @@ -0,0 +1,112 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; + +import { configureStore } from 'app/store/configureStore'; + +import { Version } from '../types'; + +import { VersionInstallButton } from './VersionInstallButton'; + +describe('VersionInstallButton', () => { + it('should show install when no version is installed', () => { + const version: Version = { + version: '', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }; + renderWithStore( + {}} /> + ); + expect(screen.getByText('Install')).toBeInTheDocument(); + }); + + it('should show upgrade when a lower version is installed', () => { + const version: Version = { + version: '1.0.1', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }; + const installedVersion = '1.0.0'; + renderWithStore( + {}} + /> + ); + expect(screen.getByText('Upgrade')).toBeInTheDocument(); + }); + + it('should show downgrade when a lower version is installed', () => { + const version: Version = { + version: '1.0.0', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }; + const installedVersion = '1.0.1'; + renderWithStore( + {}} + /> + ); + expect(screen.getByText('Downgrade')).toBeInTheDocument(); + }); + + it('should ask for confirmation on downgrade', () => { + const version: Version = { + version: '1.0.0', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }; + const installedVersion = '1.0.1'; + renderWithStore( + {}} + /> + ); + expect(screen.getByText('Downgrade')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Downgrade')); + expect(screen.getByText('Downgrade plugin version')).toBeInTheDocument(); + }); + + it('should shown installed text instead of button when version is installed', () => { + const version: Version = { + version: '1.0.0', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }; + const installedVersion = '1.0.0'; + renderWithStore( + {}} + /> + ); + const el = screen.getByText('Installed'); + expect(el).toBeVisible(); + }); +}); + +function renderWithStore(component: JSX.Element) { + const store = configureStore(); + + return render({component}); +} diff --git a/public/app/features/plugins/admin/components/VersionInstallButton.tsx b/public/app/features/plugins/admin/components/VersionInstallButton.tsx new file mode 100644 index 00000000000..46117ca6d96 --- /dev/null +++ b/public/app/features/plugins/admin/components/VersionInstallButton.tsx @@ -0,0 +1,150 @@ +import { css } from '@emotion/css'; +import { useEffect, useState } from 'react'; +import { gt } from 'semver'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { Badge, Button, ConfirmModal, Icon, Spinner, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; + +import { useInstall } from '../state/hooks'; +import { Version } from '../types'; + +const PLUGINS_VERSION_PAGE_INSTALL_INTERACTION_EVENT_NAME = 'plugins_upgrade_clicked'; +const PLUGINS_VERSION_PAGE_CHANGE_INTERACTION_EVENT_NAME = 'plugins_downgrade_clicked'; + +interface Props { + pluginId: string; + version: Version; + latestCompatibleVersion?: string; + installedVersion?: string; + disabled: boolean; + onConfirmInstallation: () => void; +} + +export const VersionInstallButton = ({ + pluginId, + version, + latestCompatibleVersion, + installedVersion, + disabled, + onConfirmInstallation, +}: Props) => { + const install = useInstall(); + const [isInstalling, setIsInstalling] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const styles = useStyles2(getStyles); + + const isDowngrade = installedVersion && gt(installedVersion, version.version); + + useEffect(() => { + if (installedVersion === version.version) { + setIsInstalling(false); + setIsModalOpen(false); + } + }, [installedVersion, version.version]); + + if (version.version === installedVersion) { + return ; + } + + const performInstallation = () => { + const trackProps = { + path: location.pathname, + plugin_id: pluginId, + version: version.version, + is_latest: latestCompatibleVersion === version.version, + creator_team: 'grafana_plugins_catalog', + schema_version: '1.0.0', + }; + + if (!installedVersion) { + reportInteraction(PLUGINS_VERSION_PAGE_INSTALL_INTERACTION_EVENT_NAME, trackProps); + } else { + reportInteraction(PLUGINS_VERSION_PAGE_CHANGE_INTERACTION_EVENT_NAME, { + ...trackProps, + previous_version: installedVersion, + }); + } + + install(pluginId, version.version, true); + setIsInstalling(true); + onConfirmInstallation(); + }; + + const onInstallClick = () => { + if (isDowngrade) { + setIsModalOpen(true); + } else { + performInstallation(); + } + }; + + const onConfirm = () => { + performInstallation(); + }; + + const onDismiss = () => { + setIsModalOpen(false); + }; + + let label = 'Downgrade'; + + if (!installedVersion) { + label = 'Install'; + } else if (gt(version.version, installedVersion)) { + label = 'Upgrade'; + } + + return ( + <> + + + + ); +}; + +function getIcon(label: string) { + if (label === 'Downgrade') { + return ; + } + if (label === 'Upgrade') { + return ; + } + return ''; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + spinner: css({ + marginLeft: theme.spacing(1), + }), + successIcon: css({ + color: theme.colors.success.main, + }), + button: css({ + width: theme.spacing(13), + }), + badge: css({ + width: theme.spacing(13), + justifyContent: 'center', + }), +}); diff --git a/public/app/features/plugins/admin/components/VersionList.test.tsx b/public/app/features/plugins/admin/components/VersionList.test.tsx new file mode 100644 index 00000000000..465209b3e6a --- /dev/null +++ b/public/app/features/plugins/admin/components/VersionList.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; + +import { configureStore } from 'app/store/configureStore'; + +import { VersionList } from './VersionList'; + +describe('VersionList', () => { + it('should only show installs when no version is installed', () => { + const versions = [ + { + version: '1.0.0', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }, + { + version: '1.0.1', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }, + ]; + + renderWithStore(); + const installElements = screen.getAllByText('Install'); + expect(installElements).toHaveLength(versions.length); + }); + + it('should downgrades and upgrades when one intermediate version is installed', () => { + const versions = [ + { + version: '1.0.0', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }, + { + version: '1.0.1', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }, + { + version: '1.0.2', + createdAt: '', + isCompatible: false, + grafanaDependency: null, + }, + ]; + + const installedVersionIndex = 1; + + renderWithStore( + + ); + expect(screen.getAllByText('Installed')).toHaveLength(1); + expect(screen.getAllByText('Downgrade')).toHaveLength(1); + expect(screen.getAllByText('Upgrade')).toHaveLength(1); + }); +}); + +function renderWithStore(component: JSX.Element) { + const store = configureStore(); + + return render({component}); +} diff --git a/public/app/features/plugins/admin/components/VersionList.tsx b/public/app/features/plugins/admin/components/VersionList.tsx index 043490d5ff0..56bc3f4f6d6 100644 --- a/public/app/features/plugins/admin/components/VersionList.tsx +++ b/public/app/features/plugins/admin/components/VersionList.tsx @@ -1,29 +1,48 @@ import { css } from '@emotion/css'; +import { useEffect, useState } from 'react'; +import { satisfies } from 'semver'; import { dateTimeFormatTimeAgo, GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; import { getLatestCompatibleVersion } from '../helpers'; import { Version } from '../types'; +import { VersionInstallButton } from './VersionInstallButton'; + interface Props { + pluginId: string; versions?: Version[]; installedVersion?: string; } -export const VersionList = ({ versions = [], installedVersion }: Props) => { +export const VersionList = ({ pluginId, versions = [], installedVersion }: Props) => { const styles = useStyles2(getStyles); const latestCompatibleVersion = getLatestCompatibleVersion(versions); + const [isInstalling, setIsInstalling] = useState(false); + + const grafanaVersion = config.buildInfo.version; + + useEffect(() => { + setIsInstalling(false); + }, [installedVersion]); + if (versions.length === 0) { return

No version history was found.

; } + const onInstallClick = () => { + setIsInstalling(true); + }; + return ( + @@ -31,6 +50,10 @@ export const VersionList = ({ versions = [], installedVersion }: Props) => { {versions.map((version) => { const isInstalledVersion = installedVersion === version.version; + const versionIsIncompatible = version.grafanaDependency + ? !satisfies(grafanaVersion, version.grafanaDependency, { includePrerelease: true }) + : false; + return ( {/* Version number */} @@ -42,6 +65,18 @@ export const VersionList = ({ versions = [], installedVersion }: Props) => { )} + {/* Install button */} + + {/* Last updated */}
Version Last updated Grafana Dependency
{version.version} + + {dateTimeFormatTimeAgo(version.createdAt)} @@ -60,6 +95,12 @@ const getStyles = (theme: GrafanaTheme2) => ({ container: css({ padding: theme.spacing(2, 4, 3), }), + currentVersion: css({ + fontWeight: theme.typography.fontWeightBold, + }), + spinner: css({ + marginLeft: theme.spacing(1), + }), table: css({ tableLayout: 'fixed', width: '100%', @@ -69,8 +110,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ th: { fontSize: theme.typography.h5.fontSize, }, - }), - currentVersion: css({ - fontWeight: theme.typography.fontWeightBold, + 'tbody tr:nth-child(odd)': { + background: theme.colors.emphasize(theme.colors.background.primary, 0.02), + }, }), }); diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts index d5e761fd29f..b5e765d1864 100644 --- a/public/app/features/plugins/admin/state/actions.ts +++ b/public/app/features/plugins/admin/state/actions.ts @@ -202,7 +202,7 @@ export const install = createAsyncThunk< ? { isInstalled: true, installedVersion: version, hasUpdate: false } : { isInstalled: true, installedVersion: version }; try { - await installPlugin(id); + await installPlugin(id, version); await updatePanels(); if (isUpdating) { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e46df0fb158..eb760013cb2 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2176,6 +2176,12 @@ "name-header": "Name", "update-header": "Update", "update-status-text": "plugins updated" + }, + "versions": { + "confirmation-text-1": "Are you really sure you want to downgrade to version", + "confirmation-text-2": "You should normally not be doing this", + "downgrade-confirm": "Downgrade", + "downgrade-title": "Downgrade plugin version" } }, "details": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index ad2a4bbed2e..466b365e87d 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -2176,6 +2176,12 @@ "name-header": "Ńämę", "update-header": "Ůpđäŧę", "update-status-text": "pľūģįʼnş ūpđäŧęđ" + }, + "versions": { + "confirmation-text-1": "Åřę yőū řęäľľy şūřę yőū ŵäʼnŧ ŧő đőŵʼnģřäđę ŧő vęřşįőʼn", + "confirmation-text-2": "Ÿőū şĥőūľđ ʼnőřmäľľy ʼnőŧ þę đőįʼnģ ŧĥįş", + "downgrade-confirm": "Đőŵʼnģřäđę", + "downgrade-title": "Đőŵʼnģřäđę pľūģįʼn vęřşįőʼn" } }, "details": {