@@ -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 */}
+
+
+
+
+ Plugins
+
+
+
+
+ {plugin.name}
+
+
+
+
+
+
+ {/* 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 (
+
Learn more
diff --git a/public/app/features/plugins/admin/components/PluginListCard.tsx b/public/app/features/plugins/admin/components/PluginListCard.tsx
index a4781833ac4..0ef2c218f8d 100644
--- a/public/app/features/plugins/admin/components/PluginListCard.tsx
+++ b/public/app/features/plugins/admin/components/PluginListCard.tsx
@@ -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) {
)}
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 (
-
-
-
-
-
-
-
-
-
-
- Plugins
-
-
-
-
- {plugin.name}
-
-
-
-
-
-
{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',
+}