mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
79747b419b
commit
ff56ea6ea6
@ -1,5 +1,4 @@
|
|||||||
import { getBackendSrv } from '@grafana/runtime';
|
import { getBackendSrv } from '@grafana/runtime';
|
||||||
import { PluginMeta } from '@grafana/data';
|
|
||||||
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
|
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
|
||||||
import { Plugin, PluginDetails, Org, LocalPlugin } from './types';
|
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 });
|
const installed = await getBackendSrv().get(`${API_ROOT}`, { embedded: 0 });
|
||||||
return installed;
|
return installed;
|
||||||
}
|
}
|
||||||
@ -63,16 +62,6 @@ async function uninstallPlugin(id: string) {
|
|||||||
return await getBackendSrv().post(`${API_ROOT}/${id}/uninstall`);
|
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 = {
|
export const api = {
|
||||||
getRemotePlugins,
|
getRemotePlugins,
|
||||||
getPlugin,
|
getPlugin,
|
||||||
@ -80,5 +69,4 @@ export const api = {
|
|||||||
getOrg,
|
getOrg,
|
||||||
installPlugin,
|
installPlugin,
|
||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
updatePlugin,
|
|
||||||
};
|
};
|
||||||
|
@ -1,44 +1,42 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { gt, satisfies } from 'semver';
|
import { satisfies } from 'semver';
|
||||||
|
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { Button, HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
|
import { Button, HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
|
||||||
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
|
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 appEvents from 'app/core/app_events';
|
||||||
|
import { CatalogPluginDetails } from '../types';
|
||||||
|
import { api } from '../api';
|
||||||
import { isGrafanaAdmin } from '../helpers';
|
import { isGrafanaAdmin } from '../helpers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
localPlugin?: LocalPlugin;
|
plugin: CatalogPluginDetails;
|
||||||
remotePlugin: Plugin;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
export const InstallControls = ({ plugin }: Props) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isInstalled, setIsInstalled] = useState(Boolean(localPlugin));
|
const [isInstalled, setIsInstalled] = useState(plugin.isInstalled || false);
|
||||||
const [shouldUpdate, setShouldUpdate] = useState(
|
const [shouldUpdate, setShouldUpdate] = useState(plugin.hasUpdate || false);
|
||||||
remotePlugin?.version && localPlugin?.info.version && gt(remotePlugin?.version!, localPlugin?.info.version!)
|
|
||||||
);
|
|
||||||
const [hasInstalledPanel, setHasInstalledPanel] = useState(false);
|
const [hasInstalledPanel, setHasInstalledPanel] = useState(false);
|
||||||
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
|
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
|
||||||
const externalManageLink = getExternalManageLink(remotePlugin);
|
const externalManageLink = getExternalManageLink(plugin);
|
||||||
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
if (!plugin) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const onInstall = async () => {
|
const onInstall = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.installPlugin(remotePlugin.slug, remotePlugin.version);
|
await api.installPlugin(plugin.id, plugin.version);
|
||||||
appEvents.emit(AppEvents.alertSuccess, [`Installed ${remotePlugin?.name}`]);
|
appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsInstalled(true);
|
setIsInstalled(true);
|
||||||
setHasInstalledPanel(remotePlugin.typeCode === 'panel');
|
setHasInstalledPanel(plugin.type === 'panel');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -47,8 +45,8 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
|||||||
const onUninstall = async () => {
|
const onUninstall = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.uninstallPlugin(remotePlugin.slug);
|
await api.uninstallPlugin(plugin.id);
|
||||||
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${remotePlugin?.name}`]);
|
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsInstalled(false);
|
setIsInstalled(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -59,8 +57,8 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
|||||||
const onUpdate = async () => {
|
const onUpdate = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.installPlugin(remotePlugin.slug, remotePlugin.version);
|
await api.installPlugin(plugin.id, plugin.version);
|
||||||
appEvents.emit(AppEvents.alertSuccess, [`Updated ${remotePlugin?.name}`]);
|
appEvents.emit(AppEvents.alertSuccess, [`Updated ${plugin.name}`]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setShouldUpdate(false);
|
setShouldUpdate(false);
|
||||||
} catch (error) {
|
} 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
|
const unsupportedGrafanaVersion = grafanaDependency
|
||||||
? !satisfies(config.buildInfo.version, grafanaDependency, {
|
? !satisfies(config.buildInfo.version, grafanaDependency, {
|
||||||
// needed for when running against master
|
// needed for when running against master
|
||||||
@ -76,9 +74,9 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
|||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
const isDevelopmentBuild = Boolean(localPlugin?.dev);
|
const isDevelopmentBuild = Boolean(plugin.isDev);
|
||||||
const isEnterprise = remotePlugin?.status === 'enterprise';
|
const isEnterprise = plugin.isEnterprise;
|
||||||
const isCore = remotePlugin?.internal || localPlugin?.signature === 'internal';
|
const isCore = plugin.isCore;
|
||||||
const hasPermission = isGrafanaAdmin();
|
const hasPermission = isGrafanaAdmin();
|
||||||
|
|
||||||
if (isCore) {
|
if (isCore) {
|
||||||
@ -161,8 +159,8 @@ export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function getExternalManageLink(plugin: Plugin): string {
|
function getExternalManageLink(plugin: CatalogPluginDetails): string {
|
||||||
return `https://grafana.com/grafana/plugins/${plugin.slug}`;
|
return `https://grafana.com/grafana/plugins/${plugin.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStyles = (theme: GrafanaTheme2) => {
|
export const getStyles = (theme: GrafanaTheme2) => {
|
||||||
@ -173,26 +171,5 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
messageMargin: css`
|
messageMargin: css`
|
||||||
margin-left: ${theme.spacing()};
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -4,13 +4,12 @@ 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, LocalPlugin } from '../types';
|
import { CatalogPlugin } from '../types';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { isLocalPlugin } from '../guards';
|
|
||||||
import { PluginLogo } from './PluginLogo';
|
import { PluginLogo } from './PluginLogo';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plugins: Array<Plugin | LocalPlugin>;
|
plugins: CatalogPlugin[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PluginList = ({ plugins }: Props) => {
|
export const PluginList = ({ plugins }: Props) => {
|
||||||
@ -19,8 +18,7 @@ export const PluginList = ({ plugins }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Grid>
|
<Grid>
|
||||||
{plugins.map((plugin) => {
|
{plugins.map((plugin) => {
|
||||||
const id = getPluginId(plugin);
|
const { name, id, orgName } = plugin;
|
||||||
const { name } = plugin;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -28,7 +26,7 @@ export const PluginList = ({ plugins }: Props) => {
|
|||||||
href={`/plugins/${id}`}
|
href={`/plugins/${id}`}
|
||||||
image={
|
image={
|
||||||
<PluginLogo
|
<PluginLogo
|
||||||
plugin={plugin}
|
src={plugin.info.logos.small}
|
||||||
className={css`
|
className={css`
|
||||||
max-height: 64px;
|
max-height: 64px;
|
||||||
`}
|
`}
|
||||||
@ -37,7 +35,7 @@ export const PluginList = ({ plugins }: Props) => {
|
|||||||
text={
|
text={
|
||||||
<>
|
<>
|
||||||
<div className={styles.name}>{name}</div>
|
<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) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
name: css`
|
name: css`
|
||||||
font-size: ${theme.typography.h4.fontSize};
|
font-size: ${theme.typography.h4.fontSize};
|
||||||
|
@ -1,24 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { isLocalPlugin } from '../guards';
|
|
||||||
import { LocalPlugin, Plugin } from '../types';
|
|
||||||
|
|
||||||
type PluginLogoProps = {
|
type PluginLogoProps = {
|
||||||
plugin: Plugin | LocalPlugin | undefined;
|
src: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PluginLogo({ plugin, className }: PluginLogoProps): React.ReactElement | null {
|
export function PluginLogo({ src, className }: PluginLogoProps): React.ReactElement {
|
||||||
return <img src={getImageSrc(plugin)} className={className} />;
|
return <img src={src} 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`;
|
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
@ -9,11 +9,30 @@ interface Props {
|
|||||||
onSearch: (value: string) => void;
|
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) => {
|
export const SearchField = ({ value, onSearch }: Props) => {
|
||||||
const [query, setQuery] = useState(value);
|
const [query, setQuery] = useState(value);
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
useDebounce(() => onSearch(query ?? ''), 500, [query]);
|
useDebounceWithoutFirstRender(() => onSearch(query ?? ''), 500, [query]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
|
@ -1,5 +1,147 @@
|
|||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
|
import { gt } from 'semver';
|
||||||
|
import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, Plugin, Version } from './types';
|
||||||
|
|
||||||
export function isGrafanaAdmin(): boolean {
|
export function isGrafanaAdmin(): boolean {
|
||||||
return config.bootData.user.isGrafanaAdmin;
|
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()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
@ -1,84 +1,86 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useAsync } from 'react-use';
|
import { useAsync } from 'react-use';
|
||||||
import { Plugin, LocalPlugin } from '../types';
|
import { CatalogPlugin, CatalogPluginDetails } from '../types';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
|
import { mapLocalToCatalog, mapRemoteToCatalog, getCatalogPluginDetails, applySearchFilter } from '../helpers';
|
||||||
|
|
||||||
export const usePlugins = () => {
|
type CatalogPluginsState = {
|
||||||
const result = useAsync(async () => {
|
loading: boolean;
|
||||||
const items = await api.getRemotePlugins();
|
error?: Error;
|
||||||
const filteredPlugins = items.filter((plugin) => {
|
plugins: CatalogPlugin[];
|
||||||
const isNotRenderer = plugin.typeCode !== 'renderer';
|
};
|
||||||
const isSigned = Boolean(plugin.versionSignatureType);
|
|
||||||
|
|
||||||
return isNotRenderer && isSigned;
|
export function usePlugins(): CatalogPluginsState {
|
||||||
});
|
const { loading, value, error } = useAsync(async () => {
|
||||||
|
const remote = await api.getRemotePlugins();
|
||||||
const installedPlugins = await api.getInstalledPlugins();
|
const installed = await api.getInstalledPlugins();
|
||||||
|
return { remote, installed };
|
||||||
return { items: filteredPlugins, installedPlugins };
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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 = {
|
type FilteredPluginsState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
items: Array<Plugin | LocalPlugin>;
|
error?: Error;
|
||||||
|
plugins: CatalogPlugin[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => {
|
export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => {
|
||||||
const { loading, value } = usePlugins();
|
const { loading, error, plugins } = usePlugins();
|
||||||
const all = useMemo(() => {
|
|
||||||
const combined: Plugin[] = [];
|
|
||||||
Array.prototype.push.apply(combined, value?.items ?? []);
|
|
||||||
Array.prototype.push.apply(combined, value?.installedPlugins ?? []);
|
|
||||||
|
|
||||||
const bySlug = combined.reduce((unique: Record<string, Plugin>, plugin) => {
|
const installed = useMemo(() => plugins.filter((plugin) => plugin.isInstalled), [plugins]);
|
||||||
unique[plugin.slug] = plugin;
|
|
||||||
return unique;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
return Object.values(bySlug);
|
|
||||||
}, [value?.items, value?.installedPlugins]);
|
|
||||||
|
|
||||||
if (filterBy === 'installed') {
|
if (filterBy === 'installed') {
|
||||||
return {
|
return {
|
||||||
isLoading: loading,
|
isLoading: loading,
|
||||||
items: applySearchFilter(searchBy, value?.installedPlugins ?? []),
|
error,
|
||||||
|
plugins: applySearchFilter(searchBy, installed),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading: loading,
|
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 = {
|
type PluginState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
remote?: Plugin;
|
plugin?: CatalogPluginDetails;
|
||||||
remoteVersions?: Array<{ version: string; createdAt: string }>;
|
|
||||||
local?: LocalPlugin;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePlugin = (slug: string): PluginState => {
|
export const usePlugin = (slug: string): PluginState => {
|
||||||
@ -86,8 +88,10 @@ export const usePlugin = (slug: string): PluginState => {
|
|||||||
return await api.getPlugin(slug);
|
return await api.getPlugin(slug);
|
||||||
}, [slug]);
|
}, [slug]);
|
||||||
|
|
||||||
|
const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading: loading,
|
isLoading: loading,
|
||||||
...value,
|
plugin,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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 plugins = [...installed, ...remote];
|
||||||
const { getByText } = setup('/plugins?filterBy=all');
|
const { getByText } = setup('/plugins?filterBy=all');
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ import { PluginList } from '../components/PluginList';
|
|||||||
import { SearchField } from '../components/SearchField';
|
import { SearchField } from '../components/SearchField';
|
||||||
import { HorizontalGroup } from '../components/HorizontalGroup';
|
import { HorizontalGroup } from '../components/HorizontalGroup';
|
||||||
import { useHistory } from '../hooks/useHistory';
|
import { useHistory } from '../hooks/useHistory';
|
||||||
import { Plugin } from '../types';
|
import { CatalogPlugin } from '../types';
|
||||||
import { Page as PluginPage } from '../components/Page';
|
import { Page as PluginPage } from '../components/Page';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { usePluginsByFilter } from '../hooks/usePlugins';
|
import { usePluginsByFilter } from '../hooks/usePlugins';
|
||||||
@ -17,7 +17,7 @@ import { useSelector } from 'react-redux';
|
|||||||
import { StoreState } from 'app/types/store';
|
import { StoreState } from 'app/types/store';
|
||||||
import { getNavModel } from 'app/core/selectors/navModel';
|
import { getNavModel } from 'app/core/selectors/navModel';
|
||||||
|
|
||||||
export default function Browse(): ReactElement {
|
export default function Browse(): ReactElement | null {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const query = locationSearchToObject(location.search);
|
const query = locationSearchToObject(location.search);
|
||||||
const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, 'plugins'));
|
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 filterBy = (query.filterBy as string) ?? 'installed';
|
||||||
const sortBy = (query.sortBy as string) ?? 'name';
|
const sortBy = (query.sortBy as string) ?? 'name';
|
||||||
|
|
||||||
const plugins = usePluginsByFilter(q, filterBy);
|
const { plugins, isLoading, error } = usePluginsByFilter(q, filterBy);
|
||||||
const sortedPlugins = plugins.items.sort(sorters[sortBy]);
|
const sortedPlugins = plugins.sort(sorters[sortBy]);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const onSortByChange = (value: SelectableValue<string>) => {
|
const onSortByChange = (value: SelectableValue<string>) => {
|
||||||
@ -42,6 +42,12 @@ export default function Browse(): ReactElement {
|
|||||||
history.push({ query: { filterBy: 'all', q } });
|
history.push({ query: { filterBy: 'all', q } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// How should we handle errors?
|
||||||
|
if (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page navModel={navModel}>
|
<Page navModel={navModel}>
|
||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
@ -49,7 +55,7 @@ export default function Browse(): ReactElement {
|
|||||||
<SearchField value={q} onSearch={onSearch} />
|
<SearchField value={q} onSearch={onSearch} />
|
||||||
<HorizontalGroup>
|
<HorizontalGroup>
|
||||||
<div>
|
<div>
|
||||||
{plugins.isLoading ? (
|
{isLoading ? (
|
||||||
<LoadingPlaceholder
|
<LoadingPlaceholder
|
||||||
className={css`
|
className={css`
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@ -87,17 +93,19 @@ export default function Browse(): ReactElement {
|
|||||||
</Field>
|
</Field>
|
||||||
</HorizontalGroup>
|
</HorizontalGroup>
|
||||||
|
|
||||||
{!plugins.isLoading && <PluginList plugins={sortedPlugins} />}
|
{!isLoading && <PluginList plugins={sortedPlugins} />}
|
||||||
</PluginPage>
|
</PluginPage>
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorters: { [name: string]: (a: Plugin, b: Plugin) => number } = {
|
const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = {
|
||||||
name: (a: Plugin, b: Plugin) => a.name.localeCompare(b.name),
|
name: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name),
|
||||||
updated: (a: Plugin, b: Plugin) => dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(),
|
updated: (a: CatalogPlugin, b: CatalogPlugin) =>
|
||||||
published: (a: Plugin, b: Plugin) => dateTimeParse(b.createdAt).valueOf() - dateTimeParse(a.createdAt).valueOf(),
|
dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(),
|
||||||
downloads: (a: Plugin, b: Plugin) => b.downloads - a.downloads,
|
published: (a: CatalogPlugin, b: CatalogPlugin) =>
|
||||||
popularity: (a: Plugin, b: Plugin) => b.popularity - a.popularity,
|
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,
|
||||||
};
|
};
|
||||||
|
@ -23,15 +23,9 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
|||||||
{ label: 'Version history', active: false },
|
{ label: 'Version history', active: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId!);
|
const { isLoading, plugin } = usePlugin(pluginId!);
|
||||||
const styles = useStyles2(getStyles);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@ -40,12 +34,13 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plugin) {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PluginPage>
|
<PluginPage>
|
||||||
<div className={styles.headerContainer}>
|
<div className={styles.headerContainer}>
|
||||||
<PluginLogo
|
<PluginLogo
|
||||||
plugin={remote ?? local}
|
src={plugin.info.logos.small}
|
||||||
className={css`
|
className={css`
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -55,26 +50,26 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.headerWrapper}>
|
<div className={styles.headerWrapper}>
|
||||||
<h1>{remote?.name ?? local?.name}</h1>
|
<h1>{plugin.name}</h1>
|
||||||
<div className={styles.headerLinks}>
|
<div className={styles.headerLinks}>
|
||||||
<a className={styles.headerOrgName} href={'/plugins'}>
|
<a className={styles.headerOrgName} href={'/plugins'}>
|
||||||
{remote?.orgName ?? local?.info?.author?.name}
|
{plugin.orgName}
|
||||||
</a>
|
</a>
|
||||||
{links.map((link: any) => (
|
{plugin.links.map((link: any) => (
|
||||||
<a key={link.name} href={link.url}>
|
<a key={link.name} href={link.url}>
|
||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
{downloads && (
|
{plugin.downloads > 0 && (
|
||||||
<span>
|
<span>
|
||||||
<Icon name="cloud-download" />
|
<Icon name="cloud-download" />
|
||||||
{` ${new Intl.NumberFormat().format(downloads)}`}{' '}
|
{` ${new Intl.NumberFormat().format(plugin.downloads)}`}{' '}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{version && <span>{version}</span>}
|
{plugin.version && <span>{plugin.version}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p>{description}</p>
|
<p>{plugin.description}</p>
|
||||||
{remote && <InstallControls localPlugin={local} remotePlugin={remote} />}
|
<InstallControls plugin={plugin} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<TabsBar>
|
<TabsBar>
|
||||||
@ -93,14 +88,21 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
|||||||
{tabs.find((_) => _.label === 'Overview')?.active && (
|
{tabs.find((_) => _.label === 'Overview')?.active && (
|
||||||
<div
|
<div
|
||||||
className={styles.readme}
|
className={styles.readme}
|
||||||
dangerouslySetInnerHTML={{ __html: readme ?? 'No plugin help or readme markdown file was found' }}
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: plugin?.readme ?? 'No plugin help or readme markdown file was found',
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tabs.find((_) => _.label === 'Version history')?.active && <VersionList versions={remoteVersions ?? []} />}
|
{tabs.find((_) => _.label === 'Version history')?.active && (
|
||||||
|
<VersionList versions={plugin?.versions ?? []} />
|
||||||
|
)}
|
||||||
</TabContent>
|
</TabContent>
|
||||||
</PluginPage>
|
</PluginPage>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStyles = (theme: GrafanaTheme2) => {
|
export const getStyles = (theme: GrafanaTheme2) => {
|
||||||
@ -137,9 +139,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
headerOrgName: css`
|
headerOrgName: css`
|
||||||
font-size: ${theme.typography.h4.fontSize};
|
font-size: ${theme.typography.h4.fontSize};
|
||||||
`,
|
`,
|
||||||
message: css`
|
|
||||||
color: ${theme.colors.text.secondary};
|
|
||||||
`,
|
|
||||||
readme: css`
|
readme: css`
|
||||||
padding: ${theme.spacing(3, 4)};
|
padding: ${theme.spacing(3, 4)};
|
||||||
|
|
||||||
|
@ -1,5 +1,41 @@
|
|||||||
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
|
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 {
|
export interface Plugin {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
Loading…
Reference in New Issue
Block a user