Plugins: Combine local and remote plugins into one structure (#36859)

* adding some structure to combine the local and remote into one type.

* feat(catalog): map local and remote responses to catalog plugin

* feat(catalog): render CatalogPlugins in list

* refactor(catalog): update usePluginsByFilter to work with new data structure

* refactor(catalog): move helper functions into helpers file. delete redundent usePlugins hook

* feat(catalog): create CatalogPluginDetails and pass to PluginDetails

* feat(catalog): update types and components for plugin installation

* chore(catalog): comment so not to forget to move code out of api layer

* fix(catalog): make sure all filter shows gcom and installed

* fix(catalog): fix up getCatalogPlugin logic for only locally available plugins

* refactor(catalog): create getCatalogPluginDetails helper. Move usage to hook

* revert(catalog): put back small logos in PluginList

* revert(catalog): put back small logo for PluginDetails page

* fix(catalog): prevent useDebounce from triggering when SearchField mounts

* chore(catalog): add coment explaining reason for usedebouncewithoutfirstrender

* refactor(catalog): replace reduce with filter to remove duplicate array of all plugins

* refactor(catalog): update types for useDebounceWithoutFirstRender

* chore(catalog): remove commented out import

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
This commit is contained in:
Marcus Andersson 2021-07-20 15:20:24 +02:00 committed by GitHub
parent 79747b419b
commit ff56ea6ea6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 378 additions and 235 deletions

View File

@ -1,5 +1,4 @@
import { getBackendSrv } from '@grafana/runtime';
import { PluginMeta } from '@grafana/data';
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
import { Plugin, PluginDetails, Org, LocalPlugin } from './types';
@ -43,7 +42,7 @@ async function getPluginVersions(id: string): Promise<any[]> {
}
}
async function getInstalledPlugins(): Promise<any> {
async function getInstalledPlugins(): Promise<LocalPlugin[]> {
const installed = await getBackendSrv().get(`${API_ROOT}`, { embedded: 0 });
return installed;
}
@ -63,16 +62,6 @@ async function uninstallPlugin(id: string) {
return await getBackendSrv().post(`${API_ROOT}/${id}/uninstall`);
}
async function updatePlugin(pluginId: string, data: Partial<PluginMeta>) {
const response = await getBackendSrv().datasourceRequest({
url: `/api/plugins/${pluginId}/settings`,
method: 'POST',
data,
});
return response?.data;
}
export const api = {
getRemotePlugins,
getPlugin,
@ -80,5 +69,4 @@ export const api = {
getOrg,
installPlugin,
uninstallPlugin,
updatePlugin,
};

View File

@ -1,44 +1,42 @@
import React, { useState } from 'react';
import { css, cx } from '@emotion/css';
import { gt, satisfies } from 'semver';
import { satisfies } from 'semver';
import { config } from '@grafana/runtime';
import { Button, HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import { LocalPlugin, Plugin } from '../types';
import { api } from '../api';
// This isn't exported in the sdk yet
// @ts-ignore
import appEvents from 'app/core/app_events';
import { CatalogPluginDetails } from '../types';
import { api } from '../api';
import { isGrafanaAdmin } from '../helpers';
interface Props {
localPlugin?: LocalPlugin;
remotePlugin: Plugin;
plugin: CatalogPluginDetails;
}
export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
export const InstallControls = ({ plugin }: Props) => {
const [loading, setLoading] = useState(false);
const [isInstalled, setIsInstalled] = useState(Boolean(localPlugin));
const [shouldUpdate, setShouldUpdate] = useState(
remotePlugin?.version && localPlugin?.info.version && gt(remotePlugin?.version!, localPlugin?.info.version!)
);
const [isInstalled, setIsInstalled] = useState(plugin.isInstalled || false);
const [shouldUpdate, setShouldUpdate] = useState(plugin.hasUpdate || false);
const [hasInstalledPanel, setHasInstalledPanel] = useState(false);
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
const externalManageLink = getExternalManageLink(remotePlugin);
const externalManageLink = getExternalManageLink(plugin);
const styles = useStyles2(getStyles);
if (!plugin) {
return null;
}
const onInstall = async () => {
setLoading(true);
try {
await api.installPlugin(remotePlugin.slug, remotePlugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Installed ${remotePlugin?.name}`]);
await api.installPlugin(plugin.id, plugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]);
setLoading(false);
setIsInstalled(true);
setHasInstalledPanel(remotePlugin.typeCode === 'panel');
setHasInstalledPanel(plugin.type === 'panel');
} catch (error) {
setLoading(false);
}
@ -47,8 +45,8 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
const onUninstall = async () => {
setLoading(true);
try {
await api.uninstallPlugin(remotePlugin.slug);
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${remotePlugin?.name}`]);
await api.uninstallPlugin(plugin.id);
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]);
setLoading(false);
setIsInstalled(false);
} catch (error) {
@ -59,8 +57,8 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
const onUpdate = async () => {
setLoading(true);
try {
await api.installPlugin(remotePlugin.slug, remotePlugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Updated ${remotePlugin?.name}`]);
await api.installPlugin(plugin.id, plugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Updated ${plugin.name}`]);
setLoading(false);
setShouldUpdate(false);
} catch (error) {
@ -68,7 +66,7 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
}
};
const grafanaDependency = remotePlugin?.json?.dependencies?.grafanaDependency;
const grafanaDependency = plugin.grafanaDependency;
const unsupportedGrafanaVersion = grafanaDependency
? !satisfies(config.buildInfo.version, grafanaDependency, {
// needed for when running against master
@ -76,9 +74,9 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
})
: false;
const isDevelopmentBuild = Boolean(localPlugin?.dev);
const isEnterprise = remotePlugin?.status === 'enterprise';
const isCore = remotePlugin?.internal || localPlugin?.signature === 'internal';
const isDevelopmentBuild = Boolean(plugin.isDev);
const isEnterprise = plugin.isEnterprise;
const isCore = plugin.isCore;
const hasPermission = isGrafanaAdmin();
if (isCore) {
@ -161,8 +159,8 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
);
};
function getExternalManageLink(plugin: Plugin): string {
return `https://grafana.com/grafana/plugins/${plugin.slug}`;
function getExternalManageLink(plugin: CatalogPluginDetails): string {
return `https://grafana.com/grafana/plugins/${plugin.id}`;
}
export const getStyles = (theme: GrafanaTheme2) => {
@ -173,26 +171,5 @@ export const getStyles = (theme: GrafanaTheme2) => {
messageMargin: css`
margin-left: ${theme.spacing()};
`,
readme: css`
margin: ${theme.spacing(3)} 0;
& img {
max-width: 100%;
}
h1,
h2,
h3 {
margin-top: ${theme.spacing(3)};
margin-bottom: ${theme.spacing(2)};
}
li {
margin-left: ${theme.spacing(2)};
& > p {
margin: ${theme.spacing()} 0;
}
}
`,
};
};

View File

@ -4,13 +4,12 @@ import { css } from '@emotion/css';
import { Card } from '../components/Card';
import { Grid } from '../components/Grid';
import { Plugin, LocalPlugin } from '../types';
import { CatalogPlugin } from '../types';
import { GrafanaTheme2 } from '@grafana/data';
import { isLocalPlugin } from '../guards';
import { PluginLogo } from './PluginLogo';
interface Props {
plugins: Array<Plugin | LocalPlugin>;
plugins: CatalogPlugin[];
}
export const PluginList = ({ plugins }: Props) => {
@ -19,8 +18,7 @@ export const PluginList = ({ plugins }: Props) => {
return (
<Grid>
{plugins.map((plugin) => {
const id = getPluginId(plugin);
const { name } = plugin;
const { name, id, orgName } = plugin;
return (
<Card
@ -28,7 +26,7 @@ export const PluginList = ({ plugins }: Props) => {
href={`/plugins/${id}`}
image={
<PluginLogo
plugin={plugin}
src={plugin.info.logos.small}
className={css`
max-height: 64px;
`}
@ -37,7 +35,7 @@ export const PluginList = ({ plugins }: Props) => {
text={
<>
<div className={styles.name}>{name}</div>
<div className={styles.orgName}>{getOrgName(plugin)}</div>
<div className={styles.orgName}>{orgName}</div>
</>
}
/>
@ -47,20 +45,6 @@ export const PluginList = ({ plugins }: Props) => {
);
};
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};

View File

@ -1,24 +1,10 @@
import React from 'react';
import { isLocalPlugin } from '../guards';
import { LocalPlugin, Plugin } from '../types';
type PluginLogoProps = {
plugin: Plugin | LocalPlugin | undefined;
src: string;
className?: string;
};
export function PluginLogo({ plugin, className }: PluginLogoProps): React.ReactElement | null {
return <img src={getImageSrc(plugin)} className={className} />;
}
function getImageSrc(plugin: Plugin | LocalPlugin | undefined): string {
if (!plugin) {
return 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small';
}
if (isLocalPlugin(plugin)) {
return plugin?.info?.logos?.large;
}
return `https://grafana.com/api/plugins/${plugin.slug}/versions/${plugin.version}/logos/small`;
export function PluginLogo({ src, className }: PluginLogoProps): React.ReactElement {
return <img src={src} className={className} />;
}

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useRef } from 'react';
import { css } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
@ -9,11 +9,30 @@ interface Props {
onSearch: (value: string) => void;
}
// useDebounce has a bug which causes it to fire on first render. This wrapper prevents that.
// https://github.com/streamich/react-use/issues/759
const useDebounceWithoutFirstRender = (callBack: () => any, delay = 0, deps: React.DependencyList = []) => {
const isFirstRender = useRef(true);
const debounceDeps = [...deps, isFirstRender];
return useDebounce(
() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
return callBack();
},
delay,
debounceDeps
);
};
export const SearchField = ({ value, onSearch }: Props) => {
const [query, setQuery] = useState(value);
const styles = useStyles2(getStyles);
useDebounce(() => onSearch(query ?? ''), 500, [query]);
useDebounceWithoutFirstRender(() => onSearch(query ?? ''), 500, [query]);
return (
<input

View File

@ -1,5 +1,147 @@
import { config } from '@grafana/runtime';
import { gt } from 'semver';
import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, Plugin, Version } from './types';
export function isGrafanaAdmin(): boolean {
return config.bootData.user.isGrafanaAdmin;
}
export function mapRemoteToCatalog(plugin: Plugin): CatalogPlugin {
const {
name,
slug: id,
description,
version,
orgName,
popularity,
downloads,
typeCode,
updatedAt,
createdAt: publishedAt,
status,
} = plugin;
const catalogPlugin = {
description,
downloads,
id,
info: {
logos: {
small: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/small`,
large: `https://grafana.com/api/plugins/${id}/versions/${version}/logos/large`,
},
},
name,
orgName,
popularity,
publishedAt,
updatedAt,
version,
hasUpdate: false,
isInstalled: false,
isCore: plugin.internal,
isDev: false,
isEnterprise: status === 'enterprise',
type: typeCode,
};
return catalogPlugin;
}
export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
const {
name,
info: { description, version, logos, updated, author },
id,
signature,
dev,
type,
} = plugin;
return {
description,
downloads: 0,
id,
info: { logos },
name,
orgName: author.name,
popularity: 0,
publishedAt: '',
updatedAt: updated,
version,
hasUpdate: false,
isInstalled: true,
isCore: signature === 'internal',
isDev: Boolean(dev),
isEnterprise: false,
type,
};
}
export function getCatalogPluginDetails(
local: LocalPlugin | undefined,
remote: Plugin | undefined,
pluginVersions: Version[] | undefined
): CatalogPluginDetails {
const version = remote?.version || local?.info.version || '';
const hasUpdate = Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version));
const id = remote?.slug || local?.id || '';
let logos = {
small: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small',
large: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/large',
};
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`,
};
} else if (local && local.info.logos) {
logos = local.info.logos;
}
const plugin = {
description: remote?.description || local?.info.description || '',
downloads: remote?.downloads || 0,
grafanaDependency: remote?.json?.dependencies?.grafanaDependency || '',
hasUpdate,
id,
info: {
logos,
},
isCore: Boolean(remote?.internal || local?.signature === 'internal'),
isDev: Boolean(local?.dev),
isEnterprise: remote?.status === 'enterprise' || false,
isInstalled: Boolean(local),
links: remote?.json?.info.links || local?.info.links || [],
name: remote?.name || local?.name || '',
orgName: remote?.orgName || local?.info.author.name || '',
popularity: remote?.popularity || 0,
publishedAt: remote?.createdAt || '',
readme: remote?.readme || 'No plugin help or readme markdown file was found',
type: remote?.typeCode || local?.type || '',
updatedAt: remote?.updatedAt || local?.info.updated || '',
version,
versions: pluginVersions || [],
};
return plugin;
}
export function applySearchFilter(searchBy: string | undefined, plugins: CatalogPlugin[]): CatalogPlugin[] {
if (!searchBy) {
return plugins;
}
return plugins.filter((plugin) => {
const fields: String[] = [];
if (plugin.name) {
fields.push(plugin.name.toLowerCase());
}
if (plugin.orgName) {
fields.push(plugin.orgName.toLowerCase());
}
return fields.some((f) => f.includes(searchBy.toLowerCase()));
});
}

View File

@ -1,84 +1,86 @@
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { Plugin, LocalPlugin } from '../types';
import { CatalogPlugin, CatalogPluginDetails } from '../types';
import { api } from '../api';
import { mapLocalToCatalog, mapRemoteToCatalog, getCatalogPluginDetails, applySearchFilter } from '../helpers';
export const usePlugins = () => {
const result = useAsync(async () => {
const items = await api.getRemotePlugins();
const filteredPlugins = items.filter((plugin) => {
const isNotRenderer = plugin.typeCode !== 'renderer';
const isSigned = Boolean(plugin.versionSignatureType);
type CatalogPluginsState = {
loading: boolean;
error?: Error;
plugins: CatalogPlugin[];
};
return isNotRenderer && isSigned;
});
const installedPlugins = await api.getInstalledPlugins();
return { items: filteredPlugins, installedPlugins };
export function usePlugins(): CatalogPluginsState {
const { loading, value, error } = useAsync(async () => {
const remote = await api.getRemotePlugins();
const installed = await api.getInstalledPlugins();
return { remote, installed };
}, []);
return result;
};
const plugins = useMemo(() => {
const installed = value?.installed || [];
const remote = value?.remote || [];
const unique: Record<string, CatalogPlugin> = {};
for (const plugin of installed) {
unique[plugin.id] = mapLocalToCatalog(plugin);
}
for (const plugin of remote) {
if (unique[plugin.slug]) {
continue;
}
if (plugin.typeCode === 'renderer') {
continue;
}
if (!Boolean(plugin.versionSignatureType)) {
continue;
}
unique[plugin.slug] = mapRemoteToCatalog(plugin);
}
return Object.values(unique);
}, [value?.installed, value?.remote]);
return {
loading,
error,
plugins,
};
}
type FilteredPluginsState = {
isLoading: boolean;
items: Array<Plugin | LocalPlugin>;
error?: Error;
plugins: CatalogPlugin[];
};
export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => {
const { loading, value } = usePlugins();
const all = useMemo(() => {
const combined: Plugin[] = [];
Array.prototype.push.apply(combined, value?.items ?? []);
Array.prototype.push.apply(combined, value?.installedPlugins ?? []);
const { loading, error, plugins } = usePlugins();
const bySlug = combined.reduce((unique: Record<string, Plugin>, plugin) => {
unique[plugin.slug] = plugin;
return unique;
}, {});
return Object.values(bySlug);
}, [value?.items, value?.installedPlugins]);
const installed = useMemo(() => plugins.filter((plugin) => plugin.isInstalled), [plugins]);
if (filterBy === 'installed') {
return {
isLoading: loading,
items: applySearchFilter(searchBy, value?.installedPlugins ?? []),
error,
plugins: applySearchFilter(searchBy, installed),
};
}
return {
isLoading: loading,
items: applySearchFilter(searchBy, all),
error,
plugins: applySearchFilter(searchBy, plugins),
};
};
function applySearchFilter(searchBy: string | undefined, plugins: Plugin[]): Plugin[] {
if (!searchBy) {
return plugins;
}
return plugins.filter((plugin) => {
const fields: String[] = [];
if (plugin.name) {
fields.push(plugin.name.toLowerCase());
}
if (plugin.orgName) {
fields.push(plugin.orgName.toLowerCase());
}
return fields.some((f) => f.includes(searchBy.toLowerCase()));
});
}
type PluginState = {
isLoading: boolean;
remote?: Plugin;
remoteVersions?: Array<{ version: string; createdAt: string }>;
local?: LocalPlugin;
plugin?: CatalogPluginDetails;
};
export const usePlugin = (slug: string): PluginState => {
@ -86,8 +88,10 @@ export const usePlugin = (slug: string): PluginState => {
return await api.getPlugin(slug);
}, [slug]);
const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions);
return {
isLoading: loading,
...value,
plugin,
};
};

View File

@ -52,7 +52,7 @@ describe('Browse list of plugins', () => {
}
});
it('should list all plugins by when filtering by all', async () => {
it('should list all plugins when filtering by all', async () => {
const plugins = [...installed, ...remote];
const { getByText } = setup('/plugins?filterBy=all');

View File

@ -9,7 +9,7 @@ import { PluginList } from '../components/PluginList';
import { SearchField } from '../components/SearchField';
import { HorizontalGroup } from '../components/HorizontalGroup';
import { useHistory } from '../hooks/useHistory';
import { Plugin } from '../types';
import { CatalogPlugin } from '../types';
import { Page as PluginPage } from '../components/Page';
import { Page } from 'app/core/components/Page/Page';
import { usePluginsByFilter } from '../hooks/usePlugins';
@ -17,7 +17,7 @@ import { useSelector } from 'react-redux';
import { StoreState } from 'app/types/store';
import { getNavModel } from 'app/core/selectors/navModel';
export default function Browse(): ReactElement {
export default function Browse(): ReactElement | null {
const location = useLocation();
const query = locationSearchToObject(location.search);
const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, 'plugins'));
@ -26,8 +26,8 @@ export default function Browse(): ReactElement {
const filterBy = (query.filterBy as string) ?? 'installed';
const sortBy = (query.sortBy as string) ?? 'name';
const plugins = usePluginsByFilter(q, filterBy);
const sortedPlugins = plugins.items.sort(sorters[sortBy]);
const { plugins, isLoading, error } = usePluginsByFilter(q, filterBy);
const sortedPlugins = plugins.sort(sorters[sortBy]);
const history = useHistory();
const onSortByChange = (value: SelectableValue<string>) => {
@ -42,6 +42,12 @@ export default function Browse(): ReactElement {
history.push({ query: { filterBy: 'all', q } });
};
// How should we handle errors?
if (error) {
console.error(error.message);
return null;
}
return (
<Page navModel={navModel}>
<Page.Contents>
@ -49,7 +55,7 @@ export default function Browse(): ReactElement {
<SearchField value={q} onSearch={onSearch} />
<HorizontalGroup>
<div>
{plugins.isLoading ? (
{isLoading ? (
<LoadingPlaceholder
className={css`
margin-bottom: 0;
@ -87,17 +93,19 @@ export default function Browse(): ReactElement {
</Field>
</HorizontalGroup>
{!plugins.isLoading && <PluginList plugins={sortedPlugins} />}
{!isLoading && <PluginList plugins={sortedPlugins} />}
</PluginPage>
</Page.Contents>
</Page>
);
}
const sorters: { [name: string]: (a: Plugin, b: Plugin) => number } = {
name: (a: Plugin, b: Plugin) => a.name.localeCompare(b.name),
updated: (a: Plugin, b: Plugin) => dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(),
published: (a: Plugin, b: Plugin) => dateTimeParse(b.createdAt).valueOf() - dateTimeParse(a.createdAt).valueOf(),
downloads: (a: Plugin, b: Plugin) => b.downloads - a.downloads,
popularity: (a: Plugin, b: Plugin) => b.popularity - a.popularity,
const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = {
name: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name),
updated: (a: CatalogPlugin, b: CatalogPlugin) =>
dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(),
published: (a: CatalogPlugin, b: CatalogPlugin) =>
dateTimeParse(b.publishedAt).valueOf() - dateTimeParse(a.publishedAt).valueOf(),
downloads: (a: CatalogPlugin, b: CatalogPlugin) => b.downloads - a.downloads,
popularity: (a: CatalogPlugin, b: CatalogPlugin) => b.popularity - a.popularity,
};

View File

@ -23,15 +23,9 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
{ label: 'Version history', active: false },
]);
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId!);
const { isLoading, plugin } = usePlugin(pluginId!);
const styles = useStyles2(getStyles);
const description = remote?.description ?? local?.info?.description;
const readme = remote?.readme;
const version = local?.info?.version || remote?.version;
const links = (local?.info?.links || remote?.json?.info?.links) ?? [];
const downloads = remote?.downloads;
if (isLoading) {
return (
<Page>
@ -40,67 +34,75 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
);
}
return (
<Page>
<PluginPage>
<div className={styles.headerContainer}>
<PluginLogo
plugin={remote ?? local}
className={css`
object-fit: cover;
width: 100%;
height: 68px;
max-width: 68px;
`}
/>
if (plugin) {
return (
<Page>
<PluginPage>
<div className={styles.headerContainer}>
<PluginLogo
src={plugin.info.logos.small}
className={css`
object-fit: cover;
width: 100%;
height: 68px;
max-width: 68px;
`}
/>
<div className={styles.headerWrapper}>
<h1>{remote?.name ?? local?.name}</h1>
<div className={styles.headerLinks}>
<a className={styles.headerOrgName} href={'/plugins'}>
{remote?.orgName ?? local?.info?.author?.name}
</a>
{links.map((link: any) => (
<a key={link.name} href={link.url}>
{link.name}
<div className={styles.headerWrapper}>
<h1>{plugin.name}</h1>
<div className={styles.headerLinks}>
<a className={styles.headerOrgName} href={'/plugins'}>
{plugin.orgName}
</a>
))}
{downloads && (
<span>
<Icon name="cloud-download" />
{` ${new Intl.NumberFormat().format(downloads)}`}{' '}
</span>
)}
{version && <span>{version}</span>}
{plugin.links.map((link: any) => (
<a key={link.name} href={link.url}>
{link.name}
</a>
))}
{plugin.downloads > 0 && (
<span>
<Icon name="cloud-download" />
{` ${new Intl.NumberFormat().format(plugin.downloads)}`}{' '}
</span>
)}
{plugin.version && <span>{plugin.version}</span>}
</div>
<p>{plugin.description}</p>
<InstallControls plugin={plugin} />
</div>
<p>{description}</p>
{remote && <InstallControls localPlugin={local} remotePlugin={remote} />}
</div>
</div>
<TabsBar>
{tabs.map((tab, key) => (
<Tab
key={key}
label={tab.label}
active={tab.active}
onChangeTab={() => {
setTabs(tabs.map((tab, index) => ({ ...tab, active: index === key })));
}}
/>
))}
</TabsBar>
<TabContent>
{tabs.find((_) => _.label === 'Overview')?.active && (
<div
className={styles.readme}
dangerouslySetInnerHTML={{ __html: readme ?? 'No plugin help or readme markdown file was found' }}
/>
)}
{tabs.find((_) => _.label === 'Version history')?.active && <VersionList versions={remoteVersions ?? []} />}
</TabContent>
</PluginPage>
</Page>
);
<TabsBar>
{tabs.map((tab, key) => (
<Tab
key={key}
label={tab.label}
active={tab.active}
onChangeTab={() => {
setTabs(tabs.map((tab, index) => ({ ...tab, active: index === key })));
}}
/>
))}
</TabsBar>
<TabContent>
{tabs.find((_) => _.label === 'Overview')?.active && (
<div
className={styles.readme}
dangerouslySetInnerHTML={{
__html: plugin?.readme ?? 'No plugin help or readme markdown file was found',
}}
/>
)}
{tabs.find((_) => _.label === 'Version history')?.active && (
<VersionList versions={plugin?.versions ?? []} />
)}
</TabContent>
</PluginPage>
</Page>
);
}
return null;
}
export const getStyles = (theme: GrafanaTheme2) => {
@ -137,9 +139,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
headerOrgName: css`
font-size: ${theme.typography.h4.fontSize};
`,
message: css`
color: ${theme.colors.text.secondary};
`,
readme: css`
padding: ${theme.spacing(3, 4)};

View File

@ -1,5 +1,41 @@
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
export interface CatalogPlugin {
description: string;
downloads: number;
hasUpdate: boolean;
id: string;
info: CatalogPluginInfo;
isDev: boolean;
isCore: boolean;
isEnterprise: boolean;
isInstalled: boolean;
name: string;
orgName: string;
popularity: number;
publishedAt: string;
type: string;
updatedAt: string;
version: string;
}
export interface CatalogPluginDetails extends CatalogPlugin {
readme: string;
versions: Version[];
links: Array<{
name: string;
url: string;
}>;
grafanaDependency?: string;
}
export interface CatalogPluginInfo {
logos: {
large: string;
small: string;
};
}
export interface Plugin {
name: string;
description: string;