diff --git a/public/app/features/plugins/admin/components/PluginBadges.test.tsx b/public/app/features/plugins/admin/components/PluginBadges.test.tsx index c801012f569..b277d6b84ab 100644 --- a/public/app/features/plugins/admin/components/PluginBadges.test.tsx +++ b/public/app/features/plugins/admin/components/PluginBadges.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { PluginSignatureStatus } from '@grafana/data'; -import { PluginBadges } from './PluginBadges'; +import { PluginListBadges } from './PluginListBadges'; import { CatalogPlugin } from '../types'; import { config } from '@grafana/runtime'; @@ -35,13 +35,13 @@ describe('PluginBadges', () => { }); it('renders a plugin signature badge', () => { - render(); + render(); expect(screen.getByText(/signed/i)).toBeVisible(); }); it('renders an installed badge', () => { - render(); + render(); expect(screen.getByText(/signed/i)).toBeVisible(); expect(screen.getByText(/installed/i)).toBeVisible(); @@ -49,14 +49,14 @@ describe('PluginBadges', () => { it('renders an enterprise badge (when a license is valid)', () => { config.licenseInfo.hasValidLicense = true; - render(); + 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)', () => { config.licenseInfo.hasValidLicense = false; - render(); + 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/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx index 2e3926a9225..504fb3b01b4 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -4,6 +4,7 @@ import { css, cx } from '@emotion/css'; import { AppPlugin, GrafanaTheme2, GrafanaPlugin, PluginMeta } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; +import { PluginTabLabels } from '../types'; import { VersionList } from '../components/VersionList'; import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper'; import { PluginDashboards } from '../../PluginDashboards'; @@ -18,7 +19,7 @@ type PluginDetailsBodyProps = { export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: PluginDetailsBodyProps): JSX.Element | null { const styles = useStyles2(getStyles); - if (tab?.label === 'Overview') { + if (tab?.label === PluginTabLabels.OVERVIEW) { return (
@@ -35,7 +36,7 @@ export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: Plugi ); } - if (tab?.label === 'Config' && plugin?.angularConfigCtrl) { + if (tab?.label === PluginTabLabels.CONFIG && plugin?.angularConfigCtrl) { return (
@@ -55,7 +56,7 @@ export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: Plugi } } - if (tab?.label === 'Dashboards' && plugin) { + if (tab?.label === PluginTabLabels.DASHBOARDS && plugin) { return (
diff --git a/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx b/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx new file mode 100644 index 00000000000..e168d1f28b9 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Icon } from '@grafana/ui'; + +import { InstallControls } from './InstallControls'; +import { usePluginDetails } from '../hooks/usePluginDetails'; +import { PluginDetailsHeaderSignature } from './PluginDetailsHeaderSignature'; +import { PluginLogo } from './PluginLogo'; + +type Props = { + parentUrl: string; + currentUrl: string; + pluginId?: string; +}; + +export function PluginDetailsHeader({ pluginId, parentUrl, currentUrl }: Props): React.ReactElement | null { + const styles = useStyles2(getStyles); + const { state, dispatch } = usePluginDetails(pluginId!); + const { plugin, pluginConfig, isInflight, hasUpdate, isInstalled, hasInstalledPanel } = state; + + if (!plugin) { + return null; + } + + return ( +
+ + +
+ {/* Title & navigation */} + + +
+ {/* Org name */} + {plugin.orgName} + + {/* Links */} + {plugin.links.map((link: any) => ( + + {link.name} + + ))} + + {/* Downloads */} + {plugin.downloads > 0 && ( + + + {` ${new Intl.NumberFormat().format(plugin.downloads)}`}{' '} + + )} + + {/* Latest version */} + {plugin.version && {plugin.version}} + + {/* Signature information */} + +
+ +

{plugin.description}

+ + +
+
+ ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + return { + headerContainer: css` + display: flex; + margin-bottom: ${theme.spacing(3)}; + margin-top: ${theme.spacing(3)}; + min-height: 120px; + `, + headerWrapper: css` + margin-left: ${theme.spacing(3)}; + `, + breadcrumb: css` + font-size: ${theme.typography.h2.fontSize}; + li { + display: inline; + list-style: none; + &::after { + content: '/'; + padding: 0 0.25ch; + } + &:last-child::after { + content: ''; + } + } + `, + headerInformation: css` + display: flex; + align-items: center; + margin-top: ${theme.spacing()}; + margin-bottom: ${theme.spacing(3)}; + + & > * { + &::after { + content: '|'; + padding: 0 ${theme.spacing()}; + } + &:last-child::after { + content: ''; + padding-right: 0; + } + } + font-size: ${theme.typography.h4.fontSize}; + `, + headerOrgName: css` + font-size: ${theme.typography.h4.fontSize}; + `, + signature: css` + margin: ${theme.spacing(3)}; + margin-bottom: 0; + `, + textUnderline: css` + text-decoration: underline; + `, + }; +}; diff --git a/public/app/features/plugins/admin/components/PluginDetailsHeaderSignature.tsx b/public/app/features/plugins/admin/components/PluginDetailsHeaderSignature.tsx new file mode 100644 index 00000000000..a45d1a62cb0 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginDetailsHeaderSignature.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { GrafanaPlugin, PluginMeta, PluginSignatureStatus } from '@grafana/data'; +import { PluginSignatureBadge } from '@grafana/ui'; +import { PluginSignatureDetailsBadge } from './PluginSignatureDetailsBadge'; + +type Props = { + installedPlugin?: GrafanaPlugin>; +}; + +// Designed to show plugin signature information in the header on the plugin's details page +export function PluginDetailsHeaderSignature({ installedPlugin }: Props): React.ReactElement | null { + if (!installedPlugin) { + return null; + } + + const isSignatureValid = installedPlugin.meta.signature === PluginSignatureStatus.valid; + + return ( +
+ + + + + {isSignatureValid && ( + + )} +
+ ); +} diff --git a/public/app/features/plugins/admin/components/PluginDetailsSignature.tsx b/public/app/features/plugins/admin/components/PluginDetailsSignature.tsx new file mode 100644 index 00000000000..4070dce9906 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginDetailsSignature.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { selectors } from '@grafana/e2e-selectors'; +import { GrafanaPlugin, PluginMeta, PluginSignatureStatus } from '@grafana/data'; +import { Alert } from '@grafana/ui'; + +type PluginDetailsSignatureProps = { + className?: string; + installedPlugin?: GrafanaPlugin>; +}; + +// Designed to show signature information inside the active tab on the plugin's details page +export function PluginDetailsSignature({ + className, + installedPlugin, +}: PluginDetailsSignatureProps): React.ReactElement | null { + if (!installedPlugin) { + return null; + } + + const isSignatureValid = installedPlugin.meta.signature === PluginSignatureStatus.valid; + const isCore = installedPlugin.meta.signature === PluginSignatureStatus.internal; + + // The basic information is already available in the header + if (isSignatureValid || isCore) { + return null; + } + + return ( + +

+ Grafana Labs checks each plugin to verify that it has a valid digital signature. Plugin signature verification + is part of our security measures to ensure plugins are safe and trustworthy. Grafana Labs can’t guarantee the + integrity of this unsigned plugin. Ask the plugin author to request it to be signed. +

+ + + Read more about plugins signing. + +
+ ); +} diff --git a/public/app/features/plugins/admin/components/PluginBadges.tsx b/public/app/features/plugins/admin/components/PluginListBadges.tsx similarity index 82% rename from public/app/features/plugins/admin/components/PluginBadges.tsx rename to public/app/features/plugins/admin/components/PluginListBadges.tsx index b79cb3b18b9..be022fd4598 100644 --- a/public/app/features/plugins/admin/components/PluginBadges.tsx +++ b/public/app/features/plugins/admin/components/PluginListBadges.tsx @@ -9,9 +9,9 @@ type PluginBadgeType = { plugin: CatalogPlugin; }; -export function PluginBadges({ plugin }: PluginBadgeType) { +export function PluginListBadges({ plugin }: PluginBadgeType) { if (plugin.isEnterprise) { - return ; + return ; } return ( @@ -21,12 +21,12 @@ export function PluginBadges({ plugin }: PluginBadgeType) { ); } -function EnterpriseBadge({ id }: { id: string }) { +function EnterpriseBadge({ plugin }: { plugin: CatalogPlugin }) { 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`, + `https://grafana.com/grafana/plugins/${plugin.id}?utm_source=grafana_catalog_learn_more`, '_blank', 'noopener,noreferrer' ); @@ -38,6 +38,7 @@ function EnterpriseBadge({ id }: { id: string }) { return ( +

By {orgName}

- + ); diff --git a/public/app/features/plugins/admin/components/PluginSignatureDetailsBadge.tsx b/public/app/features/plugins/admin/components/PluginSignatureDetailsBadge.tsx new file mode 100644 index 00000000000..fc87f60b461 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginSignatureDetailsBadge.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { capitalize } from 'lodash'; +import { GrafanaTheme2, PluginSignatureType } from '@grafana/data'; +import { useStyles2, Icon, Badge, IconName } from '@grafana/ui'; + +const SIGNATURE_ICONS: Record = { + [PluginSignatureType.grafana]: 'grafana', + [PluginSignatureType.commercial]: 'shield', + [PluginSignatureType.community]: 'shield', + DEFAULT: 'shield-exclamation', +}; + +type Props = { + signatureType?: PluginSignatureType; + signatureOrg?: string; +}; + +// Shows more information about a valid signature +export function PluginSignatureDetailsBadge({ signatureType, signatureOrg = '' }: Props): React.ReactElement | null { + const styles = useStyles2(getStyles); + + if (!signatureType && !signatureOrg) { + return null; + } + + const signatureTypeText = signatureType === PluginSignatureType.grafana ? 'Grafana Labs' : capitalize(signatureType); + const signatureIcon = SIGNATURE_ICONS[signatureType || ''] || SIGNATURE_ICONS.DEFAULT; + + return ( + <> + + Level:  + +   + {signatureTypeText} + + + + Signed by: {signatureOrg} + + + ); +} + +export const DetailsBadge: React.FC = ({ children }) => { + const styles = useStyles2(getStyles); + + return {children}} />; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + badge: css` + background-color: ${theme.colors.background.canvas}; + border-color: ${theme.colors.border.strong}; + color: ${theme.colors.text.secondary}; + margin-left: ${theme.spacing()}; + `, + strong: css` + color: ${theme.colors.text.primary}; + `, + icon: css` + margin-right: ${theme.spacing(0.5)}; + `, +}); diff --git a/public/app/features/plugins/admin/hooks/usePluginDetails.tsx b/public/app/features/plugins/admin/hooks/usePluginDetails.tsx index 31898c0d0ad..c14a0b9a972 100644 --- a/public/app/features/plugins/admin/hooks/usePluginDetails.tsx +++ b/public/app/features/plugins/admin/hooks/usePluginDetails.tsx @@ -1,11 +1,15 @@ import { useReducer, useEffect } from 'react'; -import { PluginType, PluginIncludeType } from '@grafana/data'; +import { PluginType, PluginIncludeType, GrafanaPlugin, PluginMeta } from '@grafana/data'; import { api } from '../api'; import { loadPlugin } from '../../PluginPage'; import { getCatalogPluginDetails, isOrgAdmin } from '../helpers'; -import { ActionTypes, PluginDetailsActions, PluginDetailsState } from '../types'; +import { ActionTypes, CatalogPluginDetails, PluginDetailsActions, PluginDetailsState, PluginTabLabels } from '../types'; -const defaultTabs = [{ label: 'Overview' }, { label: 'Version history' }]; +type Tab = { + label: PluginTabLabels; +}; + +const defaultTabs: Tab[] = [{ label: PluginTabLabels.OVERVIEW }, { label: PluginTabLabels.VERSIONS }]; const initialState = { hasInstalledPanel: false, @@ -84,6 +88,9 @@ const reducer = (state: PluginDetailsState, action: PluginDetailsActions) => { } }; +const pluginCache: Record = {}; +const pluginConfigCache: Record>> = {}; + export const usePluginDetails = (id: string) => { const [state, dispatch] = useReducer(reducer, initialState); const userCanConfigurePlugins = isOrgAdmin(); @@ -92,8 +99,16 @@ export const usePluginDetails = (id: string) => { const fetchPlugin = async () => { dispatch({ type: ActionTypes.LOADING }); try { - const value = await api.getPlugin(id); - const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions); + let plugin; + + if (pluginCache[id]) { + plugin = pluginCache[id]; + } else { + const value = await api.getPlugin(id); + plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions); + pluginCache[id] = plugin; + } + dispatch({ type: ActionTypes.FETCHED_PLUGIN, payload: plugin }); } catch (error) { dispatch({ type: ActionTypes.ERROR, payload: error }); @@ -107,7 +122,15 @@ export const usePluginDetails = (id: string) => { if (state.isInstalled) { dispatch({ type: ActionTypes.LOADING }); try { - const pluginConfig = await loadPlugin(id); + let pluginConfig; + + if (pluginConfigCache[id]) { + pluginConfig = pluginConfigCache[id]; + } else { + pluginConfig = await loadPlugin(id); + pluginConfigCache[id] = pluginConfig; + } + dispatch({ type: ActionTypes.FETCHED_PLUGIN_CONFIG, payload: pluginConfig }); } catch (error) { dispatch({ type: ActionTypes.ERROR, payload: error }); @@ -123,27 +146,28 @@ export const usePluginDetails = (id: string) => { useEffect(() => { const pluginConfig = state.pluginConfig; - const tabs: Array<{ label: string }> = [...defaultTabs]; + const tabs: Tab[] = [...defaultTabs]; if (pluginConfig && userCanConfigurePlugins) { if (pluginConfig.meta.type === PluginType.app) { if (pluginConfig.angularConfigCtrl) { tabs.push({ - label: 'Config', + label: PluginTabLabels.CONFIG, }); } + // Configuration pages with custom labels if (pluginConfig.configPages) { for (const page of pluginConfig.configPages) { tabs.push({ - label: page.title, + label: page.title as PluginTabLabels, }); } } if (pluginConfig.meta.includes?.find((include) => include.type === PluginIncludeType.dashboard)) { tabs.push({ - label: 'Dashboards', + label: PluginTabLabels.DASHBOARDS, }); } } diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index c5cf534e2aa..2856d8f631d 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -1,15 +1,15 @@ import React from 'react'; import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, TabsBar, TabContent, Tab, Icon, Alert } from '@grafana/ui'; +import { useStyles2, TabsBar, TabContent, Tab, Alert } from '@grafana/ui'; import { AppNotificationSeverity } from 'app/types'; -import { InstallControls } from '../components/InstallControls'; +import { PluginDetailsSignature } from '../components/PluginDetailsSignature'; +import { PluginDetailsHeader } from '../components/PluginDetailsHeader'; import { usePluginDetails } from '../hooks/usePluginDetails'; import { Page as PluginPage } from '../components/Page'; import { Loader } from '../components/Loader'; import { Page } from 'app/core/components/Page/Page'; -import { PluginLogo } from '../components/PluginLogo'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { ActionTypes } from '../types'; import { PluginDetailsBody } from '../components/PluginDetailsBody'; @@ -19,21 +19,10 @@ type PluginDetailsProps = GrafanaRouteComponentProps<{ pluginId?: string }>; export default function PluginDetails({ match }: PluginDetailsProps): JSX.Element | null { const { pluginId } = match.params; const { state, dispatch } = usePluginDetails(pluginId!); - const { - loading, - error, - plugin, - pluginConfig, - tabs, - activeTab, - isInflight, - hasUpdate, - isInstalled, - hasInstalledPanel, - } = state; + const { loading, error, plugin, pluginConfig, tabs, activeTab } = state; const tab = tabs[activeTab]; const styles = useStyles2(getStyles); - const breadcrumbHref = match.url.substring(0, match.url.lastIndexOf('/')); + const parentUrl = match.url.substring(0, match.url.lastIndexOf('/')); if (loading) { return ( @@ -43,147 +32,54 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen ); } - if (plugin) { - return ( - - -
- - -
- -
- {plugin.orgName} - {plugin.links.map((link: any) => ( - - {link.name} - - ))} - {plugin.downloads > 0 && ( - - - {` ${new Intl.NumberFormat().format(plugin.downloads)}`}{' '} - - )} - {plugin.version && {plugin.version}} -
-

{plugin.description}

- -
-
- - {tabs.map((tab: { label: string }, idx: number) => ( - dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: idx })} - /> - ))} - - - {error && ( - - <> - Check the server startup logs for more information.
- If this plugin was loaded from git, make sure it was compiled. - -
- )} - -
-
-
- ); + if (!plugin) { + return null; } - return null; + return ( + + + + + {/* Tab navigation */} + + {tabs.map((tab: { label: string }, idx: number) => ( + dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: idx })} + /> + ))} + + + {/* Active tab */} + + {error && ( + + <> + Check the server startup logs for more information.
+ If this plugin was loaded from git, make sure it was compiled. + +
+ )} + + +
+
+
+ ); } export const getStyles = (theme: GrafanaTheme2) => { return { - headerContainer: css` - display: flex; - margin-bottom: ${theme.spacing(3)}; - margin-top: ${theme.spacing(3)}; - min-height: 120px; + signature: css` + margin: ${theme.spacing(3)}; + margin-bottom: 0; `, - headerWrapper: css` - margin-left: ${theme.spacing(3)}; - `, - breadcrumb: css` - font-size: ${theme.typography.h2.fontSize}; - li { - display: inline; - list-style: none; - &::after { - content: '/'; - padding: 0 0.25ch; - } - &:last-child::after { - content: ''; - } - } - `, - headerLinks: css` - display: flex; - align-items: center; - margin-top: ${theme.spacing()}; - margin-bottom: ${theme.spacing(3)}; - - & > * { - &::after { - content: '|'; - padding: 0 ${theme.spacing()}; - } - &:last-child::after { - content: ''; - padding-right: 0; - } - } - font-size: ${theme.typography.h4.fontSize}; - `, - headerOrgName: css` - font-size: ${theme.typography.h4.fontSize}; + // Needed due to block formatting context + tabContent: css` + overflow: auto; `, }; }; diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 055fc29bdd7..78ddbc7b7a5 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -229,3 +229,10 @@ export enum PluginStatus { UNINSTALL = 'UNINSTALL', UPDATE = 'UPDATE', } + +export enum PluginTabLabels { + OVERVIEW = 'Overview', + VERSIONS = 'Version history', + CONFIG = 'Config', + DASHBOARDS = 'Dashboards', +}