Plugins Catalog: Install and show the latest compatible version of a plugin (#41003)

* fix(catalog): prefer rendering installed version over latest version

* feat(catalog): signify installed version in version history

* feat(catalog): introduce installedVersion and latestVersion

* refactor(catalog): use latestVersion for installation, simplify plugindetails header logic

* refactor(catalog): clean up installedVersion and latestVersion

* feat(catalog): use table-layout so versions list table has consistent column widths

* test(catalog): update failing tests

* removed the need of having a latest version in the plugin catalog type root level.

* fixed flaky test depending on what locale it was being running with.

* added missing test to verify version for a remote plugin.

* fixed version in header.

* preventing the UI from break if no versions are available.

* fixed failing test due to missing mock data.

* added todo as a reminder.

* refactor(catalog): prefer grafana plugin icons over gcom notfound images

* refactor(Plugins/Admin): change constant name

* refactor(Plugins/Admin): add comment to make condition easier to understand

* chore: update go modules

* feat(Backend/Plugins): add "dependencies" field to `PluginListItem`

* feat(Plugins/Admin): show the grafana dependency for the installed version

* refactor(Plugins/Admin): use the local version of links

* refactor(Plugins/Admin): prefer the local version for `.type`

* refactor(Plugins/ADmin): prefer the local `.description` field

* fix(Plugins/Admin): fix tests

* test(plugins/api): update the expected response for the `api/plugins` tests

* chore(Plugins/Admin): add todo comments to check preferation of remote/local values

* feat(backend/api): always send the grafana version as a header when proxying to GCOM

* feat(plugins/admin): use the `isCompatible` flag to get the latest compatible version

* feat(plugins/admin): show the latest compatible version in the versions list

* fix(plugins/admin): show the grafana dependency for the latest compatible version

* fix(plugins/admin): update the version list when installing/uninstalling a plugin

* test(plugins/admin): add some test-cases for the latest-compatible-version

* fix(plugins/admin): show the grafana dependency for the installed version (if installed)

* feat(plugins/backend): add the `dependencies.grafanaDependency` property to the plugin object

* test(plugins/backend): fix tests by adjusting expected response json

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
Jack Westbrook 2021-11-12 11:07:12 +01:00 committed by GitHub
parent bf2ece7281
commit 3c3cf2eee9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1612 additions and 1324 deletions

View File

@ -492,7 +492,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/render/*", reqSignedIn, hs.RenderToPng)
// grafana.net proxy
r.Any("/api/gnet/*", reqSignedIn, ProxyGnetRequest)
r.Any("/api/gnet/*", reqSignedIn, hs.ProxyGnetRequest)
// Gravatar service.
avatarCacheServer := avatar.NewCacheServer(hs.Cfg)

View File

@ -34,6 +34,7 @@ type PluginListItem struct {
Enabled bool `json:"enabled"`
Pinned bool `json:"pinned"`
Info *plugins.Info `json:"info"`
Dependencies *plugins.Dependencies `json:"dependencies"`
LatestVersion string `json:"latestVersion"`
HasUpdate bool `json:"hasUpdate"`
DefaultNavUrl string `json:"defaultNavUrl"`

View File

@ -22,7 +22,7 @@ var grafanaComProxyTransport = &http.Transport{
TLSHandshakeTimeout: 10 * time.Second,
}
func ReverseProxyGnetReq(proxyPath string) *httputil.ReverseProxy {
func ReverseProxyGnetReq(proxyPath string, version string) *httputil.ReverseProxy {
url, _ := url.Parse(setting.GrafanaComUrl)
director := func(req *http.Request) {
@ -36,14 +36,17 @@ func ReverseProxyGnetReq(proxyPath string) *httputil.ReverseProxy {
req.Header.Del("Cookie")
req.Header.Del("Set-Cookie")
req.Header.Del("Authorization")
// send the current Grafana version for each request proxied to GCOM
req.Header.Add("grafana-version", version)
}
return &httputil.ReverseProxy{Director: director}
}
func ProxyGnetRequest(c *models.ReqContext) {
func (hs *HTTPServer) ProxyGnetRequest(c *models.ReqContext) {
proxyPath := web.Params(c.Req)["*"]
proxy := ReverseProxyGnetReq(proxyPath)
proxy := ReverseProxyGnetReq(proxyPath, hs.Cfg.BuildVersion)
proxy.Transport = grafanaComProxyTransport
proxy.ServeHTTP(c.Resp, c.Req)
c.Resp.Header().Del("Set-Cookie")

View File

@ -67,6 +67,7 @@ func (hs *HTTPServer) GetPluginList(c *models.ReqContext) response.Response {
Type: string(pluginDef.Type),
Category: pluginDef.Category,
Info: &pluginDef.Info,
Dependencies: &pluginDef.Dependencies,
LatestVersion: pluginDef.GrafanaComVersion,
HasUpdate: pluginDef.GrafanaComHasUpdate,
DefaultNavUrl: pluginDef.DefaultNavURL,

View File

@ -77,8 +77,9 @@ func (e SignatureError) AsErrorCode() ErrorCode {
}
type Dependencies struct {
GrafanaVersion string `json:"grafanaVersion"`
Plugins []Dependency `json:"plugins"`
GrafanaDependency string `json:"grafanaDependency"`
GrafanaVersion string `json:"grafanaVersion"`
Plugins []Dependency `json:"plugins"`
}
type Includes struct {

File diff suppressed because it is too large Load Diff

View File

@ -51,190 +51,284 @@ export default {
{
version: '4.2.2',
createdAt: '2021-08-25T15:03:47.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.2.1',
createdAt: '2021-08-10T19:59:28.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.2.0',
createdAt: '2021-08-10T15:37:58.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.1.5',
createdAt: '2021-05-18T14:52:59.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.1.4',
createdAt: '2021-03-09T14:49:58.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.1.3',
createdAt: '2021-03-05T08:54:12.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.1.2',
createdAt: '2021-01-28T10:15:29.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.1.1',
createdAt: '2020-12-30T11:51:47.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.1.0',
createdAt: '2020-12-28T09:58:47.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.0.2',
createdAt: '2020-11-13T14:34:08.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.0.1',
createdAt: '2020-09-02T15:16:32.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '4.0.0',
createdAt: '2020-08-26T10:36:59.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.12.4',
createdAt: '2020-07-28T08:18:12.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.12.3',
createdAt: '2020-07-17T14:24:28.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.12.2',
createdAt: '2020-05-28T06:46:27.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.12.1',
createdAt: '2020-05-25T07:26:13.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.12.0',
createdAt: '2020-05-21T10:16:59.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.11.0',
createdAt: '2020-03-23T13:29:01.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.10.5',
createdAt: '2019-12-26T15:29:46.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.10.4',
createdAt: '2019-08-08T10:11:23.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.10.3',
createdAt: '2019-07-26T11:59:53.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.10.2',
createdAt: '2019-04-23T17:23:44.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.10.1',
createdAt: '2019-03-05T12:17:20.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.10.0',
createdAt: '2019-02-15T11:20:40.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.9.1',
createdAt: '2018-05-03T08:49:25.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.9.0',
createdAt: '2018-03-23T16:37:53.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.8.1',
createdAt: '2017-12-21T09:30:44.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.8.0',
createdAt: '2017-12-20T14:23:50.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.7.0',
createdAt: '2017-10-24T11:57:08.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.6.1',
createdAt: '2017-07-26T16:23:09.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.6.0',
createdAt: '2017-07-26T15:30:18.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.5.1',
createdAt: '2017-07-10T09:47:25.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.5.0',
createdAt: '2017-07-05T16:58:20.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.4.0',
createdAt: '2017-05-17T13:48:12.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.3.0',
createdAt: '2017-02-10T15:50:27.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.2.1',
createdAt: '2017-02-02T14:20:53.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.2.0',
createdAt: '2016-12-20T18:25:36.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.1.2',
createdAt: '2016-11-09T19:12:05.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.1.1',
createdAt: '2016-09-27T18:05:38.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.1.0',
createdAt: '2016-09-26T19:31:45.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.0.0',
createdAt: '2016-07-04T21:17:55.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.0.0-beta8',
createdAt: '2016-05-02T08:55:24.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.0.0-beta7',
createdAt: '2016-04-14T18:58:43.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.0.0-beta6',
createdAt: '2016-04-14T01:10:31.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.0.0-beta5',
createdAt: '2016-04-12T14:55:31.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.0.0-beta4',
createdAt: '2016-04-10T21:55:49.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '3.0.0-beta3',
createdAt: '2016-04-06T20:23:41.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
],
},

View File

@ -61,7 +61,6 @@ export default {
version: '4.2.2',
updated: '2021-08-25',
},
latestVersion: '',
hasUpdate: false,
defaultNavUrl: '/plugins/alexanderzobnin-zabbix-app/',
category: '',

View File

@ -1,6 +1,6 @@
import { mocked } from 'ts-jest/utils';
import { setBackendSrv } from '@grafana/runtime';
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
import { API_ROOT, GCOM_API_ROOT } from '../constants';
import {
CatalogPlugin,
LocalPlugin,
@ -71,17 +71,17 @@ export const mockPluginApis = ({
...originalBackendSrv,
get: (path: string) => {
// Mock GCOM plugins (remote) if necessary
if (remote && path === `${GRAFANA_API_ROOT}/plugins`) {
if (remote && path === `${GCOM_API_ROOT}/plugins`) {
return Promise.resolve({ items: [remote] });
}
// Mock GCOM single plugin page (remote) if necessary
if (remote && path === `${GRAFANA_API_ROOT}/plugins/${remote.slug}`) {
if (remote && path === `${GCOM_API_ROOT}/plugins/${remote.slug}`) {
return Promise.resolve(remote);
}
// Mock versions
if (versions && path === `${GRAFANA_API_ROOT}/plugins/${remote.slug}/versions`) {
if (versions && path === `${GCOM_API_ROOT}/plugins/${remote.slug}/versions`) {
return Promise.resolve({ items: versions });
}

View File

@ -1,6 +1,6 @@
import { getBackendSrv } from '@grafana/runtime';
import { PluginError, renderMarkdown } from '@grafana/data';
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
import { API_ROOT, GCOM_API_ROOT } from './constants';
import { mergeLocalAndRemote } from './helpers';
import {
PluginDetails,
@ -28,26 +28,19 @@ export async function getPluginDetails(id: string): Promise<CatalogPluginDetails
getPluginVersions(id),
getLocalPluginReadme(id),
]);
const dependencies = remote?.json?.dependencies;
// Prepend semver range when we fallback to grafanaVersion (deprecated in favour of grafanaDependency)
// otherwise plugins cannot be installed.
const grafanaDependency = dependencies?.grafanaDependency
? dependencies?.grafanaDependency
: dependencies?.grafanaVersion
? `>=${dependencies?.grafanaVersion}`
: '';
const dependencies = local?.dependencies || remote?.json?.dependencies;
return {
grafanaDependency,
grafanaDependency: dependencies?.grafanaDependency ?? dependencies?.grafanaVersion ?? '',
pluginDependencies: dependencies?.plugins || [],
links: remote?.json?.info.links || local?.info.links || [],
links: local?.info.links || remote?.json?.info.links || [],
readme: localReadme || remote?.readme,
versions,
};
}
export async function getRemotePlugins(): Promise<RemotePlugin[]> {
const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`);
const res = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins`);
return res.items;
}
@ -77,7 +70,7 @@ export async function getPluginErrors(): Promise<PluginError[]> {
async function getRemotePlugin(id: string, isInstalled: boolean): Promise<RemotePlugin | undefined> {
try {
return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}`, {});
return await getBackendSrv().get(`${GCOM_API_ROOT}/plugins/${id}`, {});
} catch (error) {
// It can happen that GCOM is not available, in that case we show a limited set of information to the user.
error.isHandled = true;
@ -87,11 +80,14 @@ async function getRemotePlugin(id: string, isInstalled: boolean): Promise<Remote
async function getPluginVersions(id: string): Promise<Version[]> {
try {
const versions: { items: PluginVersion[] } = await getBackendSrv().get(
`${GRAFANA_API_ROOT}/plugins/${id}/versions`
);
const versions: { items: PluginVersion[] } = await getBackendSrv().get(`${GCOM_API_ROOT}/plugins/${id}/versions`);
return (versions.items || []).map(({ version, createdAt }) => ({ version, createdAt }));
return (versions.items || []).map((v) => ({
version: v.version,
createdAt: v.createdAt,
isCompatible: v.isCompatible,
grafanaDependency: v.grafanaDependency,
}));
} catch (error) {
// It can happen that GCOM is not available, in that case we show a limited set of information to the user.
error.isHandled = true;
@ -117,14 +113,14 @@ export async function getLocalPlugins(): Promise<LocalPlugin[]> {
}
async function getOrg(slug: string): Promise<Org> {
const org = await getBackendSrv().get(`${GRAFANA_API_ROOT}/orgs/${slug}`);
return { ...org, avatarUrl: `${GRAFANA_API_ROOT}/orgs/${slug}/avatar` };
const org = await getBackendSrv().get(`${GCOM_API_ROOT}/orgs/${slug}`);
return { ...org, avatarUrl: `${GCOM_API_ROOT}/orgs/${slug}/avatar` };
}
export async function installPlugin(id: string, version: string) {
return await getBackendSrv().post(`${API_ROOT}/${id}/install`, {
version,
});
export async function installPlugin(id: string) {
// This will install the latest compatible version based on the logic
// on the backend.
return await getBackendSrv().post(`${API_ROOT}/${id}/install`);
}
export async function uninstallPlugin(id: string) {

View File

@ -1,7 +1,7 @@
import React from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2, PluginType } from '@grafana/data';
import { Tooltip, useStyles2 } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui';
import { CatalogPlugin } from '../../types';
type Props = {
@ -11,12 +11,9 @@ type Props = {
export function PluginUpdateAvailableBadge({ plugin }: Props): React.ReactElement | null {
const styles = useStyles2(getStyles);
// Currently renderer plugins are not supported by the catalog due to complications related to installation / update / uninstall.
if (plugin.hasUpdate && !plugin.isCore && plugin.type !== PluginType.renderer) {
return (
<Tooltip content={plugin.version}>
<p className={styles.hasUpdate}>Update available!</p>
</Tooltip>
);
return <p className={styles.hasUpdate}>Update available!</p>;
}
return null;

View File

@ -3,15 +3,16 @@ import { AppEvents } from '@grafana/data';
import { Button, HorizontalGroup, ConfirmModal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { CatalogPlugin, PluginStatus } from '../../types';
import { CatalogPlugin, PluginStatus, Version } from '../../types';
import { useInstallStatus, useUninstallStatus, useInstall, useUninstall } from '../../state/hooks';
type InstallControlsButtonProps = {
plugin: CatalogPlugin;
pluginStatus: PluginStatus;
latestCompatibleVersion?: Version;
};
export function InstallControlsButton({ plugin, pluginStatus }: InstallControlsButtonProps) {
export function InstallControlsButton({ plugin, pluginStatus, latestCompatibleVersion }: InstallControlsButtonProps) {
const { isInstalling, error: errorInstalling } = useInstallStatus();
const { isUninstalling, error: errorUninstalling } = useUninstallStatus();
const install = useInstall();
@ -22,7 +23,7 @@ export function InstallControlsButton({ plugin, pluginStatus }: InstallControlsB
const uninstallBtnText = isUninstalling ? 'Uninstalling' : 'Uninstall';
const onInstall = async () => {
await install(plugin.id, plugin.version);
await install(plugin.id, latestCompatibleVersion?.version);
if (!errorInstalling) {
appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]);
}
@ -37,7 +38,7 @@ export function InstallControlsButton({ plugin, pluginStatus }: InstallControlsB
};
const onUpdate = async () => {
await install(plugin.id, plugin.version, true);
await install(plugin.id, latestCompatibleVersion?.version, true);
if (!errorInstalling) {
appEvents.emit(AppEvents.alertSuccess, [`Updated ${plugin.name}`]);
}

View File

@ -1,6 +1,5 @@
import React from 'react';
import { css } from '@emotion/css';
import { satisfies } from 'semver';
import { config } from '@grafana/runtime';
import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
@ -8,27 +7,23 @@ import { GrafanaTheme2, PluginType } from '@grafana/data';
import { ExternallyManagedButton } from './ExternallyManagedButton';
import { InstallControlsButton } from './InstallControlsButton';
import { CatalogPlugin, PluginStatus } from '../../types';
import { CatalogPlugin, PluginStatus, Version } from '../../types';
import { getExternalManageLink } from '../../helpers';
import { useIsRemotePluginsAvailable } from '../../state/hooks';
import { isGrafanaAdmin } from '../../permissions';
interface Props {
plugin: CatalogPlugin;
latestCompatibleVersion?: Version;
}
export const InstallControls = ({ plugin }: Props) => {
export const InstallControls = ({ plugin, latestCompatibleVersion }: Props) => {
const styles = useStyles2(getStyles);
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
const hasPermission = isGrafanaAdmin();
const grafanaDependency = plugin.details?.grafanaDependency;
const isRemotePluginsAvailable = useIsRemotePluginsAvailable();
const unsupportedGrafanaVersion = grafanaDependency
? !satisfies(config.buildInfo.version, grafanaDependency, {
// needed for when running against main
includePrerelease: true,
})
: false;
const isCompatible = Boolean(latestCompatibleVersion);
const pluginStatus = plugin.isInstalled
? plugin.hasUpdate
? PluginStatus.UPDATE
@ -68,7 +63,7 @@ export const InstallControls = ({ plugin }: Props) => {
return <div className={styles.message}>{message}</div>;
}
if (unsupportedGrafanaVersion) {
if (!isCompatible) {
return (
<div className={styles.message}>
<Icon name="exclamation-triangle" />
@ -89,7 +84,13 @@ export const InstallControls = ({ plugin }: Props) => {
);
}
return <InstallControlsButton plugin={plugin} pluginStatus={pluginStatus} />;
return (
<InstallControlsButton
plugin={plugin}
pluginStatus={pluginStatus}
latestCompatibleVersion={latestCompatibleVersion}
/>
);
};
export const getStyles = (theme: GrafanaTheme2) => {

View File

@ -34,7 +34,7 @@ export function PluginDetailsBody({ plugin, queryParams }: Props): JSX.Element {
if (pageId === PluginTabIds.VERSIONS) {
return (
<div className={styles.container}>
<VersionList versions={plugin.details?.versions} />
<VersionList versions={plugin.details?.versions} installedVersion={plugin.installedVersion} />
</div>
);
}

View File

@ -10,6 +10,7 @@ import { PluginLogo } from './PluginLogo';
import { CatalogPlugin } from '../types';
import { PluginDisabledBadge } from './Badges';
import { GetStartedWithPlugin } from './GetStartedWithPlugin';
import { getLatestCompatibleVersion } from '../helpers';
type Props = {
currentUrl: string;
@ -19,6 +20,8 @@ type Props = {
export function PluginDetailsHeader({ plugin, currentUrl, parentUrl }: Props): React.ReactElement {
const styles = useStyles2(getStyles);
const latestCompatibleVersion = getLatestCompatibleVersion(plugin.details?.versions);
const version = plugin.installedVersion || latestCompatibleVersion?.version;
return (
<div className={styles.headerContainer}>
@ -69,8 +72,8 @@ export function PluginDetailsHeader({ plugin, currentUrl, parentUrl }: Props): R
</span>
)}
{/* Latest version */}
{plugin.version && <span>{plugin.version}</span>}
{/* Version */}
{Boolean(version) && <span>{version}</span>}
{/* Signature information */}
<PluginDetailsHeaderSignature plugin={plugin} />
@ -80,13 +83,14 @@ export function PluginDetailsHeader({ plugin, currentUrl, parentUrl }: Props): R
<PluginDetailsHeaderDependencies
plugin={plugin}
latestCompatibleVersion={latestCompatibleVersion}
className={cx(styles.headerInformationRow, styles.headerInformationRowSecondary)}
/>
<p>{plugin.description}</p>
<HorizontalGroup height="auto">
<InstallControls plugin={plugin} />
<InstallControls plugin={plugin} latestCompatibleVersion={latestCompatibleVersion} />
<GetStartedWithPlugin plugin={plugin} />
</HorizontalGroup>
</div>

