mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
bf2ece7281
commit
3c3cf2eee9
@ -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)
|
||||
|
@ -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"`
|
||||
|
@ -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")
|
||||
|
@ -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,
|
||||
|
@ -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
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -61,7 +61,6 @@ export default {
|
||||
version: '4.2.2',
|
||||
updated: '2021-08-25',
|
||||
},
|
||||
latestVersion: '',
|
||||
hasUpdate: false,
|
||||
defaultNavUrl: '/plugins/alexanderzobnin-zabbix-app/',
|
||||
category: '',
|
||||
|
@ -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 });
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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}`]);
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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};
|
||||
`,
|
||||
});
|
||||
|
@ -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';
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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.');
|
||||
|
@ -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 = () => {
|
||||
|
@ -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;
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user