From cc7c54be0ea64ff42d163256f623fa7f553f754d Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Wed, 4 Aug 2021 15:09:57 +0200 Subject: [PATCH] Plugins: Update Catalog Card UI (#37350) * feat(catalog): lazy load and add alt text to plugin logos * refactor(catalog): use plugin types, make sure data is available for new ui * test(catalog): fix up tests after types refactor * feat(catalog): introduce Tile and PluginBadge components for ui updates * refactor(catalog): update PluginList to use new components, lazy load images, add creditcard icon * test(catalog): update Browse.test types * fix(catalog): if local and remote make sure to get the correct local plugin from array * refactor(catalog): prefer grafana/ui components over custom Tile component * chore(catalog): delete redundant components * feat(catalog): introduce ascending descending name sort for Browse * refactor(catalog): prefer theme spacing over hardcoded values * refactor(catalog): update Local and Remote plugin types to match api responses * fix(catalog): prefer local.hasUpdate and local.signature so updateable plugin signature is valid * test(catalog): update test plugin mocks * test(catalog): add tests for sorting and categorise * test(catalog): add tests for plugin cards, remove grid component * test(catalog): add tests for PluginBadges component * refactor(catalog): change enterprise learn more link to open plugin page on website --- packages/grafana-data/src/types/plugin.ts | 1 + packages/grafana-ui/src/types/icon.ts | 1 + public/app/features/plugins/admin/api.ts | 6 +- .../plugins/admin/components/Card.tsx | 68 --- .../plugins/admin/components/Grid.tsx | 23 - .../admin/components/PluginBadges.test.tsx | 69 +++ .../plugins/admin/components/PluginBadges.tsx | 58 +++ .../plugins/admin/components/PluginList.tsx | 65 +-- .../admin/components/PluginListCard.test.tsx | 67 +++ .../admin/components/PluginListCard.tsx | 81 ++++ .../plugins/admin/components/PluginLogo.tsx | 9 +- public/app/features/plugins/admin/helpers.ts | 51 ++- .../plugins/admin/hooks/usePlugins.tsx | 23 +- .../plugins/admin/pages/Browse.test.tsx | 425 +++++++++++++----- .../features/plugins/admin/pages/Browse.tsx | 8 +- .../admin/pages/PluginDetails.test.tsx | 33 +- .../plugins/admin/pages/PluginDetails.tsx | 1 + public/app/features/plugins/admin/types.ts | 106 +++-- 18 files changed, 742 insertions(+), 353 deletions(-) delete mode 100644 public/app/features/plugins/admin/components/Card.tsx delete mode 100644 public/app/features/plugins/admin/components/Grid.tsx create mode 100644 public/app/features/plugins/admin/components/PluginBadges.test.tsx create mode 100644 public/app/features/plugins/admin/components/PluginBadges.tsx create mode 100644 public/app/features/plugins/admin/components/PluginListCard.test.tsx create mode 100644 public/app/features/plugins/admin/components/PluginListCard.tsx diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 9d9c211ed71..4aa9c807606 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -33,6 +33,7 @@ export enum PluginSignatureType { commercial = 'commercial', community = 'community', private = 'private', + core = 'core', } /** Describes error code returned from Grafana plugins API call */ diff --git a/packages/grafana-ui/src/types/icon.ts b/packages/grafana-ui/src/types/icon.ts index b9fee639d7f..36ca55dc71b 100644 --- a/packages/grafana-ui/src/types/icon.ts +++ b/packages/grafana-ui/src/types/icon.ts @@ -47,6 +47,7 @@ export const getAvailableIcons = () => 'comments-alt', 'compass', 'copy', + 'credit-card', 'cube', 'database', 'document-info', diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 376c5e813f2..3d6ba7d5244 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -1,8 +1,8 @@ import { getBackendSrv } from '@grafana/runtime'; import { API_ROOT, GRAFANA_API_ROOT } from './constants'; -import { Plugin, PluginDetails, Org, LocalPlugin } from './types'; +import { PluginDetails, Org, LocalPlugin, RemotePlugin } from './types'; -async function getRemotePlugins(): Promise { +async function getRemotePlugins(): Promise { const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`); return res.items; } @@ -23,7 +23,7 @@ async function getPlugin(slug: string): Promise { }; } -async function getRemotePlugin(slug: string, local: LocalPlugin | undefined): Promise { +async function getRemotePlugin(slug: string, local: LocalPlugin | undefined): Promise { try { return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${slug}`); } catch (error) { diff --git a/public/app/features/plugins/admin/components/Card.tsx b/public/app/features/plugins/admin/components/Card.tsx deleted file mode 100644 index a41367ef728..00000000000 --- a/public/app/features/plugins/admin/components/Card.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { css, cx } from '@emotion/css'; -import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2 } from '@grafana/ui'; - -interface Props { - href: string; - text: React.ReactNode; - image: React.ReactNode; - layout?: 'horizontal' | 'vertical'; -} - -export const Card = ({ href, text, image, layout = 'vertical' }: Props) => { - const styles = useStyles2(getCardStyles); - - return ( - -
-
- {image} -
- {text} -
-
- ); -}; - -const getCardStyles = (theme: GrafanaTheme2) => ({ - root: css` - background-color: ${theme.colors.background.secondary}; - border-radius: ${theme.shape.borderRadius()}; - cursor: pointer; - height: 100%; - padding: ${theme.spacing(2)}; - - &:hover { - background-color: ${theme.colors.action.hover}; - } - `, - container: css` - display: flex; - flex-direction: column; - justify-content: space-around; - height: 100%; - `, - containerHorizontal: css` - flex-direction: row; - justify-content: flex-start; - `, - imgContainer: css` - align-items: center; - display: flex; - flex-grow: 1; - justify-content: center; - padding: ${theme.spacing()} 0; - `, - imgContainerHorizontal: css` - flex-grow: 0; - `, -}); diff --git a/public/app/features/plugins/admin/components/Grid.tsx b/public/app/features/plugins/admin/components/Grid.tsx deleted file mode 100644 index 4b6fcdd2878..00000000000 --- a/public/app/features/plugins/admin/components/Grid.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { css } from '@emotion/css'; -import { useTheme2 } from '@grafana/ui'; - -interface Props { - children: React.ReactNode; -} - -export const Grid = ({ children }: Props) => { - const theme = useTheme2(); - - return ( -
- {children} -
- ); -}; diff --git a/public/app/features/plugins/admin/components/PluginBadges.test.tsx b/public/app/features/plugins/admin/components/PluginBadges.test.tsx new file mode 100644 index 00000000000..a249a57f8b2 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginBadges.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { PluginSignatureStatus } from '@grafana/data'; +import { PluginBadges } from './PluginBadges'; +import { CatalogPlugin } from '../types'; + +const runtimeMock = jest.requireMock('@grafana/runtime'); + +jest.mock('@grafana/runtime', () => ({ + config: { + licenseInfo: { + hasValidLicense: false, + }, + }, +})); + +describe('PluginBadges', () => { + const plugin: CatalogPlugin = { + description: 'The test plugin', + downloads: 5, + id: 'test-plugin', + info: { + logos: { + small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small', + large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large', + }, + }, + name: 'Testing Plugin', + orgName: 'Test', + popularity: 0, + signature: PluginSignatureStatus.valid, + publishedAt: '2020-09-01', + updatedAt: '2021-06-28', + version: '1.0.0', + hasUpdate: false, + isInstalled: false, + isCore: false, + isDev: false, + isEnterprise: false, + }; + + it('renders a plugin signature badge', () => { + render(); + + expect(screen.getByText(/signed/i)).toBeVisible(); + }); + + it('renders an installed badge', () => { + render(); + + expect(screen.getByText(/signed/i)).toBeVisible(); + expect(screen.getByText(/installed/i)).toBeVisible(); + }); + + it('renders an enterprise badge (when a license is valid)', () => { + runtimeMock.config.licenseInfo.hasValidLicense = true; + render(); + expect(screen.getByText(/enterprise/i)).toBeVisible(); + expect(screen.queryByRole('button', { name: /learn more/i })).not.toBeInTheDocument(); + }); + + it('renders an enterprise badge with icon and link (when a license is invalid)', () => { + runtimeMock.config.licenseInfo.hasValidLicense = false; + render(); + expect(screen.getByText(/enterprise/i)).toBeVisible(); + expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /learn more/i })).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/plugins/admin/components/PluginBadges.tsx b/public/app/features/plugins/admin/components/PluginBadges.tsx new file mode 100644 index 00000000000..b79cb3b18b9 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginBadges.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { Badge, Button, HorizontalGroup, PluginSignatureBadge, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { CatalogPlugin } from '../types'; + +type PluginBadgeType = { + plugin: CatalogPlugin; +}; + +export function PluginBadges({ plugin }: PluginBadgeType) { + if (plugin.isEnterprise) { + return ; + } + return ( + + + {plugin.isInstalled && } + + ); +} + +function EnterpriseBadge({ id }: { id: string }) { + const customBadgeStyles = useStyles2(getBadgeColor); + const onClick = (ev: React.MouseEvent) => { + ev.preventDefault(); + window.open( + `https://grafana.com/grafana/plugins/${id}?utm_source=grafana_catalog_learn_more`, + '_blank', + 'noopener,noreferrer' + ); + }; + + if (config.licenseInfo?.hasValidLicense) { + return ; + } + + return ( + + + + + ); +} + +function InstalledBadge() { + const customBadgeStyles = useStyles2(getBadgeColor); + return ; +} + +const getBadgeColor = (theme: GrafanaTheme2) => css` + background: ${theme.colors.background.primary}; + border-color: ${theme.colors.border.strong}; + color: ${theme.colors.text.secondary}; +`; diff --git a/public/app/features/plugins/admin/components/PluginList.tsx b/public/app/features/plugins/admin/components/PluginList.tsx index 225fd2f6a4c..0beda053505 100644 --- a/public/app/features/plugins/admin/components/PluginList.tsx +++ b/public/app/features/plugins/admin/components/PluginList.tsx @@ -1,13 +1,10 @@ import React from 'react'; -import { useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; -import { useLocation } from 'react-router-dom'; -import { Card } from '../components/Card'; -import { Grid } from '../components/Grid'; - -import { CatalogPlugin } from '../types'; +import { useStyles2 } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; -import { PluginLogo } from './PluginLogo'; +import { CatalogPlugin } from '../types'; +import { PluginListCard } from './PluginListCard'; +import { useLocation } from 'react-router-dom'; interface Props { plugins: CatalogPlugin[]; @@ -18,50 +15,16 @@ export const PluginList = ({ plugins }: Props) => { const location = useLocation(); return ( - - {plugins.map((plugin) => { - const { name, id, orgName } = plugin; - - return ( - - } - text={ - <> -
{name}
-
{orgName}
- - } - /> - ); - })} -
+
+ {plugins.map((plugin) => ( + + ))} +
); }; -const getStyles = (theme: GrafanaTheme2) => ({ - name: css` - font-size: ${theme.typography.h4.fontSize}; - color: ${theme.colors.text}; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: center; - `, - orgName: css` - font-size: ${theme.typography.body.fontSize}; - color: ${theme.colors.text.secondary}; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: center; - `, -}); +const getStyles = (theme: GrafanaTheme2) => css` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(288px, 1fr)); + grid-gap: ${theme.spacing(3)}; +`; diff --git a/public/app/features/plugins/admin/components/PluginListCard.test.tsx b/public/app/features/plugins/admin/components/PluginListCard.test.tsx new file mode 100644 index 00000000000..33de131c098 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginListCard.test.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { PluginSignatureStatus, PluginType } from '@grafana/data'; +import { PluginListCard } from './PluginListCard'; +import { CatalogPlugin } from '../types'; + +describe('PluginCard', () => { + const plugin: CatalogPlugin = { + description: 'The test plugin', + downloads: 5, + id: 'test-plugin', + info: { + logos: { + small: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/small', + large: 'https://grafana.com/api/plugins/test-plugin/versions/0.0.10/logos/large', + }, + }, + name: 'Testing Plugin', + orgName: 'Test', + popularity: 0, + signature: PluginSignatureStatus.valid, + publishedAt: '2020-09-01', + updatedAt: '2021-06-28', + version: '1.0.0', + hasUpdate: false, + isInstalled: false, + isCore: false, + isDev: false, + isEnterprise: false, + }; + + it('renders a card with link, image, name, orgName and badges', () => { + render(); + + expect(screen.getByRole('link')).toHaveAttribute('href', '/plugins/test-plugin'); + + const logo = screen.getByRole('img'); + expect(logo).toHaveAttribute('src', plugin.info.logos.small); + expect(logo).toHaveAttribute('alt', `${plugin.name} logo`); + + expect(screen.getByRole('heading', { name: /testing plugin/i })).toBeVisible(); + expect(screen.getByText(`By ${plugin.orgName}`)).toBeVisible(); + expect(screen.getByText(/signed/i)).toBeVisible(); + expect(screen.queryByLabelText(/icon/i)).not.toBeInTheDocument(); + }); + + it('renders a datasource plugin with correct icon', () => { + const datasourcePlugin = { ...plugin, type: PluginType.datasource }; + render(); + + expect(screen.getByLabelText(/datasource plugin icon/i)).toBeVisible(); + }); + + it('renders a panel plugin with correct icon', () => { + const panelPlugin = { ...plugin, type: PluginType.panel }; + render(); + + expect(screen.getByLabelText(/panel plugin icon/i)).toBeVisible(); + }); + + it('renders an app plugin with correct icon', () => { + const appPlugin = { ...plugin, type: PluginType.app }; + render(); + + expect(screen.getByLabelText(/app plugin icon/i)).toBeVisible(); + }); +}); diff --git a/public/app/features/plugins/admin/components/PluginListCard.tsx b/public/app/features/plugins/admin/components/PluginListCard.tsx new file mode 100644 index 00000000000..a4781833ac4 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginListCard.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { Icon, useStyles2, CardContainer, VerticalGroup } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { CatalogPlugin } from '../types'; +import { PluginLogo } from './PluginLogo'; +import { PluginBadges } from './PluginBadges'; + +const LOGO_SIZE = '48px'; + +enum IconName { + app = 'apps', + datasource = 'database', + panel = 'credit-card', + renderer = 'pen', +} + +type PluginListCardProps = { + plugin: CatalogPlugin; + pathName: string; +}; + +export function PluginListCard({ plugin, pathName }: PluginListCardProps) { + const { name, id, orgName, type } = plugin; + const styles = useStyles2(getStyles); + + return ( + + +
+ +

{name}

+ {type && ( +
+ +
+ )} +
+

By {orgName}

+ +
+
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + cardContainer: css` + margin-bottom: 0; + padding: ${theme.spacing()}; + `, + headerWrap: css` + align-items: center; + display: grid; + grid-template-columns: ${LOGO_SIZE} 1fr ${theme.spacing(3)}; + grid-gap: ${theme.spacing(2)}; + width: 100%; + `, + name: css` + color: ${theme.colors.text.primary}; + flex-grow: 1; + font-size: ${theme.typography.h4.fontSize}; + margin-bottom: 0; + `, + image: css` + object-fit: contain; + max-width: 100%; + `, + icon: css` + align-self: flex-start; + color: ${theme.colors.text.secondary}; + `, + orgName: css` + color: ${theme.colors.text.secondary}; + margin-bottom: 0; + `, +}); diff --git a/public/app/features/plugins/admin/components/PluginLogo.tsx b/public/app/features/plugins/admin/components/PluginLogo.tsx index d111517e2eb..7c864020f34 100644 --- a/public/app/features/plugins/admin/components/PluginLogo.tsx +++ b/public/app/features/plugins/admin/components/PluginLogo.tsx @@ -1,10 +1,13 @@ import React from 'react'; type PluginLogoProps = { - src: string; + alt: string; className?: string; + src: string; + height?: string | number; }; -export function PluginLogo({ src, className }: PluginLogoProps): React.ReactElement { - return ; +export function PluginLogo({ alt, className, src, height }: PluginLogoProps): React.ReactElement { + // @ts-ignore - react doesn't know about loading attr. + return {alt}; } diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts index 370544b6981..8a93ed7cb93 100644 --- a/public/app/features/plugins/admin/helpers.ts +++ b/public/app/features/plugins/admin/helpers.ts @@ -1,7 +1,8 @@ import { config } from '@grafana/runtime'; import { gt } from 'semver'; +import { PluginSignatureStatus } from '@grafana/data'; +import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, RemotePlugin, Version, PluginFilter } from './types'; import { contextSrv } from 'app/core/services/context_srv'; -import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, Plugin, Version, PluginFilter } from './types'; export function isGrafanaAdmin(): boolean { return config.bootData.user.isGrafanaAdmin; @@ -11,7 +12,7 @@ export function isOrgAdmin() { return contextSrv.hasRole('Admin'); } -export function mapRemoteToCatalog(plugin: Plugin): CatalogPlugin { +export function mapRemoteToCatalog(plugin: RemotePlugin): CatalogPlugin { const { name, slug: id, @@ -24,7 +25,11 @@ export function mapRemoteToCatalog(plugin: Plugin): CatalogPlugin { updatedAt, createdAt: publishedAt, status, + versionSignatureType, + signatureType, } = plugin; + + const hasSignature = signatureType !== '' || versionSignatureType !== ''; const catalogPlugin = { description, downloads, @@ -39,6 +44,7 @@ export function mapRemoteToCatalog(plugin: Plugin): CatalogPlugin { orgName, popularity, publishedAt, + signature: hasSignature ? PluginSignatureStatus.valid : PluginSignatureStatus.missing, updatedAt, version, hasUpdate: false, @@ -69,6 +75,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin { orgName: author.name, popularity: 0, publishedAt: '', + signature, updatedAt: updated, version, hasUpdate: false, @@ -80,15 +87,12 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin { }; } -export function getCatalogPluginDetails( - local: LocalPlugin | undefined, - remote: Plugin | undefined, - pluginVersions: Version[] | undefined -): CatalogPluginDetails { +export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin): CatalogPlugin { const version = remote?.version || local?.info.version || ''; - const hasUpdate = Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version)); + const hasUpdate = + local?.hasUpdate || Boolean(remote?.version && local?.info.version && gt(remote?.version, local?.info.version)); const id = remote?.slug || local?.id || ''; - + const hasRemoteSignature = remote?.signatureType !== '' || remote?.versionSignatureType !== ''; let logos = { small: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small', large: 'https://grafana.com/api/plugins/404notfound/versions/none/logos/large', @@ -103,32 +107,43 @@ export function getCatalogPluginDetails( logos = local.info.logos; } - const plugin = { + return { 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'), + isCore: Boolean(remote?.internal || local?.signature === PluginSignatureStatus.internal), isDev: Boolean(local?.dev), - isEnterprise: remote?.status === 'enterprise' || false, + isEnterprise: remote?.status === 'enterprise', 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 || '', + type: remote?.typeCode || local?.type, + signature: local?.signature || hasRemoteSignature ? PluginSignatureStatus.valid : PluginSignatureStatus.missing, updatedAt: remote?.updatedAt || local?.info.updated || '', version, - versions: pluginVersions || [], }; +} - return plugin; +export function getCatalogPluginDetails( + local: LocalPlugin | undefined, + remote: RemotePlugin | undefined, + pluginVersions: Version[] = [] +): CatalogPluginDetails { + const plugin = mapToCatalogPlugin(local, remote); + + return { + ...plugin, + grafanaDependency: remote?.json?.dependencies?.grafanaDependency || '', + links: remote?.json?.info.links || local?.info.links || [], + readme: remote?.readme || 'No plugin help or readme markdown file was found', + versions: pluginVersions, + }; } export const isInstalled: PluginFilter = (plugin, query) => diff --git a/public/app/features/plugins/admin/hooks/usePlugins.tsx b/public/app/features/plugins/admin/hooks/usePlugins.tsx index c1611f2c4e4..4bfc908a5c7 100644 --- a/public/app/features/plugins/admin/hooks/usePlugins.tsx +++ b/public/app/features/plugins/admin/hooks/usePlugins.tsx @@ -2,7 +2,14 @@ import { useMemo } from 'react'; import { useAsync } from 'react-use'; import { CatalogPlugin, CatalogPluginsState, PluginsByFilterType, FilteredPluginsState } from '../types'; import { api } from '../api'; -import { mapLocalToCatalog, mapRemoteToCatalog, isInstalled, isType, matchesKeyword } from '../helpers'; +import { + mapLocalToCatalog, + mapRemoteToCatalog, + mapToCatalogPlugin, + isInstalled, + isType, + matchesKeyword, +} from '../helpers'; export function usePlugins(): CatalogPluginsState { const { loading, value, error } = useAsync(async () => { @@ -21,10 +28,6 @@ export function usePlugins(): CatalogPluginsState { } for (const plugin of remote) { - if (unique[plugin.slug]) { - continue; - } - if (plugin.typeCode === 'renderer') { continue; } @@ -33,9 +36,15 @@ export function usePlugins(): CatalogPluginsState { continue; } - unique[plugin.slug] = mapRemoteToCatalog(plugin); + if (unique[plugin.slug]) { + unique[plugin.slug] = mapToCatalogPlugin( + installed.find((installedPlugin) => installedPlugin.id === plugin.slug), + plugin + ); + } else { + unique[plugin.slug] = mapRemoteToCatalog(plugin); + } } - return Object.values(unique); }, [value?.installed, value?.remote]); diff --git a/public/app/features/plugins/admin/pages/Browse.test.tsx b/public/app/features/plugins/admin/pages/Browse.test.tsx index b2887f6c4b4..ad33e6af3d4 100644 --- a/public/app/features/plugins/admin/pages/Browse.test.tsx +++ b/public/app/features/plugins/admin/pages/Browse.test.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { Router } from 'react-router-dom'; -import { render, RenderResult, waitFor } from '@testing-library/react'; +import { render, RenderResult, waitFor, within } from '@testing-library/react'; import { Provider } from 'react-redux'; import { locationService } from '@grafana/runtime'; +import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data'; import BrowsePage from './Browse'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { configureStore } from 'app/store/configureStore'; -import { LocalPlugin, Plugin, PluginAdminRoutes } from '../types'; +import { LocalPlugin, RemotePlugin, PluginAdminRoutes } from '../types'; import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; jest.mock('@grafana/runtime', () => ({ @@ -42,135 +43,221 @@ function setup(path = '/plugins'): RenderResult { } describe('Browse list of plugins', () => { - it('should list installed plugins by default', async () => { - const { queryByText } = setup('/plugins'); + describe('when filtering', () => { + it('should list installed plugins by default', async () => { + const { queryByText } = setup('/plugins'); - await waitFor(() => queryByText('Installed')); + await waitFor(() => queryByText('Installed')); - for (const plugin of installed) { - expect(queryByText(plugin.name)).toBeInTheDocument(); - } + for (const plugin of installed) { + expect(queryByText(plugin.name)).toBeInTheDocument(); + } - for (const plugin of remote) { - expect(queryByText(plugin.name)).toBeNull(); - } + for (const plugin of remote) { + expect(queryByText(plugin.name)).toBeNull(); + } + }); + + it('should list all plugins (except core plugins) when filtering by all', async () => { + const { queryByText } = setup('/plugins?filterBy=all&filterByType=all'); + + await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument()); + for (const plugin of remote) { + expect(queryByText(plugin.name)).toBeInTheDocument(); + } + + expect(queryByText('Alert Manager')).not.toBeInTheDocument(); + }); + + it('should list installed plugins (including core plugins) when filtering by installed', async () => { + const { queryByText } = setup('/plugins?filterBy=installed'); + + await waitFor(() => queryByText('Installed')); + + for (const plugin of installed) { + expect(queryByText(plugin.name)).toBeInTheDocument(); + } + + for (const plugin of remote) { + expect(queryByText(plugin.name)).not.toBeInTheDocument(); + } + }); + + it('should list enterprise plugins', async () => { + const { queryByText } = setup('/plugins?filterBy=all&q=wavefront'); + + await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument()); + }); + + it('should list only datasource plugins when filtering by datasource', async () => { + const { queryByText } = setup('/plugins?filterBy=all&filterByType=datasource'); + + await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument()); + + expect(queryByText('Alert Manager')).not.toBeInTheDocument(); + expect(queryByText('Diagram')).not.toBeInTheDocument(); + expect(queryByText('Zabbix')).not.toBeInTheDocument(); + expect(queryByText('ACE.SVG')).not.toBeInTheDocument(); + }); + + it('should list only panel plugins when filtering by panel', async () => { + const { queryByText } = setup('/plugins?filterBy=all&filterByType=panel'); + + await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument()); + expect(queryByText('ACE.SVG')).toBeInTheDocument(); + + expect(queryByText('Wavefront')).not.toBeInTheDocument(); + expect(queryByText('Alert Manager')).not.toBeInTheDocument(); + expect(queryByText('Zabbix')).not.toBeInTheDocument(); + }); + + it('should list only app plugins when filtering by app', async () => { + const { queryByText } = setup('/plugins?filterBy=all&filterByType=app'); + + await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument()); + + expect(queryByText('Wavefront')).not.toBeInTheDocument(); + expect(queryByText('Alert Manager')).not.toBeInTheDocument(); + expect(queryByText('Diagram')).not.toBeInTheDocument(); + expect(queryByText('ACE.SVG')).not.toBeInTheDocument(); + }); + }); + describe('when searching', () => { + it('should only list plugins matching search', async () => { + const { queryByText } = setup('/plugins?filterBy=all&q=zabbix'); + + await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument()); + + expect(queryByText('Wavefront')).not.toBeInTheDocument(); + expect(queryByText('Alert Manager')).not.toBeInTheDocument(); + expect(queryByText('Diagram')).not.toBeInTheDocument(); + expect(queryByText('Redis Application')).not.toBeInTheDocument(); + }); }); - it('should list all plugins (except core plugins) when filtering by all', async () => { - const { queryByText } = setup('/plugins?filterBy=all?filterByType=all'); + describe('when sorting', () => { + it('should sort plugins by name in ascending alphabetical order', async () => { + const { findByTestId } = setup('/plugins?filterBy=all'); - await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument()); - for (const plugin of remote) { - expect(queryByText(plugin.name)).toBeInTheDocument(); - } + const pluginList = await findByTestId('plugin-list'); + const pluginHeadings = within(pluginList).queryAllByRole('heading'); - expect(queryByText('Alert Manager')).not.toBeInTheDocument(); - }); + expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([ + 'ACE.SVG', + 'Diagram', + 'Redis Application', + 'Wavefront', + 'Zabbix', + ]); + }); - it('should list installed plugins (including core plugins) when filtering by installed', async () => { - const { queryByText } = setup('/plugins?filterBy=installed'); + it('should sort plugins by name in descending alphabetical order', async () => { + const { findByTestId } = setup('/plugins?filterBy=all&sortBy=nameDesc'); - await waitFor(() => queryByText('Installed')); + const pluginList = await findByTestId('plugin-list'); + const pluginHeadings = within(pluginList).queryAllByRole('heading'); - for (const plugin of installed) { - expect(queryByText(plugin.name)).toBeInTheDocument(); - } + expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([ + 'Zabbix', + 'Wavefront', + 'Redis Application', + 'Diagram', + 'ACE.SVG', + ]); + }); - for (const plugin of remote) { - expect(queryByText(plugin.name)).not.toBeInTheDocument(); - } - }); + it('should sort plugins by date in ascending updated order', async () => { + const { findByTestId } = setup('/plugins?filterBy=all&sortBy=updated'); - it('should list enterprise plugins', async () => { - const { queryByText } = setup('/plugins?filterBy=all&q=wavefront'); + const pluginList = await findByTestId('plugin-list'); + const pluginHeadings = within(pluginList).queryAllByRole('heading'); - await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument()); - }); + expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([ + 'Diagram', + 'Wavefront', + 'Redis Application', + 'ACE.SVG', + 'Zabbix', + ]); + }); - it('should list only datasource plugins when filtering by datasource', async () => { - const { queryByText } = setup('/plugins?filterBy=all&filterByType=datasource'); + it('should sort plugins by date in ascending published order', async () => { + const { findByTestId } = setup('/plugins?filterBy=all&sortBy=published'); - await waitFor(() => expect(queryByText('Wavefront')).toBeInTheDocument()); + const pluginList = await findByTestId('plugin-list'); + const pluginHeadings = within(pluginList).queryAllByRole('heading'); - expect(queryByText('Alert Manager')).not.toBeInTheDocument(); - expect(queryByText('Diagram')).not.toBeInTheDocument(); - expect(queryByText('Zabbix')).not.toBeInTheDocument(); - }); + expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([ + 'Diagram', + 'Redis Application', + 'ACE.SVG', + 'Wavefront', + 'Zabbix', + ]); + }); - it('should list only panel plugins when filtering by panel', async () => { - const { queryByText } = setup('/plugins?filterBy=all&filterByType=panel'); + it('should sort plugins by number of downloads in ascending order', async () => { + const { findByTestId } = setup('/plugins?filterBy=all&sortBy=downloads'); - await waitFor(() => expect(queryByText('Diagram')).toBeInTheDocument()); + const pluginList = await findByTestId('plugin-list'); + const pluginHeadings = within(pluginList).queryAllByRole('heading'); - expect(queryByText('Wavefront')).not.toBeInTheDocument(); - expect(queryByText('Alert Manager')).not.toBeInTheDocument(); - expect(queryByText('Zabbix')).not.toBeInTheDocument(); - }); - - it('should list only app plugins when filtering by app', async () => { - const { queryByText } = setup('/plugins?filterBy=all&filterByType=app'); - - await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument()); - - expect(queryByText('Wavefront')).not.toBeInTheDocument(); - expect(queryByText('Alert Manager')).not.toBeInTheDocument(); - expect(queryByText('Diagram')).not.toBeInTheDocument(); - }); - - it('should only list plugins matching search', async () => { - const { queryByText } = setup('/plugins?filterBy=all&q=zabbix'); - - await waitFor(() => expect(queryByText('Zabbix')).toBeInTheDocument()); - - expect(queryByText('Wavefront')).not.toBeInTheDocument(); - expect(queryByText('Alert Manager')).not.toBeInTheDocument(); - expect(queryByText('Diagram')).not.toBeInTheDocument(); + expect(pluginHeadings.map((heading) => heading.innerHTML)).toStrictEqual([ + 'Zabbix', + 'ACE.SVG', + 'Wavefront', + 'Diagram', + 'Redis Application', + ]); + }); }); }); const installed: LocalPlugin[] = [ { - category: '', - defaultNavUrl: '/plugins/alertmanager/', + name: 'Alert Manager', + type: PluginType.datasource, + id: 'alertmanager', + enabled: true, + pinned: false, info: { author: { name: 'Prometheus alertmanager', url: 'https://grafana.com', }, - build: {}, description: '', - links: [], + links: [ + { + name: 'Learn more', + url: 'https://prometheus.io/docs/alerting/latest/alertmanager/', + }, + ], logos: { - small: '', - large: '', + small: 'public/app/plugins/datasource/alertmanager/img/logo.svg', + large: 'public/app/plugins/datasource/alertmanager/img/logo.svg', }, - updated: '', + build: {}, + screenshots: null, version: '', + updated: '', }, - enabled: true, - hasUpdate: false, - id: 'alertmanager', latestVersion: '', - name: 'Alert Manager', - pinned: false, - signature: 'internal', - signatureOrg: '', - signatureType: '', + hasUpdate: false, + defaultNavUrl: '/plugins/alertmanager/', + category: '', state: 'alpha', - type: 'datasource', - dev: false, + signature: PluginSignatureStatus.internal, + signatureType: '', + signatureOrg: '', }, { name: 'Diagram', - type: 'panel', + type: PluginType.panel, id: 'jdbranham-diagram-panel', enabled: true, pinned: false, info: { - author: { - name: 'Jeremy Branham', - url: 'https://savantly.net', - }, + author: { name: 'Jeremy Branham', url: 'https://savantly.net' }, description: 'Display diagrams and charts with colored metric indicators', links: [ { @@ -187,61 +274,153 @@ const installed: LocalPlugin[] = [ large: 'public/plugins/jdbranham-diagram-panel/img/logo.svg', }, build: {}, - version: '1.7.1', - updated: '2021-05-26', + screenshots: [], + version: '1.7.3', + updated: '2021-07-20', }, latestVersion: '1.7.3', hasUpdate: true, defaultNavUrl: '/plugins/jdbranham-diagram-panel/', category: '', state: '', - signature: 'unsigned', + signature: PluginSignatureStatus.missing, signatureType: '', signatureOrg: '', - dev: false, + }, + { + name: 'Redis Application', + type: PluginType.app, + id: 'redis-app', + enabled: false, + pinned: false, + info: { + author: { + name: 'RedisGrafana', + url: 'https://redisgrafana.github.io', + }, + description: 'Provides Application pages and custom panels for Redis Data Source.', + links: [ + { name: 'Website', url: 'https://redisgrafana.github.io' }, + { + name: 'License', + url: 'https://github.com/RedisGrafana/grafana-redis-app/blob/master/LICENSE', + }, + ], + logos: { + small: 'public/plugins/redis-app/img/logo.svg', + large: 'public/plugins/redis-app/img/logo.svg', + }, + build: {}, + screenshots: [], + version: '2.0.1', + updated: '2021-07-07', + }, + latestVersion: '2.0.1', + hasUpdate: false, + defaultNavUrl: '/plugins/redis-app/', + category: '', + state: '', + signature: PluginSignatureStatus.valid, + signatureType: PluginSignatureType.commercial, + signatureOrg: 'RedisGrafana', }, ]; -const remote: Plugin[] = [ + +const remote: RemotePlugin[] = [ { - createdAt: '2016-04-06T20:23:41.000Z', - description: 'Zabbix plugin for Grafana', - downloads: 33645089, - featured: 180, - internal: false, - links: [], + status: 'active', + id: 74, + typeId: 1, + typeName: 'Application', + typeCode: PluginType.app, + slug: 'alexanderzobnin-zabbix-app', name: 'Zabbix', + description: 'Zabbix plugin for Grafana', + version: '4.1.5', + versionStatus: 'active', + versionSignatureType: PluginSignatureType.community, + versionSignedByOrg: 'alexanderzobnin', + versionSignedByOrgName: 'Alexander Zobnin', + userId: 0, + orgId: 13056, orgName: 'Alexander Zobnin', orgSlug: 'alexanderzobnin', - packages: {}, - popularity: 0.2111, - signatureType: 'community', - slug: 'alexanderzobnin-zabbix-app', - status: 'active', - typeCode: 'app', + orgUrl: 'https://github.com/alexanderzobnin', + url: 'https://github.com/alexanderzobnin/grafana-zabbix', + createdAt: '2016-04-06T20:23:41.000Z', updatedAt: '2021-05-18T14:53:01.000Z', - version: '4.1.5', - versionSignatureType: 'community', - readme: '', + downloads: 34387994, + verified: false, + featured: 180, + internal: false, + downloadSlug: 'alexanderzobnin-zabbix-app', + popularity: 0.2019, + signatureType: PluginSignatureType.community, + packages: {}, + links: [], }, { - createdAt: '2020-09-01T13:02:57.000Z', - description: 'Wavefront Datasource', - downloads: 7283, - featured: 0, - internal: false, - links: [], + status: 'enterprise', + id: 658, + typeId: 2, + typeName: 'Data Source', + typeCode: PluginType.datasource, + slug: 'grafana-wavefront-datasource', name: 'Wavefront', + description: 'Wavefront Datasource', + version: '1.0.8', + versionStatus: 'active', + versionSignatureType: PluginSignatureType.grafana, + versionSignedByOrg: 'grafana', + versionSignedByOrgName: 'Grafana Labs', + userId: 0, + orgId: 5000, orgName: 'Grafana Labs', orgSlug: 'grafana', + orgUrl: 'https://grafana.org', + url: 'https://github.com/grafana/wavefront-datasource/', + createdAt: '2020-09-01T13:02:57.000Z', + updatedAt: '2021-07-12T18:41:03.000Z', + downloads: 7818, + verified: false, + featured: 0, + internal: false, + downloadSlug: 'grafana-wavefront-datasource', + popularity: 0.0107, + signatureType: PluginSignatureType.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: '', + links: [], + }, + { + status: 'active', + id: 659, + typeId: 3, + typeName: 'Panel', + typeCode: PluginType.panel, + slug: 'aceiot-svg-panel', + name: 'ACE.SVG', + description: 'SVG Visualization Panel', + version: '0.0.10', + versionStatus: 'active', + versionSignatureType: PluginSignatureType.community, + versionSignedByOrg: 'aceiot', + versionSignedByOrgName: 'Andrew Rodgers', + userId: 0, + orgId: 409764, + orgName: 'Andrew Rodgers', + orgSlug: 'aceiot', + orgUrl: '', + url: 'https://github.com/ACE-IoT-Solutions/ace-svg-react', + createdAt: '2020-09-01T14:46:44.000Z', + updatedAt: '2021-06-28T14:01:36.000Z', + downloads: 101569, + verified: false, + featured: 0, + internal: false, + downloadSlug: 'aceiot-svg-panel', + popularity: 0.0134, + signatureType: PluginSignatureType.community, + packages: {}, + links: [], }, ]; diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx index 2579bc6ea16..6cb4f1e21d3 100644 --- a/public/app/features/plugins/admin/pages/Browse.tsx +++ b/public/app/features/plugins/admin/pages/Browse.tsx @@ -27,7 +27,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem const q = (query.q as string) ?? ''; const filterBy = (query.filterBy as string) ?? 'installed'; const filterByType = (query.filterByType as string) ?? 'all'; - const sortBy = (query.sortBy as string) ?? 'name'; + const sortBy = (query.sortBy as string) ?? 'nameAsc'; const { plugins, isLoading, error } = usePluginsByFilter({ searchBy: q, filterBy, filterByType }); const sortedPlugins = plugins.sort(sorters[sortBy]); @@ -90,7 +90,8 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem value={sortBy} onChange={onSortByChange} options={[ - { value: 'name', label: 'Sort by name (A-Z)' }, + { value: 'nameAsc', label: 'Sort by name (A-Z)' }, + { value: 'nameDesc', label: 'Sort by name (Z-A)' }, { value: 'updated', label: 'Sort by updated date' }, { value: 'published', label: 'Sort by published date' }, { value: 'downloads', label: 'Sort by downloads' }, @@ -139,7 +140,8 @@ const getNavModelId = (routeName?: string) => { }; const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = { - name: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name), + nameAsc: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name), + nameDesc: (a: CatalogPlugin, b: CatalogPlugin) => b.name.localeCompare(a.name), updated: (a: CatalogPlugin, b: CatalogPlugin) => dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(), published: (a: CatalogPlugin, b: CatalogPlugin) => diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index d4bc3e712b9..9b8bd471147 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { render, RenderResult, waitFor } from '@testing-library/react'; +import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data'; import PluginDetailsPage from './PluginDetails'; import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; -import { LocalPlugin, Plugin } from '../types'; +import { LocalPlugin, RemotePlugin } from '../types'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; jest.mock('@grafana/runtime', () => { @@ -74,26 +75,38 @@ describe('Plugin details page', () => { }); }); -function remotePlugin(plugin: Partial = {}): Plugin { +function remotePlugin(plugin: Partial = {}): RemotePlugin { return { createdAt: '2016-04-06T20:23:41.000Z', description: 'Zabbix plugin for Grafana', downloads: 33645089, featured: 180, + id: 74, + typeId: 1, + typeName: 'Application', internal: false, links: [], name: 'Zabbix', + orgId: 13056, orgName: 'Alexander Zobnin', orgSlug: 'alexanderzobnin', + orgUrl: 'https://github.com/alexanderzobnin', + url: 'https://github.com/alexanderzobnin/grafana-zabbix', + verified: false, + downloadSlug: 'alexanderzobnin-zabbix-app', packages: {}, popularity: 0.2111, - signatureType: 'community', + signatureType: PluginSignatureType.community, slug: 'alexanderzobnin-zabbix-app', status: 'active', - typeCode: 'app', + typeCode: PluginType.app, updatedAt: '2021-05-18T14:53:01.000Z', version: '4.1.5', - versionSignatureType: 'community', + versionStatus: 'active', + versionSignatureType: PluginSignatureType.community, + versionSignedByOrg: 'alexanderzobnin', + versionSignedByOrgName: 'Alexander Zobnin', + userId: 0, readme: '', json: { dependencies: { @@ -133,12 +146,11 @@ function localPlugin(plugin: Partial = {}): LocalPlugin { latestVersion: '', name: 'Alert Manager', pinned: false, - signature: 'internal', + signature: PluginSignatureStatus.internal, signatureOrg: '', signatureType: '', state: 'alpha', - type: 'datasource', - dev: false, + type: PluginType.datasource, ...plugin, }; } @@ -147,7 +159,6 @@ function corePlugin(plugin: Partial = {}): LocalPlugin { return { category: 'sql', defaultNavUrl: '/plugins/postgres/', - dev: false, enabled: true, hasUpdate: false, id: 'core', @@ -166,11 +177,11 @@ function corePlugin(plugin: Partial = {}): LocalPlugin { latestVersion: '', name: 'PostgreSQL', pinned: false, - signature: 'internal', + signature: PluginSignatureStatus.internal, signatureOrg: '', signatureType: '', state: '', - type: 'datasource', + type: PluginType.datasource, ...plugin, }; } diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index 51016c1d78d..c5cf534e2aa 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -49,6 +49,7 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
; json?: { dependencies: { grafanaDependency: string; @@ -86,50 +67,89 @@ export interface Plugin { }>; }; }; -} + links: Array<{ rel: string; href: string }>; + name: string; + orgId: number; + orgName: string; + orgSlug: string; + orgUrl: string; + packages: { + [arch: string]: { + packageName: string; + downloadUrl: string; + }; + }; + popularity: number; + readme?: string; + signatureType: PluginSignatureType | ''; + slug: string; + status: string; + typeCode: PluginType; + typeId: number; + typeName: string; + updatedAt: string; + url: string; + userId: number; + verified: boolean; + version: string; + versionSignatureType: PluginSignatureType | ''; + versionSignedByOrg: string; + versionSignedByOrgName: string; + versionStatus: string; +}; export type LocalPlugin = { category: string; defaultNavUrl: string; + dev?: boolean; enabled: boolean; hasUpdate: boolean; id: string; info: { - author: { - name: string; - url: string; - }; - build: {}; + author: Rel; description: string; - links: Array<{ - name: string; - url: string; - }>; + links?: Rel[]; logos: { - large: string; small: string; + large: string; }; - updated: string; + build: Build; + screenshots?: Array<{ + path: string; + name: string; + }> | null; version: string; + updated: string; }; latestVersion: string; name: string; pinned: boolean; - signature: string; + signature: PluginSignatureStatus; signatureOrg: string; signatureType: string; state: string; - type: string; - dev: boolean | undefined; + type: PluginType; }; +interface Rel { + name: string; + url: string; +} + +export interface Build { + time?: number; + repo?: string; + branch?: string; + hash?: string; +} + export interface Version { version: string; createdAt: string; } export interface PluginDetails { - remote?: Plugin; + remote?: RemotePlugin; remoteVersions?: Version[]; local?: LocalPlugin; }