Plugins: Add install specific version feature in plugins version tab (#93922)

This commit is contained in:
Hugo Kiyodi Oshiro 2024-11-06 10:29:03 +01:00 committed by GitHub
parent 70e05c6a3a
commit 058b28aaad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 403 additions and 11 deletions

View File

@ -156,13 +156,19 @@ export async function getProvisionedPlugins(): Promise<ProvisionedPlugin[]> {
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) {

View File

@ -56,7 +56,11 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E
if (pageId === PluginTabIds.VERSIONS) {
return (
<div>
<VersionList versions={plugin.details?.versions} installedVersion={plugin.installedVersion} />
<VersionList
pluginId={plugin.id}
versions={plugin.details?.versions}
installedVersion={plugin.installedVersion}
/>
</div>
);
}

View File

@ -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(
<VersionInstallButton pluginId={''} version={version} disabled={false} onConfirmInstallation={() => {}} />
);
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(
<VersionInstallButton
installedVersion={installedVersion}
pluginId={''}
version={version}
disabled={false}
onConfirmInstallation={() => {}}
/>
);
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(
<VersionInstallButton
installedVersion={installedVersion}
pluginId={''}
version={version}
disabled={false}
onConfirmInstallation={() => {}}
/>
);
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(
<VersionInstallButton
installedVersion={installedVersion}
pluginId={''}
version={version}
disabled={false}
onConfirmInstallation={() => {}}
/>
);
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(
<VersionInstallButton
installedVersion={installedVersion}
pluginId={''}
version={version}
disabled={false}
onConfirmInstallation={() => {}}
/>
);
const el = screen.getByText('Installed');
expect(el).toBeVisible();
});
});
function renderWithStore(component: JSX.Element) {
const store = configureStore();
return render(<Provider store={store}>{component}</Provider>);
}

View File

@ -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 <Badge className={styles.badge} text="Installed" icon="check" color="green" />;
}
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 (
<>
<Button
fill="solid"
disabled={disabled || isInstalling}
fullWidth
size="sm"
variant={latestCompatibleVersion === version.version ? 'primary' : 'secondary'}
onClick={onInstallClick}
className={styles.button}
>
{label} {isInstalling ? <Spinner className={styles.spinner} inline size="sm" /> : getIcon(label)}
</Button>
<ConfirmModal
isOpen={isModalOpen}
title={t('plugins.catalog.versions.downgrade-title', 'Downgrade plugin version')}
body={`${t('plugins.catalog.versions.confirmation-text-1', 'Are you really sure you want to downgrade to version')} ${version.version}? ${t('plugins.catalog.versions.confirmation-text-2', 'You should normally not be doing this')}`}
confirmText={t('plugins.catalog.versions.downgrade-confirm', 'Downgrade')}
onConfirm={onConfirm}
onDismiss={onDismiss}
disabled={isInstalling}
confirmButtonVariant="primary"
/>
</>
);
};
function getIcon(label: string) {
if (label === 'Downgrade') {
return <Icon name="arrow-down" />;
}
if (label === 'Upgrade') {
return <Icon name="arrow-up" />;
}
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',
}),
});

View File

@ -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(<VersionList pluginId={''} versions={versions} />);
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(
<VersionList pluginId={''} versions={versions} installedVersion={versions[installedVersionIndex].version} />
);
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(<Provider store={store}>{component}</Provider>);
}

View File

@ -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 <p>No version history was found.</p>;
}
const onInstallClick = () => {
setIsInstalling(true);
};
return (
<table className={styles.table}>
<thead>
<tr>
<th>Version</th>
<th></th>
<th>Last updated</th>
<th>Grafana Dependency</th>
</tr>
@ -31,6 +50,10 @@ export const VersionList = ({ versions = [], installedVersion }: Props) => {
<tbody>
{versions.map((version) => {
const isInstalledVersion = installedVersion === version.version;
const versionIsIncompatible = version.grafanaDependency
? !satisfies(grafanaVersion, version.grafanaDependency, { includePrerelease: true })
: false;
return (
<tr key={version.version}>
{/* Version number */}
@ -42,6 +65,18 @@ export const VersionList = ({ versions = [], installedVersion }: Props) => {
<td>{version.version}</td>
)}
{/* Install button */}
<td>
<VersionInstallButton
pluginId={pluginId}
version={version}
latestCompatibleVersion={latestCompatibleVersion?.version}
installedVersion={installedVersion}
onConfirmInstallation={onInstallClick}
disabled={isInstalledVersion || isInstalling || versionIsIncompatible}
/>
</td>
{/* Last updated */}
<td className={isInstalledVersion ? styles.currentVersion : ''}>
{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),
},
}),
});

View File

@ -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) {

View File

@ -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": {

View File

@ -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": {