Plugins: display enterprise plugins in the plugins catalog (#36599)

* displaying enterprise plugins in the list.

* added place holder for tests and removed unused code.

* added test for the browse page.

* added empty test file.

* added some more tests.
pull/36650/head
Marcus Andersson 5 years ago committed by GitHub
parent 561ccf419c
commit 2fb385c192
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 23
      public/app/features/plugins/admin/components/PluginList.tsx
  2. 70
      public/app/features/plugins/admin/hooks/usePlugins.tsx
  3. 161
      public/app/features/plugins/admin/pages/Browse.test.tsx
  4. 2
      public/app/features/plugins/admin/pages/Browse.tsx
  5. 124
      public/app/features/plugins/admin/pages/Discover.tsx
  6. 60
      public/app/features/plugins/admin/pages/Library.tsx
  7. 132
      public/app/features/plugins/admin/pages/PluginDetails.test.tsx
  8. 10
      public/app/features/plugins/admin/pages/PluginDetails.tsx
  9. 60
      public/app/features/plugins/admin/pages/nav.ts
  10. 2
      public/app/features/plugins/admin/types.ts

@ -4,13 +4,13 @@ import { css } from '@emotion/css';
import { Card } from '../components/Card';
import { Grid } from '../components/Grid';
import { Plugin } from '../types';
import { Plugin, LocalPlugin } from '../types';
import { GrafanaTheme2 } from '@grafana/data';
import { isLocalPlugin } from '../guards';
import { PluginLogo } from './PluginLogo';
interface Props {
plugins: Plugin[];
plugins: Array<Plugin | LocalPlugin>;
}
export const PluginList = ({ plugins }: Props) => {
@ -19,11 +19,13 @@ export const PluginList = ({ plugins }: Props) => {
return (
<Grid>
{plugins.map((plugin) => {
const { name, orgName, typeCode } = plugin;
const id = getPluginId(plugin);
const { name } = plugin;
return (
<Card
key={`${orgName}-${name}-${typeCode}`}
href={`/plugins/${getPluginId(plugin)}`}
key={`${id}`}
href={`/plugins/${id}`}
image={
<PluginLogo
plugin={plugin}
@ -35,7 +37,7 @@ export const PluginList = ({ plugins }: Props) => {
text={
<>
<div className={styles.name}>{name}</div>
<div className={styles.orgName}>{orgName}</div>
<div className={styles.orgName}>{getOrgName(plugin)}</div>
</>
}
/>
@ -45,13 +47,20 @@ export const PluginList = ({ plugins }: Props) => {
);
};
function getPluginId(plugin: Plugin): string {
function getPluginId(plugin: Plugin | LocalPlugin): string {
if (isLocalPlugin(plugin)) {
return plugin.id;
}
return plugin.slug;
}
function getOrgName(plugin: Plugin | LocalPlugin): string | undefined {
if (isLocalPlugin(plugin)) {
return plugin.info?.author?.name;
}
return plugin.orgName;
}
const getStyles = (theme: GrafanaTheme2) => ({
name: css`
font-size: ${theme.typography.h4.fontSize};

@ -1,50 +1,37 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { Plugin, LocalPlugin } from '../types';
import { api } from '../api';
type PluginsState = {
isLoading: boolean;
items: Plugin[];
installedPlugins: any[];
};
export const usePlugins = () => {
const [state, setState] = useState<PluginsState>({ isLoading: true, items: [], installedPlugins: [] });
useEffect(() => {
const fetchPluginData = async () => {
const items = await api.getRemotePlugins();
const filteredPlugins = items.filter((plugin) => {
const isNotRenderer = plugin.typeCode !== 'renderer';
const isSigned = Boolean(plugin.versionSignatureType);
const isNotEnterprise = plugin.status !== 'enterprise';
return isNotRenderer && isSigned && isNotEnterprise;
});
const result = useAsync(async () => {
const items = await api.getRemotePlugins();
const filteredPlugins = items.filter((plugin) => {
const isNotRenderer = plugin.typeCode !== 'renderer';
const isSigned = Boolean(plugin.versionSignatureType);
const installedPlugins = await api.getInstalledPlugins();
return isNotRenderer && isSigned;
});
setState((state) => ({ ...state, items: filteredPlugins, installedPlugins, isLoading: false }));
};
const installedPlugins = await api.getInstalledPlugins();
fetchPluginData();
return { items: filteredPlugins, installedPlugins };
}, []);
return state;
return result;
};
type FilteredPluginsState = {
isLoading: boolean;
items: Plugin[];
items: Array<Plugin | LocalPlugin>;
};
export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => {
const plugins = usePlugins();
const { loading, value } = usePlugins();
const all = useMemo(() => {
const combined: Plugin[] = [];
Array.prototype.push.apply(combined, plugins.items);
Array.prototype.push.apply(combined, plugins.installedPlugins);
Array.prototype.push.apply(combined, value?.items ?? []);
Array.prototype.push.apply(combined, value?.installedPlugins ?? []);
const bySlug = combined.reduce((unique: Record<string, Plugin>, plugin) => {
unique[plugin.slug] = plugin;
@ -52,17 +39,17 @@ export const usePluginsByFilter = (searchBy: string, filterBy: string): Filtered
}, {});
return Object.values(bySlug);
}, [plugins.items, plugins.installedPlugins]);
}, [value?.items, value?.installedPlugins]);
if (filterBy === 'installed') {
return {
isLoading: plugins.isLoading,
items: applySearchFilter(searchBy, plugins.installedPlugins ?? []),
isLoading: loading,
items: applySearchFilter(searchBy, value?.installedPlugins ?? []),
};
}
return {
isLoading: plugins.isLoading,
isLoading: loading,
items: applySearchFilter(searchBy, all),
};
};
@ -95,17 +82,12 @@ type PluginState = {
};
export const usePlugin = (slug: string): PluginState => {
const [state, setState] = useState<PluginState>({
isLoading: true,
});
useEffect(() => {
const fetchPluginData = async () => {
const plugin = await api.getPlugin(slug);
setState({ ...plugin, isLoading: false });
};
fetchPluginData();
const { loading, value } = useAsync(async () => {
return await api.getPlugin(slug);
}, [slug]);
return state;
return {
isLoading: loading,
...value,
};
};

@ -0,0 +1,161 @@
import React from 'react';
import { Router } from 'react-router-dom';
import { render, RenderResult, waitFor } from '@testing-library/react';
import BrowsePage from './Browse';
import { locationService } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { Provider } from 'react-redux';
import { LocalPlugin, Plugin } from '../types';
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
jest.mock('@grafana/runtime', () => ({
...(jest.requireActual('@grafana/runtime') as object),
getBackendSrv: () => ({
get: (path: string) => {
switch (path) {
case `${GRAFANA_API_ROOT}/plugins`:
return Promise.resolve({ items: remote });
case API_ROOT:
return Promise.resolve(installed);
default:
return Promise.reject();
}
},
}),
}));
function setup(path = '/plugins'): RenderResult {
const store = configureStore();
locationService.push(path);
return render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<BrowsePage />
</Router>
</Provider>
);
}
describe('Browse list of plugins', () => {
it('should list installed plugins by default', async () => {
const { getByText, queryByText } = setup('/plugins');
await waitFor(() => getByText('Installed'));
for (const plugin of installed) {
expect(getByText(plugin.name)).toBeInTheDocument();
}
for (const plugin of remote) {
expect(queryByText(plugin.name)).toBeNull();
}
});
it('should list all plugins by when filtering by all', async () => {
const plugins = [...installed, ...remote];
const { getByText } = setup('/plugins?filterBy=all');
await waitFor(() => getByText('All'));
for (const plugin of plugins) {
expect(getByText(plugin.name)).toBeInTheDocument();
}
});
it('should only list plugins matching search', async () => {
const { getByText } = setup('/plugins?filterBy=all&q=zabbix');
await waitFor(() => getByText('All'));
expect(getByText('Zabbix')).toBeInTheDocument();
expect(getByText('1 result')).toBeInTheDocument();
});
it('should list enterprise plugins', async () => {
const { getByText } = setup('/plugins?filterBy=all&q=wavefront');
await waitFor(() => getByText('All'));
expect(getByText('Wavefront')).toBeInTheDocument();
expect(getByText('1 result')).toBeInTheDocument();
});
});
const installed: LocalPlugin[] = [
{
category: '',
defaultNavUrl: '/plugins/alertmanager/',
info: {
author: {
name: 'Prometheus alertmanager',
url: 'https://grafana.com',
},
build: {},
description: '',
links: [],
logos: {
small: '',
large: '',
},
updated: '',
version: '',
},
enabled: true,
hasUpdate: false,
id: 'alertmanager',
latestVersion: '',
name: 'Alert Manager',
pinned: false,
signature: 'internal',
signatureOrg: '',
signatureType: '',
state: 'alpha',
type: 'datasource',
dev: false,
},
];
const remote: Plugin[] = [
{
createdAt: '2016-04-06T20:23:41.000Z',
description: 'Zabbix plugin for Grafana',
downloads: 33645089,
featured: 180,
internal: false,
links: [],
name: 'Zabbix',
orgName: 'Alexander Zobnin',
orgSlug: 'alexanderzobnin',
packages: {},
popularity: 0.2111,
signatureType: 'community',
slug: 'alexanderzobnin-zabbix-app',
status: 'active',
typeCode: 'app',
updatedAt: '2021-05-18T14:53:01.000Z',
version: '4.1.5',
versionSignatureType: 'community',
readme: '',
},
{
createdAt: '2020-09-01T13:02:57.000Z',
description: 'Wavefront Datasource',
downloads: 7283,
featured: 0,
internal: false,
links: [],
name: 'Wavefront',
orgName: 'Grafana Labs',
orgSlug: 'grafana',
packages: {},
popularity: 0.0133,
signatureType: 'grafana',
slug: 'grafana-wavefront-datasource',
status: 'enterprise',
typeCode: 'datasource',
updatedAt: '2021-06-23T12:45:13.000Z',
version: '1.0.7',
versionSignatureType: 'grafana',
readme: '',
},
];

@ -39,7 +39,7 @@ export default function Browse(): ReactElement {
};
const onSearch = (q: any) => {
history.push({ query: { filterBy: null, q } });
history.push({ query: { filterBy: 'all', q } });
};
return (

@ -1,124 +0,0 @@
import React from 'react';
import { cx, css } from '@emotion/css';
import { dateTimeParse, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Legend, LinkButton } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import { Card } from '../components/Card';
import { Grid } from '../components/Grid';
import { PluginList } from '../components/PluginList';
import { SearchField } from '../components/SearchField';
import { PluginTypeIcon } from '../components/PluginTypeIcon';
import { usePlugins } from '../hooks/usePlugins';
import { Plugin } from '../types';
import { Page as PluginPage } from '../components/Page';
import { Loader } from '../components/Loader';
import { Page } from 'app/core/components/Page/Page';
export default function Discover(): JSX.Element | null {
const { items, isLoading } = usePlugins();
const styles = useStyles2(getStyles);
const onSearch = (q: string) => {
locationService.push({
pathname: '/plugins/browse',
search: `?q=${q}`,
});
};
const featuredPlugins = items.filter((_) => _.featured > 0);
featuredPlugins.sort((a: Plugin, b: Plugin) => {
return b.featured - a.featured;
});
const recentlyAdded = items.filter((_) => true);
recentlyAdded.sort((a: Plugin, b: Plugin) => {
const at = dateTimeParse(a.createdAt);
const bt = dateTimeParse(b.createdAt);
return bt.valueOf() - at.valueOf();
});
const mostPopular = items.filter((_) => true);
mostPopular.sort((a: Plugin, b: Plugin) => {
return b.popularity - a.popularity;
});
if (isLoading) {
return (
<Page>
<Page.Contents>
<Loader />
</Page.Contents>
</Page>
);
}
return (
<Page>
<Page.Contents>
<PluginPage>
<SearchField onSearch={onSearch} />
{/* Featured */}
<Legend className={styles.legend}>Featured</Legend>
<PluginList plugins={featuredPlugins.slice(0, 5)} />
{/* Most popular */}
<div className={styles.legendContainer}>
<Legend className={styles.legend}>Most popular</Legend>
<LinkButton href={'/plugins/browse?sortBy=popularity'}>See more</LinkButton>
</div>
<PluginList plugins={mostPopular.slice(0, 5)} />
{/* Recently added */}
<div className={styles.legendContainer}>
<Legend className={styles.legend}>Recently added</Legend>
<LinkButton href={'/plugins/browse?sortBy=published'}>See more</LinkButton>
</div>
<PluginList plugins={recentlyAdded.slice(0, 5)} />
{/* Browse by type */}
<Legend className={cx(styles.legend)}>Browse by type</Legend>
<Grid>
<Card
layout="horizontal"
href={'/plugins/browse?filterBy=panel'}
image={<PluginTypeIcon typeCode="panel" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Panels</span>}
/>
<Card
layout="horizontal"
href={'/plugins/browse?filterBy=datasource'}
image={<PluginTypeIcon typeCode="datasource" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Data sources</span>}
/>
<Card
layout="horizontal"
href={'/plugins/browse?filterBy=app'}
image={<PluginTypeIcon typeCode="app" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Apps</span>}
/>
</Grid>
</PluginPage>
</Page.Contents>
</Page>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
legend: css`
margin-top: ${theme.spacing(4)};
`,
legendContainer: css`
align-items: baseline;
display: flex;
justify-content: space-between;
`,
typeLegend: css`
align-items: center;
display: flex;
font-size: ${theme.typography.h4.fontSize};
`,
};
};

@ -1,60 +0,0 @@
import React from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { PluginList } from '../components/PluginList';
import { usePlugins } from '../hooks/usePlugins';
import { Page as PluginPage } from '../components/Page';
import { Loader } from '../components/Loader';
import { CatalogTab, getCatalogNavModel } from './nav';
import { Page } from 'app/core/components/Page/Page';
export default function Library(): JSX.Element | null {
const { isLoading, items, installedPlugins } = usePlugins();
const styles = useStyles2(getStyles);
const filteredPlugins = items.filter((plugin) => !!installedPlugins.find((_) => _.id === plugin.slug));
if (isLoading) {
return (
<Page navModel={getCatalogNavModel(CatalogTab.Library, '/plugins')}>
<Page.Contents>
<Loader />
</Page.Contents>
</Page>
);
}
return (
<Page navModel={getCatalogNavModel(CatalogTab.Library, '/plugins')}>
<Page.Contents>
<PluginPage>
<h1 className={styles.header}>Library</h1>
{filteredPlugins.length > 0 ? (
<PluginList plugins={filteredPlugins} />
) : (
<p>
You haven&#39;t installed any plugins. Browse the{' '}
<a className={styles.link} href={'/plugins/browse?sortBy=popularity'}>
catalog
</a>{' '}
for plugins to install.
</p>
)}
</PluginPage>
</Page.Contents>
</Page>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
header: css`
margin-bottom: ${theme.spacing(3)};
margin-top: ${theme.spacing(3)};
`,
link: css`
text-decoration: underline;
`,
};
};

@ -0,0 +1,132 @@
import React from 'react';
import { render, RenderResult, waitFor } from '@testing-library/react';
import PluginDetailsPage from './PluginDetails';
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
import { LocalPlugin, Plugin } from '../types';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
jest.mock('@grafana/runtime', () => {
const original = jest.requireActual('@grafana/runtime');
return {
...original,
getBackendSrv: () => ({
get: (path: string) => {
switch (path) {
case `${GRAFANA_API_ROOT}/plugins/not-installed/versions`:
case `${GRAFANA_API_ROOT}/plugins/enterprise/versions`:
return Promise.resolve([]);
case API_ROOT:
return Promise.resolve([localPlugin()]);
case `${GRAFANA_API_ROOT}/plugins/not-installed`:
return Promise.resolve(remotePlugin());
case `${GRAFANA_API_ROOT}/plugins/enterprise`:
return Promise.resolve(remotePlugin({ status: 'enterprise' }));
default:
return Promise.reject();
}
},
}),
config: {
...original.config,
buildInfo: {
...original.config.buildInfo,
version: 'v7.5.0',
},
},
};
});
function setup(pluginId: string): RenderResult {
const props = getRouteComponentProps({ match: { params: { pluginId }, isExact: true, url: '', path: '' } });
return render(<PluginDetailsPage {...props} />);
}
describe('Plugin details page', () => {
it('should display install button for uninstalled plugins', async () => {
const { getByText } = setup('not-installed');
const expected = 'Install';
await waitFor(() => getByText(expected));
expect(getByText(expected)).toBeInTheDocument();
});
it('should not display install button for enterprise plugins', async () => {
const { getByText } = setup('enterprise');
const expected = "Marketplace doesn't support installing Enterprise plugins yet. Stay tuned!";
await waitFor(() => getByText(expected));
expect(getByText(expected)).toBeInTheDocument();
});
});
function remotePlugin(plugin: Partial<Plugin> = {}): Plugin {
return {
createdAt: '2016-04-06T20:23:41.000Z',
description: 'Zabbix plugin for Grafana',
downloads: 33645089,
featured: 180,
internal: false,
links: [],
name: 'Zabbix',
orgName: 'Alexander Zobnin',
orgSlug: 'alexanderzobnin',
packages: {},
popularity: 0.2111,
signatureType: 'community',
slug: 'alexanderzobnin-zabbix-app',
status: 'active',
typeCode: 'app',
updatedAt: '2021-05-18T14:53:01.000Z',
version: '4.1.5',
versionSignatureType: 'community',
readme: '',
json: {
dependencies: {
grafanaDependency: '>=7.3.0',
grafanaVersion: '7.3',
},
info: {
links: [],
},
},
...plugin,
};
}
function localPlugin(plugin: Partial<LocalPlugin> = {}): LocalPlugin {
return {
category: '',
defaultNavUrl: '/plugins/alertmanager/',
info: {
author: {
name: 'Prometheus alertmanager',
url: 'https://grafana.com',
},
build: {},
description: '',
links: [],
logos: {
small: '',
large: '',
},
updated: '',
version: '',
},
enabled: true,
hasUpdate: false,
id: 'alertmanager',
latestVersion: '',
name: 'Alert Manager',
pinned: false,
signature: 'internal',
signatureOrg: '',
signatureType: '',
state: 'alpha',
type: 'datasource',
dev: false,
...plugin,
};
}

@ -3,7 +3,6 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui';
import { useParams } from 'react-router-dom';
import { VersionList } from '../components/VersionList';
import { InstallControls } from '../components/InstallControls';
@ -12,16 +11,19 @@ import { Page as PluginPage } from '../components/Page';
import { Loader } from '../components/Loader';
import { Page } from 'app/core/components/Page/Page';
import { PluginLogo } from '../components/PluginLogo';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
export default function PluginDetails(): JSX.Element | null {
const { pluginId } = useParams<{ pluginId: string }>();
type PluginDetailsProps = GrafanaRouteComponentProps<{ pluginId?: string }>;
export default function PluginDetails({ match }: PluginDetailsProps): JSX.Element | null {
const { pluginId } = match.params;
const [tabs, setTabs] = useState([
{ label: 'Overview', active: true },
{ label: 'Version history', active: false },
]);
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId);
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId!);
const styles = useStyles2(getStyles);
const description = remote?.description ?? local?.info?.description;

@ -1,60 +0,0 @@
import { NavModel, NavModelItem } from '@grafana/data';
export enum CatalogTab {
Browse = 'browse',
Discover = 'discover',
Library = 'library',
}
export function getCatalogNavModel(tab: CatalogTab, baseURL: string): NavModel {
const pages: NavModelItem[] = [];
if (!baseURL.endsWith('/')) {
baseURL += '/';
}
pages.push({
text: 'Browse',
icon: 'icon-gf icon-gf-apps',
url: `${baseURL}${CatalogTab.Browse}`,
id: CatalogTab.Browse,
});
pages.push({
text: 'Library',
icon: 'icon-gf icon-gf-apps',
url: `${baseURL}${CatalogTab.Library}`,
id: CatalogTab.Library,
});
const node: NavModelItem = {
text: 'Catalog',
icon: 'cog',
subTitle: 'Manage plugin installations',
breadcrumbs: [{ title: 'Plugins', url: 'plugins' }],
children: setActivePage(tab, pages, CatalogTab.Browse),
};
return {
node: node,
main: node,
};
}
function setActivePage(pageId: CatalogTab, pages: NavModelItem[], defaultPageId: CatalogTab): NavModelItem[] {
let found = false;
const selected = pageId || defaultPageId;
const changed = pages.map((p) => {
const active = !found && selected === p.id;
if (active) {
found = true;
}
return { ...p, active };
});
if (!found) {
changed[0].active = true;
}
return changed;
}

@ -28,7 +28,7 @@ export interface Plugin {
rel: string;
href: string;
}>;
json: {
json?: {
dependencies: {
grafanaDependency: string;
grafanaVersion: string;

Loading…
Cancel
Save