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.
This commit is contained in:
@@ -4,13 +4,13 @@ import { css } from '@emotion/css';
|
|||||||
import { Card } from '../components/Card';
|
import { Card } from '../components/Card';
|
||||||
import { Grid } from '../components/Grid';
|
import { Grid } from '../components/Grid';
|
||||||
|
|
||||||
import { Plugin } from '../types';
|
import { Plugin, LocalPlugin } from '../types';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { isLocalPlugin } from '../guards';
|
import { isLocalPlugin } from '../guards';
|
||||||
import { PluginLogo } from './PluginLogo';
|
import { PluginLogo } from './PluginLogo';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plugins: Plugin[];
|
plugins: Array<Plugin | LocalPlugin>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PluginList = ({ plugins }: Props) => {
|
export const PluginList = ({ plugins }: Props) => {
|
||||||
@@ -19,11 +19,13 @@ export const PluginList = ({ plugins }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Grid>
|
<Grid>
|
||||||
{plugins.map((plugin) => {
|
{plugins.map((plugin) => {
|
||||||
const { name, orgName, typeCode } = plugin;
|
const id = getPluginId(plugin);
|
||||||
|
const { name } = plugin;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={`${orgName}-${name}-${typeCode}`}
|
key={`${id}`}
|
||||||
href={`/plugins/${getPluginId(plugin)}`}
|
href={`/plugins/${id}`}
|
||||||
image={
|
image={
|
||||||
<PluginLogo
|
<PluginLogo
|
||||||
plugin={plugin}
|
plugin={plugin}
|
||||||
@@ -35,7 +37,7 @@ export const PluginList = ({ plugins }: Props) => {
|
|||||||
text={
|
text={
|
||||||
<>
|
<>
|
||||||
<div className={styles.name}>{name}</div>
|
<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)) {
|
if (isLocalPlugin(plugin)) {
|
||||||
return plugin.id;
|
return plugin.id;
|
||||||
}
|
}
|
||||||
return plugin.slug;
|
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) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
name: css`
|
name: css`
|
||||||
font-size: ${theme.typography.h4.fontSize};
|
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 { Plugin, LocalPlugin } from '../types';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
|
|
||||||
type PluginsState = {
|
|
||||||
isLoading: boolean;
|
|
||||||
items: Plugin[];
|
|
||||||
installedPlugins: any[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePlugins = () => {
|
export const usePlugins = () => {
|
||||||
const [state, setState] = useState<PluginsState>({ isLoading: true, items: [], installedPlugins: [] });
|
const result = useAsync(async () => {
|
||||||
|
const items = await api.getRemotePlugins();
|
||||||
|
const filteredPlugins = items.filter((plugin) => {
|
||||||
|
const isNotRenderer = plugin.typeCode !== 'renderer';
|
||||||
|
const isSigned = Boolean(plugin.versionSignatureType);
|
||||||
|
|
||||||
useEffect(() => {
|
return isNotRenderer && isSigned;
|
||||||
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 installedPlugins = await api.getInstalledPlugins();
|
||||||
});
|
|
||||||
|
|
||||||
const installedPlugins = await api.getInstalledPlugins();
|
return { items: filteredPlugins, installedPlugins };
|
||||||
|
|
||||||
setState((state) => ({ ...state, items: filteredPlugins, installedPlugins, isLoading: false }));
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchPluginData();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return state;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FilteredPluginsState = {
|
type FilteredPluginsState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
items: Plugin[];
|
items: Array<Plugin | LocalPlugin>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => {
|
export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => {
|
||||||
const plugins = usePlugins();
|
const { loading, value } = usePlugins();
|
||||||
const all = useMemo(() => {
|
const all = useMemo(() => {
|
||||||
const combined: Plugin[] = [];
|
const combined: Plugin[] = [];
|
||||||
Array.prototype.push.apply(combined, plugins.items);
|
Array.prototype.push.apply(combined, value?.items ?? []);
|
||||||
Array.prototype.push.apply(combined, plugins.installedPlugins);
|
Array.prototype.push.apply(combined, value?.installedPlugins ?? []);
|
||||||
|
|
||||||
const bySlug = combined.reduce((unique: Record<string, Plugin>, plugin) => {
|
const bySlug = combined.reduce((unique: Record<string, Plugin>, plugin) => {
|
||||||
unique[plugin.slug] = plugin;
|
unique[plugin.slug] = plugin;
|
||||||
@@ -52,17 +39,17 @@ export const usePluginsByFilter = (searchBy: string, filterBy: string): Filtered
|
|||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
return Object.values(bySlug);
|
return Object.values(bySlug);
|
||||||
}, [plugins.items, plugins.installedPlugins]);
|
}, [value?.items, value?.installedPlugins]);
|
||||||
|
|
||||||
if (filterBy === 'installed') {
|
if (filterBy === 'installed') {
|
||||||
return {
|
return {
|
||||||
isLoading: plugins.isLoading,
|
isLoading: loading,
|
||||||
items: applySearchFilter(searchBy, plugins.installedPlugins ?? []),
|
items: applySearchFilter(searchBy, value?.installedPlugins ?? []),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading: plugins.isLoading,
|
isLoading: loading,
|
||||||
items: applySearchFilter(searchBy, all),
|
items: applySearchFilter(searchBy, all),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -95,17 +82,12 @@ type PluginState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const usePlugin = (slug: string): PluginState => {
|
export const usePlugin = (slug: string): PluginState => {
|
||||||
const [state, setState] = useState<PluginState>({
|
const { loading, value } = useAsync(async () => {
|
||||||
isLoading: true,
|
return await api.getPlugin(slug);
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchPluginData = async () => {
|
|
||||||
const plugin = await api.getPlugin(slug);
|
|
||||||
setState({ ...plugin, isLoading: false });
|
|
||||||
};
|
|
||||||
fetchPluginData();
|
|
||||||
}, [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) => {
|
const onSearch = (q: any) => {
|
||||||
history.push({ query: { filterBy: null, q } });
|
history.push({ query: { filterBy: 'all', q } });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}> Panels</span>}
|
|
||||||
/>
|
|
||||||
<Card
|
|
||||||
layout="horizontal"
|
|
||||||
href={'/plugins/browse?filterBy=datasource'}
|
|
||||||
image={<PluginTypeIcon typeCode="datasource" size={18} />}
|
|
||||||
text={<span className={styles.typeLegend}> Data sources</span>}
|
|
||||||
/>
|
|
||||||
<Card
|
|
||||||
layout="horizontal"
|
|
||||||
href={'/plugins/browse?filterBy=app'}
|
|
||||||
image={<PluginTypeIcon typeCode="app" size={18} />}
|
|
||||||
text={<span className={styles.typeLegend}> 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'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 { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui';
|
import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui';
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { VersionList } from '../components/VersionList';
|
import { VersionList } from '../components/VersionList';
|
||||||
import { InstallControls } from '../components/InstallControls';
|
import { InstallControls } from '../components/InstallControls';
|
||||||
@@ -12,16 +11,19 @@ import { Page as PluginPage } from '../components/Page';
|
|||||||
import { Loader } from '../components/Loader';
|
import { Loader } from '../components/Loader';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { PluginLogo } from '../components/PluginLogo';
|
import { PluginLogo } from '../components/PluginLogo';
|
||||||
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
|
|
||||||
export default function PluginDetails(): JSX.Element | null {
|
type PluginDetailsProps = GrafanaRouteComponentProps<{ pluginId?: string }>;
|
||||||
const { pluginId } = useParams<{ pluginId: string }>();
|
|
||||||
|
export default function PluginDetails({ match }: PluginDetailsProps): JSX.Element | null {
|
||||||
|
const { pluginId } = match.params;
|
||||||
|
|
||||||
const [tabs, setTabs] = useState([
|
const [tabs, setTabs] = useState([
|
||||||
{ label: 'Overview', active: true },
|
{ label: 'Overview', active: true },
|
||||||
{ label: 'Version history', active: false },
|
{ label: 'Version history', active: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId);
|
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId!);
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const description = remote?.description ?? local?.info?.description;
|
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;
|
rel: string;
|
||||||
href: string;
|
href: string;
|
||||||
}>;
|
}>;
|
||||||
json: {
|
json?: {
|
||||||
dependencies: {
|
dependencies: {
|
||||||
grafanaDependency: string;
|
grafanaDependency: string;
|
||||||
grafanaVersion: string;
|
grafanaVersion: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user