Plugins Catalog: add signature information to details (#38558)

* refaactor: extract the tab labels into a const

* feat: list - show signature badge for enterprise plugins as well

* feat(plugins): add a component for showing plugin signature details

* feat: add a component for showing signature info under details

* feat: add a component for displaying signature info in details header

* feat: extract the plugin details header into a separate component

* feat: show signature information on the plugins details page

* refactor(Plugins): use an enum instead of an object

* refactor(Plugins): use more strict typing for tabs

* refactor(Plugins): use function declaration instead of fat-arrow for components

* fix(Plugins): fix typo

* fix: make installed plugin config an optional param again

* refactor: cache plugin meta requests

* refactor: move PLUGIN_TAB_LABELS to the types module
This commit is contained in:
Levente Balogh
2021-08-30 14:00:11 +02:00
committed by GitHub
parent d15cbe4b4e
commit 82c038bd06
11 changed files with 403 additions and 175 deletions

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { PluginSignatureStatus } from '@grafana/data'; import { PluginSignatureStatus } from '@grafana/data';
import { PluginBadges } from './PluginBadges'; import { PluginListBadges } from './PluginListBadges';
import { CatalogPlugin } from '../types'; import { CatalogPlugin } from '../types';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
@@ -35,13 +35,13 @@ describe('PluginBadges', () => {
}); });
it('renders a plugin signature badge', () => { it('renders a plugin signature badge', () => {
render(<PluginBadges plugin={plugin} />); render(<PluginListBadges plugin={plugin} />);
expect(screen.getByText(/signed/i)).toBeVisible(); expect(screen.getByText(/signed/i)).toBeVisible();
}); });
it('renders an installed badge', () => { it('renders an installed badge', () => {
render(<PluginBadges plugin={{ ...plugin, isInstalled: true }} />); render(<PluginListBadges plugin={{ ...plugin, isInstalled: true }} />);
expect(screen.getByText(/signed/i)).toBeVisible(); expect(screen.getByText(/signed/i)).toBeVisible();
expect(screen.getByText(/installed/i)).toBeVisible(); expect(screen.getByText(/installed/i)).toBeVisible();
@@ -49,14 +49,14 @@ describe('PluginBadges', () => {
it('renders an enterprise badge (when a license is valid)', () => { it('renders an enterprise badge (when a license is valid)', () => {
config.licenseInfo.hasValidLicense = true; config.licenseInfo.hasValidLicense = true;
render(<PluginBadges plugin={{ ...plugin, isEnterprise: true }} />); render(<PluginListBadges plugin={{ ...plugin, isEnterprise: true }} />);
expect(screen.getByText(/enterprise/i)).toBeVisible(); expect(screen.getByText(/enterprise/i)).toBeVisible();
expect(screen.queryByRole('button', { name: /learn more/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /learn more/i })).not.toBeInTheDocument();
}); });
it('renders an enterprise badge with icon and link (when a license is invalid)', () => { it('renders an enterprise badge with icon and link (when a license is invalid)', () => {
config.licenseInfo.hasValidLicense = false; config.licenseInfo.hasValidLicense = false;
render(<PluginBadges plugin={{ ...plugin, isEnterprise: true }} />); render(<PluginListBadges plugin={{ ...plugin, isEnterprise: true }} />);
expect(screen.getByText(/enterprise/i)).toBeVisible(); expect(screen.getByText(/enterprise/i)).toBeVisible();
expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument(); expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /learn more/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /learn more/i })).toBeInTheDocument();

View File

@@ -4,6 +4,7 @@ import { css, cx } from '@emotion/css';
import { AppPlugin, GrafanaTheme2, GrafanaPlugin, PluginMeta } from '@grafana/data'; import { AppPlugin, GrafanaTheme2, GrafanaPlugin, PluginMeta } from '@grafana/data';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { PluginTabLabels } from '../types';
import { VersionList } from '../components/VersionList'; import { VersionList } from '../components/VersionList';
import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper'; import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper';
import { PluginDashboards } from '../../PluginDashboards'; import { PluginDashboards } from '../../PluginDashboards';
@@ -18,7 +19,7 @@ type PluginDetailsBodyProps = {
export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: PluginDetailsBodyProps): JSX.Element | null { export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: PluginDetailsBodyProps): JSX.Element | null {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
if (tab?.label === 'Overview') { if (tab?.label === PluginTabLabels.OVERVIEW) {
return ( return (
<div <div
className={cx(styles.readme, styles.container)} className={cx(styles.readme, styles.container)}
@@ -27,7 +28,7 @@ export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: Plugi
); );
} }
if (tab?.label === 'Version history') { if (tab?.label === PluginTabLabels.VERSIONS) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<VersionList versions={remoteVersions ?? []} /> <VersionList versions={remoteVersions ?? []} />
@@ -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 ( return (
<div className={styles.container}> <div className={styles.container}>
<AppConfigCtrlWrapper app={plugin as AppPlugin} /> <AppConfigCtrlWrapper app={plugin as AppPlugin} />
@@ -55,7 +56,7 @@ export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: Plugi
} }
} }
if (tab?.label === 'Dashboards' && plugin) { if (tab?.label === PluginTabLabels.DASHBOARDS && plugin) {
return ( return (
<div className={styles.container}> <div className={styles.container}>
<PluginDashboards plugin={plugin.meta} /> <PluginDashboards plugin={plugin.meta} />

View File

@@ -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 (
<div className={styles.headerContainer}>
<PluginLogo
alt={`${plugin.name} logo`}
src={plugin.info.logos.small}
className={css`
object-fit: contain;
width: 100%;
height: 68px;
max-width: 68px;
`}
/>
<div className={styles.headerWrapper}>
{/* Title & navigation */}
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<ol>
<li>
<a className={styles.textUnderline} href={parentUrl}>
Plugins
</a>
</li>
<li>
<a href={currentUrl} aria-current="page">
{plugin.name}
</a>
</li>
</ol>
</nav>
<div className={styles.headerInformation}>
{/* Org name */}
<span>{plugin.orgName}</span>
{/* Links */}
{plugin.links.map((link: any) => (
<a key={link.name} href={link.url}>
{link.name}
</a>
))}
{/* Downloads */}
{plugin.downloads > 0 && (
<span>
<Icon name="cloud-download" />
{` ${new Intl.NumberFormat().format(plugin.downloads)}`}{' '}
</span>
)}
{/* Latest version */}
{plugin.version && <span>{plugin.version}</span>}
{/* Signature information */}
<PluginDetailsHeaderSignature installedPlugin={pluginConfig} />
</div>
<p>{plugin.description}</p>
<InstallControls
plugin={plugin}
isInflight={isInflight}
hasUpdate={hasUpdate}
isInstalled={isInstalled}
hasInstalledPanel={hasInstalledPanel}
dispatch={dispatch}
/>
</div>
</div>
);
}
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;
`,
};
};

View File

@@ -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<PluginMeta<{}>>;
};
// 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 (
<div>
<a href="https://grafana.com/docs/grafana/latest/plugins/plugin-signatures/" target="_blank" rel="noreferrer">
<PluginSignatureBadge status={installedPlugin.meta.signature} />
</a>
{isSignatureValid && (
<PluginSignatureDetailsBadge
signatureType={installedPlugin.meta.signatureType}
signatureOrg={installedPlugin.meta.signatureOrg}
/>
)}
</div>
);
}

View File

@@ -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<PluginMeta<{}>>;
};
// 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 (
<Alert
severity="warning"
title="Invalid plugin signature"
aria-label={selectors.pages.PluginPage.signatureInfo}
className={className}
>
<p>
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 cant guarantee the
integrity of this unsigned plugin. Ask the plugin author to request it to be signed.
</p>
<a
href="https://grafana.com/docs/grafana/latest/plugins/plugin-signatures/"
className="external-link"
target="_blank"
rel="noreferrer"
>
Read more about plugins signing.
</a>
</Alert>
);
}

View File

@@ -9,9 +9,9 @@ type PluginBadgeType = {
plugin: CatalogPlugin; plugin: CatalogPlugin;
}; };
export function PluginBadges({ plugin }: PluginBadgeType) { export function PluginListBadges({ plugin }: PluginBadgeType) {
if (plugin.isEnterprise) { if (plugin.isEnterprise) {
return <EnterpriseBadge id={plugin.id} />; return <EnterpriseBadge plugin={plugin} />;
} }
return ( return (
<HorizontalGroup> <HorizontalGroup>
@@ -21,12 +21,12 @@ export function PluginBadges({ plugin }: PluginBadgeType) {
); );
} }
function EnterpriseBadge({ id }: { id: string }) { function EnterpriseBadge({ plugin }: { plugin: CatalogPlugin }) {
const customBadgeStyles = useStyles2(getBadgeColor); const customBadgeStyles = useStyles2(getBadgeColor);
const onClick = (ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const onClick = (ev: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
ev.preventDefault(); ev.preventDefault();
window.open( 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', '_blank',
'noopener,noreferrer' 'noopener,noreferrer'
); );
@@ -38,6 +38,7 @@ function EnterpriseBadge({ id }: { id: string }) {
return ( return (
<HorizontalGroup> <HorizontalGroup>
<PluginSignatureBadge status={plugin.signature} />
<Badge icon="lock" aria-label="lock icon" text="Enterprise" color="blue" className={customBadgeStyles} /> <Badge icon="lock" aria-label="lock icon" text="Enterprise" color="blue" className={customBadgeStyles} />
<Button size="sm" fill="text" icon="external-link-alt" onClick={onClick}> <Button size="sm" fill="text" icon="external-link-alt" onClick={onClick}>
Learn more Learn more

View File

@@ -4,7 +4,7 @@ import { Icon, useStyles2, CardContainer, VerticalGroup } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { CatalogPlugin } from '../types'; import { CatalogPlugin } from '../types';
import { PluginLogo } from './PluginLogo'; import { PluginLogo } from './PluginLogo';
import { PluginBadges } from './PluginBadges'; import { PluginListBadges } from './PluginListBadges';
const LOGO_SIZE = '48px'; const LOGO_SIZE = '48px';
@@ -42,7 +42,7 @@ export function PluginListCard({ plugin, pathName }: PluginListCardProps) {
)} )}
</div> </div>
<p className={styles.orgName}>By {orgName}</p> <p className={styles.orgName}>By {orgName}</p>
<PluginBadges plugin={plugin} /> <PluginListBadges plugin={plugin} />
</VerticalGroup> </VerticalGroup>
</CardContainer> </CardContainer>
); );

View File

@@ -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<string, IconName> = {
[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 (
<>
<DetailsBadge>
<strong className={styles.strong}>Level:&nbsp;</strong>
<Icon size="xs" name={signatureIcon} />
&nbsp;
{signatureTypeText}
</DetailsBadge>
<DetailsBadge>
<strong className={styles.strong}>Signed by:</strong> {signatureOrg}
</DetailsBadge>
</>
);
}
export const DetailsBadge: React.FC = ({ children }) => {
const styles = useStyles2(getStyles);
return <Badge color="green" className={styles.badge} text={<>{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)};
`,
});

View File

@@ -1,11 +1,15 @@
import { useReducer, useEffect } from 'react'; import { useReducer, useEffect } from 'react';
import { PluginType, PluginIncludeType } from '@grafana/data'; import { PluginType, PluginIncludeType, GrafanaPlugin, PluginMeta } from '@grafana/data';
import { api } from '../api'; import { api } from '../api';
import { loadPlugin } from '../../PluginPage'; import { loadPlugin } from '../../PluginPage';
import { getCatalogPluginDetails, isOrgAdmin } from '../helpers'; 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 = { const initialState = {
hasInstalledPanel: false, hasInstalledPanel: false,
@@ -84,6 +88,9 @@ const reducer = (state: PluginDetailsState, action: PluginDetailsActions) => {
} }
}; };
const pluginCache: Record<string, CatalogPluginDetails> = {};
const pluginConfigCache: Record<string, GrafanaPlugin<PluginMeta<{}>>> = {};
export const usePluginDetails = (id: string) => { export const usePluginDetails = (id: string) => {
const [state, dispatch] = useReducer(reducer, initialState); const [state, dispatch] = useReducer(reducer, initialState);
const userCanConfigurePlugins = isOrgAdmin(); const userCanConfigurePlugins = isOrgAdmin();
@@ -92,8 +99,16 @@ export const usePluginDetails = (id: string) => {
const fetchPlugin = async () => { const fetchPlugin = async () => {
dispatch({ type: ActionTypes.LOADING }); dispatch({ type: ActionTypes.LOADING });
try { try {
const value = await api.getPlugin(id); let plugin;
const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions);
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 }); dispatch({ type: ActionTypes.FETCHED_PLUGIN, payload: plugin });
} catch (error) { } catch (error) {
dispatch({ type: ActionTypes.ERROR, payload: error }); dispatch({ type: ActionTypes.ERROR, payload: error });
@@ -107,7 +122,15 @@ export const usePluginDetails = (id: string) => {
if (state.isInstalled) { if (state.isInstalled) {
dispatch({ type: ActionTypes.LOADING }); dispatch({ type: ActionTypes.LOADING });
try { 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 }); dispatch({ type: ActionTypes.FETCHED_PLUGIN_CONFIG, payload: pluginConfig });
} catch (error) { } catch (error) {
dispatch({ type: ActionTypes.ERROR, payload: error }); dispatch({ type: ActionTypes.ERROR, payload: error });
@@ -123,27 +146,28 @@ export const usePluginDetails = (id: string) => {
useEffect(() => { useEffect(() => {
const pluginConfig = state.pluginConfig; const pluginConfig = state.pluginConfig;
const tabs: Array<{ label: string }> = [...defaultTabs]; const tabs: Tab[] = [...defaultTabs];
if (pluginConfig && userCanConfigurePlugins) { if (pluginConfig && userCanConfigurePlugins) {
if (pluginConfig.meta.type === PluginType.app) { if (pluginConfig.meta.type === PluginType.app) {
if (pluginConfig.angularConfigCtrl) { if (pluginConfig.angularConfigCtrl) {
tabs.push({ tabs.push({
label: 'Config', label: PluginTabLabels.CONFIG,
}); });
} }
// Configuration pages with custom labels
if (pluginConfig.configPages) { if (pluginConfig.configPages) {
for (const page of pluginConfig.configPages) { for (const page of pluginConfig.configPages) {
tabs.push({ tabs.push({
label: page.title, label: page.title as PluginTabLabels,
}); });
} }
} }
if (pluginConfig.meta.includes?.find((include) => include.type === PluginIncludeType.dashboard)) { if (pluginConfig.meta.includes?.find((include) => include.type === PluginIncludeType.dashboard)) {
tabs.push({ tabs.push({
label: 'Dashboards', label: PluginTabLabels.DASHBOARDS,
}); });
} }
} }

View File

@@ -1,15 +1,15 @@
import React from 'react'; import React from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data'; 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 { 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 { usePluginDetails } from '../hooks/usePluginDetails';
import { Page as PluginPage } from '../components/Page'; import { Page as PluginPage } from '../components/Page';
import { Loader } from '../components/Loader'; import { Loader } from '../components/Loader';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import { PluginLogo } from '../components/PluginLogo';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { ActionTypes } from '../types'; import { ActionTypes } from '../types';
import { PluginDetailsBody } from '../components/PluginDetailsBody'; import { PluginDetailsBody } from '../components/PluginDetailsBody';
@@ -19,21 +19,10 @@ type PluginDetailsProps = GrafanaRouteComponentProps<{ pluginId?: string }>;
export default function PluginDetails({ match }: PluginDetailsProps): JSX.Element | null { export default function PluginDetails({ match }: PluginDetailsProps): JSX.Element | null {
const { pluginId } = match.params; const { pluginId } = match.params;
const { state, dispatch } = usePluginDetails(pluginId!); const { state, dispatch } = usePluginDetails(pluginId!);
const { const { loading, error, plugin, pluginConfig, tabs, activeTab } = state;
loading,
error,
plugin,
pluginConfig,
tabs,
activeTab,
isInflight,
hasUpdate,
isInstalled,
hasInstalledPanel,
} = state;
const tab = tabs[activeTab]; const tab = tabs[activeTab];
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const breadcrumbHref = match.url.substring(0, match.url.lastIndexOf('/')); const parentUrl = match.url.substring(0, match.url.lastIndexOf('/'));
if (loading) { if (loading) {
return ( return (
@@ -43,147 +32,54 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
); );
} }
if (plugin) { if (!plugin) {
return ( return null;
<Page>
<PluginPage>
<div className={styles.headerContainer}>
<PluginLogo
alt={`${plugin.name} logo`}
src={plugin.info.logos.small}
className={css`
object-fit: contain;
width: 100%;
height: 68px;
max-width: 68px;
`}
/>
<div className={styles.headerWrapper}>
<nav className={styles.breadcrumb} aria-label="Breadcrumb">
<ol>
<li>
<a
className={css`
text-decoration: underline;
`}
href={breadcrumbHref}
>
Plugins
</a>
</li>
<li>
<a href={`${match.url}`} aria-current="page">
{plugin.name}
</a>
</li>
</ol>
</nav>
<div className={styles.headerLinks}>
<span>{plugin.orgName}</span>
{plugin.links.map((link: any) => (
<a key={link.name} href={link.url}>
{link.name}
</a>
))}
{plugin.downloads > 0 && (
<span>
<Icon name="cloud-download" />
{` ${new Intl.NumberFormat().format(plugin.downloads)}`}{' '}
</span>
)}
{plugin.version && <span>{plugin.version}</span>}
</div>
<p>{plugin.description}</p>
<InstallControls
plugin={plugin}
isInflight={isInflight}
hasUpdate={hasUpdate}
isInstalled={isInstalled}
hasInstalledPanel={hasInstalledPanel}
dispatch={dispatch}
/>
</div>
</div>
<TabsBar>
{tabs.map((tab: { label: string }, idx: number) => (
<Tab
key={tab.label}
label={tab.label}
active={idx === activeTab}
onChangeTab={() => dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: idx })}
/>
))}
</TabsBar>
<TabContent>
{error && (
<Alert severity={AppNotificationSeverity.Error} title="Error Loading Plugin">
<>
Check the server startup logs for more information. <br />
If this plugin was loaded from git, make sure it was compiled.
</>
</Alert>
)}
<PluginDetailsBody
tab={tab}
plugin={pluginConfig}
remoteVersions={plugin.versions}
readme={plugin.readme}
/>
</TabContent>
</PluginPage>
</Page>
);
} }
return null; return (
<Page>
<PluginPage>
<PluginDetailsHeader currentUrl={match.url} parentUrl={parentUrl} pluginId={pluginId} />
{/* Tab navigation */}
<TabsBar>
{tabs.map((tab: { label: string }, idx: number) => (
<Tab
key={tab.label}
label={tab.label}
active={idx === activeTab}
onChangeTab={() => dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: idx })}
/>
))}
</TabsBar>
{/* Active tab */}
<TabContent className={styles.tabContent}>
{error && (
<Alert severity={AppNotificationSeverity.Error} title="Error Loading Plugin">
<>
Check the server startup logs for more information. <br />
If this plugin was loaded from git, make sure it was compiled.
</>
</Alert>
)}
<PluginDetailsSignature installedPlugin={pluginConfig} className={styles.signature} />
<PluginDetailsBody tab={tab} plugin={pluginConfig} remoteVersions={plugin.versions} readme={plugin.readme} />
</TabContent>
</PluginPage>
</Page>
);
} }
export const getStyles = (theme: GrafanaTheme2) => { export const getStyles = (theme: GrafanaTheme2) => {
return { return {
headerContainer: css` signature: css`
display: flex; margin: ${theme.spacing(3)};
margin-bottom: ${theme.spacing(3)}; margin-bottom: 0;
margin-top: ${theme.spacing(3)};
min-height: 120px;
`, `,
headerWrapper: css` // Needed due to block formatting context
margin-left: ${theme.spacing(3)}; tabContent: css`
`, overflow: auto;
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};
`, `,
}; };
}; };

View File

@@ -229,3 +229,10 @@ export enum PluginStatus {
UNINSTALL = 'UNINSTALL', UNINSTALL = 'UNINSTALL',
UPDATE = 'UPDATE', UPDATE = 'UPDATE',
} }
export enum PluginTabLabels {
OVERVIEW = 'Overview',
VERSIONS = 'Version history',
CONFIG = 'Config',
DASHBOARDS = 'Dashboards',
}