View File

@ -2,17 +2,24 @@ import React from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Icon } from '@grafana/ui';
import { CatalogPlugin, PluginIconName } from '../types';
import { Version, CatalogPlugin, PluginIconName } from '../types';
type Props = {
plugin: CatalogPlugin;
latestCompatibleVersion?: Version;
className?: string;
};
export function PluginDetailsHeaderDependencies({ plugin, className }: Props): React.ReactElement | null {
export function PluginDetailsHeaderDependencies({
plugin,
latestCompatibleVersion,
className,
}: Props): React.ReactElement | null {
const styles = useStyles2(getStyles);
const pluginDependencies = plugin.details?.pluginDependencies;
const grafanaDependency = plugin.details?.grafanaDependency;
const grafanaDependency = plugin.isInstalled
? plugin.details?.grafanaDependency
: latestCompatibleVersion?.grafanaDependency || plugin.details?.grafanaDependency;
const hasNoDependencyInfo = !grafanaDependency && (!pluginDependencies || !pluginDependencies.length);
if (hasNoDependencyInfo) {

View File

@ -46,7 +46,6 @@ describe('PluginListItem', () => {
signature: PluginSignatureStatus.valid,
publishedAt: '2020-09-01',
updatedAt: '2021-06-28',
version: '1.0.0',
hasUpdate: false,
isInstalled: false,
isCore: false,

View File

@ -22,7 +22,6 @@ describe('PluginListItemBadges', () => {
signature: PluginSignatureStatus.valid,
publishedAt: '2020-09-01',
updatedAt: '2021-06-28',
version: '1.0.0',
hasUpdate: false,
isInstalled: false,
isCore: false,
@ -63,8 +62,13 @@ describe('PluginListItemBadges', () => {
expect(screen.getByRole('button', { name: /learn more/i })).toBeInTheDocument();
});
it('renders a error badge (when plugin has an error', () => {
it('renders a error badge (when plugin has an error)', () => {
render(<PluginListItemBadges plugin={{ ...plugin, isDisabled: true, error: PluginErrorCode.modifiedSignature }} />);
expect(screen.getByText(/disabled/i)).toBeVisible();
});
it('renders an upgrade badge (when plugin has an available update)', () => {
render(<PluginListItemBadges plugin={{ ...plugin, hasUpdate: true, installedVersion: '0.0.9' }} />);
expect(screen.getByText(/update available/i)).toBeVisible();
});
});

View File

@ -4,13 +4,16 @@ import { css } from '@emotion/css';
import { dateTimeFormatTimeAgo, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Version } from '../types';
import { getLatestCompatibleVersion } from '../helpers';
interface Props {
versions?: Version[];
installedVersion?: string;
}
export const VersionList = ({ versions = [] }: Props) => {
export const VersionList = ({ versions = [], installedVersion }: Props) => {
const styles = useStyles2(getStyles);
const latestCompatibleVersion = getLatestCompatibleVersion(versions);
if (versions.length === 0) {
return <p>No version history was found.</p>;
@ -26,10 +29,22 @@ export const VersionList = ({ versions = [] }: Props) => {
</thead>
<tbody>
{versions.map((version) => {
const isInstalledVersion = installedVersion === version.version;
return (
<tr key={version.version}>
<td>{version.version}</td>
<td>{dateTimeFormatTimeAgo(version.createdAt)}</td>
{/* Version number */}
{isInstalledVersion ? (
<td className={styles.currentVersion}>{version.version} (installed version)</td>
) : version.version === latestCompatibleVersion?.version ? (
<td>{version.version} (latest compatible version)</td>
) : (
<td>{version.version}</td>
)}
{/* Last updated */}
<td className={isInstalledVersion ? styles.currentVersion : ''}>
{dateTimeFormatTimeAgo(version.createdAt)}
</td>
</tr>
);
})}
@ -43,6 +58,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: ${theme.spacing(2, 4, 3)};
`,
table: css`
table-layout: fixed;
width: 100%;
td,
th {
@ -52,4 +68,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
font-size: ${theme.typography.h5.fontSize};
}
`,
currentVersion: css`
font-weight: ${theme.typography.fontWeightBold};
`,
});

View File

@ -1,5 +1,5 @@
export const API_ROOT = '/api/plugins';
export const GRAFANA_API_ROOT = '/api/gnet';
export const GCOM_API_ROOT = '/api/gnet';
// Used for prefixing the Redux actions
export const STATE_PREFIX = 'plugins';

View File

@ -103,7 +103,6 @@ describe('Plugins/Helpers', () => {
signature: 'valid',
type: 'app',
updatedAt: '2021-05-18T14:53:01.000Z',
version: '4.1.5',
});
});
@ -164,7 +163,7 @@ describe('Plugins/Helpers', () => {
signatureType: 'community',
type: 'app',
updatedAt: '2021-08-25',
version: '4.2.2',
installedVersion: '4.2.2',
});
});
@ -211,18 +210,18 @@ describe('Plugins/Helpers', () => {
signatureType: 'community',
type: 'app',
updatedAt: '2021-05-18T14:53:01.000Z',
version: '4.1.5',
installedVersion: '4.2.2',
});
});
test('`.description` - prefers the remote', () => {
test('`.description` - prefers the local', () => {
// Local & Remote
expect(
mapToCatalogPlugin(
{ ...localPlugin, info: { ...localPlugin.info, description: 'Local description' } },
{ ...remotePlugin, description: 'Remote description' }
)
).toMatchObject({ description: 'Remote description' });
).toMatchObject({ description: 'Local description' });
// Remote only
expect(mapToCatalogPlugin(undefined, { ...remotePlugin, description: 'Remote description' })).toMatchObject({
@ -239,31 +238,9 @@ describe('Plugins/Helpers', () => {
});
test('`.hasUpdate` - prefers the local', () => {
// Local & Remote (only if the remote version is greater than the local one)
expect(
mapToCatalogPlugin(
{ ...localPlugin, info: { ...localPlugin.info, version: '2.0.0' } },
{ ...remotePlugin, version: '2.1.0' }
)
).toMatchObject({ hasUpdate: true });
expect(
mapToCatalogPlugin(
{ ...localPlugin, info: { ...localPlugin.info, version: '2.1.0' } },
{ ...remotePlugin, version: '2.1.0' }
)
).toMatchObject({ hasUpdate: false });
// Remote only
expect(mapToCatalogPlugin(undefined, { ...remotePlugin, version: '2.1.0' })).toMatchObject({
hasUpdate: false,
});
// Local only
expect(mapToCatalogPlugin({ ...localPlugin })).toMatchObject({ hasUpdate: false });
expect(mapToCatalogPlugin({ ...localPlugin, hasUpdate: true })).toMatchObject({ hasUpdate: true });
expect(mapToCatalogPlugin({ ...localPlugin, info: { ...localPlugin.info, version: '2.1.0' } })).toMatchObject({
hasUpdate: false,
});
// No local or remote
expect(mapToCatalogPlugin()).toMatchObject({ hasUpdate: false });
@ -421,7 +398,7 @@ describe('Plugins/Helpers', () => {
expect(mapToCatalogPlugin()).toMatchObject({ publishedAt: '' });
});
test('`.type` - prefers the remote', () => {
test('`.type` - prefers the local', () => {
// Local & Remote
expect(
mapToCatalogPlugin(
@ -429,7 +406,7 @@ describe('Plugins/Helpers', () => {
{ ...remotePlugin, typeCode: PluginType.datasource }
)
).toMatchObject({
type: PluginType.datasource,
type: PluginType.app,
});
// Remote only

View File

@ -1,9 +1,8 @@
import { config } from '@grafana/runtime';
import { gt } from 'semver';
import { PluginSignatureStatus, dateTimeParse, PluginError, PluginErrorCode } from '@grafana/data';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { Settings } from 'app/core/config';
import { CatalogPlugin, LocalPlugin, RemotePlugin } from './types';
import { CatalogPlugin, LocalPlugin, RemotePlugin, Version } from './types';
export function mergeLocalsAndRemotes(
local: LocalPlugin[] = [],
@ -62,7 +61,7 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
} = plugin;
const isDisabled = !!error;
const catalogPlugin = {
return {
description,
downloads,
id,
@ -78,7 +77,6 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
publishedAt,
signature: getPluginSignature({ remote: plugin, error }),
updatedAt,
version,
hasUpdate: false,
isInstalled: isDisabled,
isDisabled: isDisabled,
@ -88,7 +86,6 @@ export function mapRemoteToCatalog(plugin: RemotePlugin, error?: PluginError): C
type: typeCode,
error: error?.errorCode,
};
return catalogPlugin;
}
export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): CatalogPlugin {
@ -101,6 +98,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
type,
signatureOrg,
signatureType,
hasUpdate,
} = plugin;
return {
@ -116,8 +114,8 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
signatureOrg,
signatureType,
updatedAt: updated,
version,
hasUpdate: false,
installedVersion: version,
hasUpdate,
isInstalled: true,
isDisabled: !!error,
isCore: signature === 'internal',
@ -128,31 +126,31 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
};
}
// TODO: change the signature by removing the optionals for local and remote.
export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, error?: PluginError): CatalogPlugin {
const version = remote?.version || local?.info.version || '';
const hasUpdate =
local?.hasUpdate || Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version));
const installedVersion = local?.info.version;
const id = remote?.slug || local?.id || '';
const type = local?.type || remote?.typeCode;
const isDisabled = !!error;
let logos = {
small: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small',
large: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/large',
small: `/public/img/icn-${type}.svg`,
large: `/public/img/icn-${type}.svg`,
};
if (remote) {
logos = {
small: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/small`,
large: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/large`,
small: `https://grafana.com/api/plugins/${id}/versions/${remote.version}/logos/small`,
large: `https://grafana.com/api/plugins/${id}/versions/${remote.version}/logos/large`,
};
} else if (local && local.info.logos) {
logos = local.info.logos;
}
return {
description: remote?.description || local?.info.description || '',
description: local?.info.description || remote?.description || '',
downloads: remote?.downloads || 0,
hasUpdate,
hasUpdate: local?.hasUpdate || false,
id,
info: {
logos,
@ -162,16 +160,19 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin, e
isEnterprise: remote?.status === 'enterprise',
isInstalled: Boolean(local) || isDisabled,
isDisabled: isDisabled,
// TODO<check if we would like to keep preferring the remote version>
name: remote?.name || local?.name || '',
// TODO<check if we would like to keep preferring the remote version>
orgName: remote?.orgName || local?.info.author.name || '',
popularity: remote?.popularity || 0,
publishedAt: remote?.createdAt || '',
type: remote?.typeCode || local?.type,
type,
signature: getPluginSignature({ local, remote, error }),
signatureOrg: local?.signatureOrg || remote?.versionSignedByOrgName,
signatureType: local?.signatureType || remote?.versionSignatureType || remote?.signatureType || undefined,
// TODO<check if we would like to keep preferring the remote version>
updatedAt: remote?.updatedAt || local?.info.updated || '',
version,
installedVersion,
error: error?.errorCode,
};
}
@ -247,3 +248,12 @@ export const updatePanels = () =>
.then((settings: Settings) => {
config.panels = settings.panels;
});
export function getLatestCompatibleVersion(versions: Version[] | undefined): Version | undefined {
if (!versions) {
return;
}
const [latest] = versions.filter((v) => Boolean(v.isCompatible));
return latest;
}

View File

@ -1,17 +1,24 @@
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, RenderResult, waitFor } from '@testing-library/react';
import { getDefaultNormalizer, render, RenderResult, SelectorMatcherOptions, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { config } from '@grafana/runtime';
import { mockPluginApis, getCatalogPluginMock, getPluginsStateMock, mockUserPermissions } from '../__mocks__';
import { configureStore } from 'app/store/configureStore';
import PluginDetailsPage from './PluginDetails';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { CatalogPlugin, PluginTabIds, ReducerState, RequestStatus } from '../types';
import {
CatalogPlugin,
CatalogPluginDetails,
PluginTabIds,
PluginTabLabels,
ReducerState,
RequestStatus,
} from '../types';
import * as api from '../api';
import { fetchRemotePlugins } from '../state/actions';
import { PluginErrorCode, PluginSignatureStatus, PluginType } from '@grafana/data';
import { PluginErrorCode, PluginSignatureStatus, PluginType, dateTimeFormatTimeAgo } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
jest.mock('@grafana/runtime', () => {
@ -122,16 +129,38 @@ describe('Plugin details page', () => {
});
it('should display the number of downloads in the header', async () => {
// depending on what locale you have the Intl.NumberFormat will return a format that contains
// whitespaces. In that case we don't want testing library to remove whitespaces.
const downloads = 24324;
const options: SelectorMatcherOptions = { normalizer: getDefaultNormalizer({ collapseWhitespace: false }) };
const expected = new Intl.NumberFormat().format(downloads);
const { queryByText } = renderPluginDetails({ id, downloads });
await waitFor(() => expect(queryByText(new Intl.NumberFormat().format(downloads))).toBeInTheDocument());
await waitFor(() => expect(queryByText(expected, options)).toBeInTheDocument());
});
it('should display the version in the header', async () => {
const version = '1.3.443';
const { queryByText } = renderPluginDetails({ id, version });
it('should display the installed version if a plugin is installed', async () => {
const installedVersion = '1.3.443';
const { queryByText } = renderPluginDetails({ id, installedVersion });
await waitFor(() => expect(queryByText(version)).toBeInTheDocument());
await waitFor(() => expect(queryByText(installedVersion)).toBeInTheDocument());
});
it('should display the latest compatible version in the header if a plugin is not installed', async () => {
const details: CatalogPluginDetails = {
links: [],
versions: [
{ version: '1.3.0', createdAt: '', isCompatible: false, grafanaDependency: '>=9.0.0' },
{ version: '1.2.0', createdAt: '', isCompatible: false, grafanaDependency: '>=8.3.0' },
{ version: '1.1.1', createdAt: '', isCompatible: true, grafanaDependency: '>=8.0.0' },
{ version: '1.1.0', createdAt: '', isCompatible: true, grafanaDependency: '>=8.0.0' },
{ version: '1.0.0', createdAt: '', isCompatible: true, grafanaDependency: '>=7.0.0' },
],
};
const { queryByText } = renderPluginDetails({ id, details });
await waitFor(() => expect(queryByText('1.1.1')).toBeInTheDocument());
await waitFor(() => expect(queryByText(/>=8.0.0/i)).toBeInTheDocument());
});
it('should display description in the header', async () => {
@ -166,17 +195,32 @@ describe('Plugin details page', () => {
});
it('should display version history in case it is available', async () => {
const versions = [
{
version: '1.2.0',
createdAt: '2018-04-06T20:23:41.000Z',
isCompatible: false,
grafanaDependency: '>=8.3.0',
},
{
version: '1.1.0',
createdAt: '2017-04-06T20:23:41.000Z',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
{
version: '1.0.0',
createdAt: '2016-04-06T20:23:41.000Z',
isCompatible: true,
grafanaDependency: '>=7.0.0',
},
];
const { queryByText, getByRole } = renderPluginDetails(
{
id,
details: {
links: [],
versions: [
{
version: '1.0.0',
createdAt: '2016-04-06T20:23:41.000Z',
},
],
versions,
},
},
{ pageId: PluginTabIds.VERSIONS }
@ -185,32 +229,27 @@ describe('Plugin details page', () => {
// Check if version information is available
await waitFor(() => expect(queryByText(/version history/i)).toBeInTheDocument());
expect(
getByRole('columnheader', {
name: /version/i,
})
).toBeInTheDocument();
expect(
getByRole('columnheader', {
name: /last updated/i,
})
).toBeInTheDocument();
expect(
getByRole('cell', {
name: /1\.0\.0/i,
})
).toBeInTheDocument();
expect(
getByRole('cell', {
name: /5 years ago/i,
})
).toBeInTheDocument();
// Check the column headers
expect(getByRole('columnheader', { name: /version/i })).toBeInTheDocument();
expect(getByRole('columnheader', { name: /last updated/i })).toBeInTheDocument();
// Check the data
for (const version of versions) {
expect(getByRole('cell', { name: new RegExp(version.version, 'i') })).toBeInTheDocument();
expect(
getByRole('cell', { name: new RegExp(dateTimeFormatTimeAgo(version.createdAt), 'i') })
).toBeInTheDocument();
// Check the latest compatible version
expect(queryByText('1.1.0 (latest compatible version)')).toBeInTheDocument();
}
});
it("should display an install button for a plugin that isn't installed", async () => {
const { queryByRole } = renderPluginDetails({ id, isInstalled: false });
await waitFor(() => expect(queryByRole('button', { name: /install/i })).toBeInTheDocument());
await waitFor(() => expect(queryByRole('button', { name: /^install/i })).toBeInTheDocument());
// Does not display "uninstall" button
expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
});
@ -218,6 +257,8 @@ describe('Plugin details page', () => {
const { queryByRole } = renderPluginDetails({ id, isInstalled: true });
await waitFor(() => expect(queryByRole('button', { name: /uninstall/i })).toBeInTheDocument());
// Does not display "install" button
expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
});
it('should display update and uninstall buttons for a plugin with update', async () => {
@ -225,10 +266,10 @@ describe('Plugin details page', () => {
// Displays an "update" button
await waitFor(() => expect(queryByRole('button', { name: /update/i })).toBeInTheDocument());
// Does not display "install" and "uninstall" buttons
expect(queryByRole('button', { name: /install/i })).toBeInTheDocument();
expect(queryByRole('button', { name: /uninstall/i })).toBeInTheDocument();
// Does not display "install" button
expect(queryByRole('button', { name: /^install/i })).not.toBeInTheDocument();
});
it('should display an install button for enterprise plugins if license is valid', async () => {
@ -326,7 +367,7 @@ describe('Plugin details page', () => {
// @ts-ignore
api.uninstallPlugin = jest.fn();
const { queryByText, queryByRole, getByRole } = renderPluginDetails({
const { queryByText, getByRole } = renderPluginDetails({
id,
name: 'Akumuli',
isInstalled: true,
@ -334,11 +375,19 @@ describe('Plugin details page', () => {
pluginDependencies: [],
grafanaDependency: '>=8.0.0',
links: [],
versions: [
{
version: '1.0.0',
createdAt: '',
isCompatible: true,
grafanaDependency: '>=8.0.0',
},
],
},
});
// Wait for the install controls to be loaded
await waitFor(() => expect(queryByRole('button', { name: /install/i })).toBeInTheDocument());
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
// Open the confirmation modal
userEvent.click(getByRole('button', { name: /uninstall/i }));
@ -454,7 +503,7 @@ describe('Plugin details page', () => {
it("should not display an install button for a plugin that isn't installed", async () => {
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: false });
await waitFor(() => expect(queryByText('Overview')).toBeInTheDocument());
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
expect(queryByRole('button', { name: /install/i })).not.toBeInTheDocument();
});
@ -462,7 +511,7 @@ describe('Plugin details page', () => {
it('should not display an uninstall button for an already installed plugin', async () => {
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true });
await waitFor(() => expect(queryByText('Overview')).toBeInTheDocument());
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
});
@ -470,7 +519,7 @@ describe('Plugin details page', () => {
it('should not display update or uninstall buttons for a plugin with update', async () => {
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: true, hasUpdate: true });
await waitFor(() => expect(queryByText('Overview')).toBeInTheDocument());
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
expect(queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
expect(queryByRole('button', { name: /uninstall/i })).not.toBeInTheDocument();
@ -480,7 +529,7 @@ describe('Plugin details page', () => {
config.licenseInfo.hasValidLicense = true;
const { queryByRole, queryByText } = renderPluginDetails({ id, isInstalled: false, isEnterprise: true });
await waitFor(() => expect(queryByText('Overview')).toBeInTheDocument());
await waitFor(() => expect(queryByText(PluginTabLabels.OVERVIEW)).toBeInTheDocument());
expect(queryByRole('button', { name: /install/i })).not.toBeInTheDocument();
});

View File

@ -59,10 +59,12 @@ export const fetchDetails = createAsyncThunk(`${STATE_PREFIX}/fetchDetails`, asy
// We are also using the install API endpoint to update the plugin
export const install = createAsyncThunk(
`${STATE_PREFIX}/install`,
async ({ id, version, isUpdating = false }: { id: string; version: string; isUpdating?: boolean }, thunkApi) => {
const changes = isUpdating ? { isInstalled: true, hasUpdate: false } : { isInstalled: true };
async ({ id, version, isUpdating = false }: { id: string; version?: string; isUpdating?: boolean }, thunkApi) => {
const changes = isUpdating
? { isInstalled: true, installedVersion: version, hasUpdate: false }
: { isInstalled: true, installedVersion: version };
try {
await installPlugin(id, version);
await installPlugin(id);
await updatePanels();
if (isUpdating) {
@ -85,7 +87,7 @@ export const uninstall = createAsyncThunk(`${STATE_PREFIX}/uninstall`, async (id
return {
id,
changes: { isInstalled: false },
changes: { isInstalled: false, installedVersion: undefined },
} as Update<CatalogPlugin>;
} catch (e) {
return thunkApi.rejectWithValue('Unknown error.');

View File

@ -55,8 +55,7 @@ export const useGetSingle = (id: string): CatalogPlugin | undefined => {
export const useInstall = () => {
const dispatch = useDispatch();
return (id: string, version: string, isUpdating?: boolean) => dispatch(install({ id, version, isUpdating }));
return (id: string, version?: string, isUpdating?: boolean) => dispatch(install({ id, version, isUpdating }));
};
export const useUninstall = () => {

View File

@ -52,7 +52,7 @@ export interface CatalogPlugin {
publishedAt: string;
type?: PluginType;
updatedAt: string;
version: string;
installedVersion?: string;
details?: CatalogPluginDetails;
error?: PluginErrorCode;
}
@ -146,7 +146,6 @@ export type LocalPlugin = {
version: string;
updated: string;
};
latestVersion: string;
name: string;
pinned: boolean;
signature: PluginSignatureStatus;
@ -154,6 +153,7 @@ export type LocalPlugin = {
signatureType: PluginSignatureType;
state: string;
type: PluginType;
dependencies: PluginDependencies;
};
interface Rel {
@ -171,6 +171,8 @@ export interface Build {
export interface Version {
version: string;
createdAt: string;
isCompatible: boolean;
grafanaDependency: string | null;
}
export interface PluginDetails {
@ -269,4 +271,6 @@ export type PluginVersion = {
status: string;
downloadSlug: string;
links: Array<{ rel: string; href: string }>;
isCompatible: boolean;
grafanaDependency: string | null;
};