mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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(<PluginBadges plugin={plugin} />);
|
||||
render(<PluginListBadges plugin={plugin} />);
|
||||
|
||||
expect(screen.getByText(/signed/i)).toBeVisible();
|
||||
});
|
||||
|
||||
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(/installed/i)).toBeVisible();
|
||||
@@ -49,14 +49,14 @@ describe('PluginBadges', () => {
|
||||
|
||||
it('renders an enterprise badge (when a license is valid)', () => {
|
||||
config.licenseInfo.hasValidLicense = true;
|
||||
render(<PluginBadges plugin={{ ...plugin, isEnterprise: true }} />);
|
||||
render(<PluginListBadges plugin={{ ...plugin, isEnterprise: true }} />);
|
||||
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(<PluginBadges plugin={{ ...plugin, isEnterprise: true }} />);
|
||||
render(<PluginListBadges plugin={{ ...plugin, isEnterprise: true }} />);
|
||||
expect(screen.getByText(/enterprise/i)).toBeVisible();
|
||||
expect(screen.getByLabelText(/lock icon/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /learn more/i })).toBeInTheDocument();
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
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 (
|
||||
<div className={styles.container}>
|
||||
<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 (
|
||||
<div className={styles.container}>
|
||||
<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 (
|
||||
<div className={styles.container}>
|
||||
<PluginDashboards plugin={plugin.meta} />
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 can’t 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>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,9 @@ type PluginBadgeType = {
|
||||
plugin: CatalogPlugin;
|
||||
};
|
||||
|
||||
export function PluginBadges({ plugin }: PluginBadgeType) {
|
||||
export function PluginListBadges({ plugin }: PluginBadgeType) {
|
||||
if (plugin.isEnterprise) {
|
||||
return <EnterpriseBadge id={plugin.id} />;
|
||||
return <EnterpriseBadge plugin={plugin} />;
|
||||
}
|
||||
return (
|
||||
<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 onClick = (ev: React.MouseEvent<HTMLButtonElement, 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 (
|
||||
<HorizontalGroup>
|
||||
<PluginSignatureBadge status={plugin.signature} />
|
||||
<Badge icon="lock" aria-label="lock icon" text="Enterprise" color="blue" className={customBadgeStyles} />
|
||||
<Button size="sm" fill="text" icon="external-link-alt" onClick={onClick}>
|
||||
Learn more
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
import { PluginListBadges } from './PluginListBadges';
|
||||
|
||||
const LOGO_SIZE = '48px';
|
||||
|
||||
@@ -42,7 +42,7 @@ export function PluginListCard({ plugin, pathName }: PluginListCardProps) {
|
||||
)}
|
||||
</div>
|
||||
<p className={styles.orgName}>By {orgName}</p>
|
||||
<PluginBadges plugin={plugin} />
|
||||
<PluginListBadges plugin={plugin} />
|
||||
</VerticalGroup>
|
||||
</CardContainer>
|
||||
);
|
||||
|
||||
@@ -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: </strong>
|
||||
<Icon size="xs" name={signatureIcon} />
|
||||
|
||||
{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)};
|
||||
`,
|
||||
});
|
||||
@@ -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<string, CatalogPluginDetails> = {};
|
||||
const pluginConfigCache: Record<string, GrafanaPlugin<PluginMeta<{}>>> = {};
|
||||
|
||||
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 {
|
||||
let plugin;
|
||||
|
||||
if (pluginCache[id]) {
|
||||
plugin = pluginCache[id];
|
||||
} else {
|
||||
const value = await api.getPlugin(id);
|
||||
const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions);
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,68 +32,16 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
||||
);
|
||||
}
|
||||
|
||||
if (plugin) {
|
||||
if (!plugin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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;
|
||||
`}
|
||||
/>
|
||||
<PluginDetailsHeader currentUrl={match.url} parentUrl={parentUrl} pluginId={pluginId} />
|
||||
|
||||
<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>
|
||||
{/* Tab navigation */}
|
||||
<TabsBar>
|
||||
{tabs.map((tab: { label: string }, idx: number) => (
|
||||
<Tab
|
||||
@@ -115,7 +52,9 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
||||
/>
|
||||
))}
|
||||
</TabsBar>
|
||||
<TabContent>
|
||||
|
||||
{/* Active tab */}
|
||||
<TabContent className={styles.tabContent}>
|
||||
{error && (
|
||||
<Alert severity={AppNotificationSeverity.Error} title="Error Loading Plugin">
|
||||
<>
|
||||
@@ -124,66 +63,23 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
||||
</>
|
||||
</Alert>
|
||||
)}
|
||||
<PluginDetailsBody
|
||||
tab={tab}
|
||||
plugin={pluginConfig}
|
||||
remoteVersions={plugin.versions}
|
||||
readme={plugin.readme}
|
||||
/>
|
||||
<PluginDetailsSignature installedPlugin={pluginConfig} className={styles.signature} />
|
||||
<PluginDetailsBody tab={tab} plugin={pluginConfig} remoteVersions={plugin.versions} readme={plugin.readme} />
|
||||
</TabContent>
|
||||
</PluginPage>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -229,3 +229,10 @@ export enum PluginStatus {
|
||||
UNINSTALL = 'UNINSTALL',
|
||||
UPDATE = 'UPDATE',
|
||||
}
|
||||
|
||||
export enum PluginTabLabels {
|
||||
OVERVIEW = 'Overview',
|
||||
VERSIONS = 'Version history',
|
||||
CONFIG = 'Config',
|
||||
DASHBOARDS = 'Dashboards',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user