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 { 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();

View File

@@ -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} />

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;
};
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

View File

@@ -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>
);

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 { 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 {
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,
});
}
}

View File

@@ -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 (
<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>
);
if (!plugin) {
return null;
}
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) => {
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;
`,
};
};

View File

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