From 1d37d675d79560d52ac223c5d56b06f6597f07ab Mon Sep 17 00:00:00 2001 From: Jack Westbrook Date: Wed, 21 Jul 2021 19:44:43 +0200 Subject: [PATCH] Plugins: Render app plugin config pages in catalog (#37063) * feat(catalog): introduce PluginDetailsBody component and useLoadPluginConfig hook * feat(catalog): use PluginDetailsBody and useLoadPluginConfig hook in PluginDetails * feat(catalog): introduce usePluginDetails hook to handle PluginDetails state * feat(catalog): wire usePluginDetails hook to PluginDetails and InstallControls * refactor(catalog): fix typescript errors related to usePluginDetails hook * chore(catalog): update types for PluginDetailsActions --- .../admin/components/InstallControls.tsx | 51 +++--- .../admin/components/PluginDetailsBody.tsx | 96 +++++++++++ .../plugins/admin/components/VersionList.tsx | 40 +++-- .../plugins/admin/hooks/usePluginDetails.tsx | 155 ++++++++++++++++++ .../plugins/admin/hooks/usePlugins.tsx | 22 +-- .../plugins/admin/pages/PluginDetails.tsx | 100 ++++++----- public/app/features/plugins/admin/types.ts | 43 ++++- 7 files changed, 384 insertions(+), 123 deletions(-) create mode 100644 public/app/features/plugins/admin/components/PluginDetailsBody.tsx create mode 100644 public/app/features/plugins/admin/hooks/usePluginDetails.tsx diff --git a/public/app/features/plugins/admin/components/InstallControls.tsx b/public/app/features/plugins/admin/components/InstallControls.tsx index 13495f2c199..a71a67e6f07 100644 --- a/public/app/features/plugins/admin/components/InstallControls.tsx +++ b/public/app/features/plugins/admin/components/InstallControls.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { css, cx } from '@emotion/css'; import { satisfies } from 'semver'; @@ -7,19 +7,20 @@ import { Button, HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ import { AppEvents, GrafanaTheme2 } from '@grafana/data'; import appEvents from 'app/core/app_events'; -import { CatalogPluginDetails } from '../types'; +import { CatalogPluginDetails, ActionTypes } from '../types'; import { api } from '../api'; import { isGrafanaAdmin } from '../helpers'; interface Props { plugin: CatalogPluginDetails; + isInflight: boolean; + hasUpdate: boolean; + hasInstalledPanel: boolean; + isInstalled: boolean; + dispatch: React.Dispatch; } -export const InstallControls = ({ plugin }: Props) => { - const [loading, setLoading] = useState(false); - const [isInstalled, setIsInstalled] = useState(plugin.isInstalled || false); - const [shouldUpdate, setShouldUpdate] = useState(plugin.hasUpdate || false); - const [hasInstalledPanel, setHasInstalledPanel] = useState(false); +export const InstallControls = ({ plugin, isInflight, hasUpdate, isInstalled, hasInstalledPanel, dispatch }: Props) => { const isExternallyManaged = config.pluginAdminExternalManageEnabled; const externalManageLink = getExternalManageLink(plugin); @@ -30,39 +31,35 @@ export const InstallControls = ({ plugin }: Props) => { } const onInstall = async () => { - setLoading(true); + dispatch({ type: ActionTypes.INFLIGHT }); try { await api.installPlugin(plugin.id, plugin.version); appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]); - setLoading(false); - setIsInstalled(true); - setHasInstalledPanel(plugin.type === 'panel'); + dispatch({ type: ActionTypes.INSTALLED, payload: plugin.type === 'panel' }); } catch (error) { - setLoading(false); + dispatch({ type: ActionTypes.ERROR, payload: { error } }); } }; const onUninstall = async () => { - setLoading(true); + dispatch({ type: ActionTypes.INFLIGHT }); try { await api.uninstallPlugin(plugin.id); appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]); - setLoading(false); - setIsInstalled(false); + dispatch({ type: ActionTypes.UNINSTALLED }); } catch (error) { - setLoading(false); + dispatch({ type: ActionTypes.ERROR, payload: error }); } }; const onUpdate = async () => { - setLoading(true); + dispatch({ type: ActionTypes.INFLIGHT }); try { await api.installPlugin(plugin.id, plugin.version); appEvents.emit(AppEvents.alertSuccess, [`Updated ${plugin.name}`]); - setLoading(false); - setShouldUpdate(false); + dispatch({ type: ActionTypes.UPDATED }); } catch (error) { - setLoading(false); + dispatch({ type: ActionTypes.ERROR, payload: error }); } }; @@ -100,14 +97,14 @@ export const InstallControls = ({ plugin }: Props) => { if (isInstalled) { return ( - {shouldUpdate && + {hasUpdate && (isExternallyManaged ? ( {'Update via grafana.com'} ) : ( - ))} @@ -117,8 +114,8 @@ export const InstallControls = ({ plugin }: Props) => { ) : ( <> - {hasInstalledPanel && (
@@ -149,8 +146,8 @@ export const InstallControls = ({ plugin }: Props) => { ) : ( <> - {!hasPermission &&
You need admin privileges to install this plugin.
} diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx new file mode 100644 index 00000000000..2e3926a9225 --- /dev/null +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { css, cx } from '@emotion/css'; + +import { AppPlugin, GrafanaTheme2, GrafanaPlugin, PluginMeta } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { VersionList } from '../components/VersionList'; +import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper'; +import { PluginDashboards } from '../../PluginDashboards'; + +type PluginDetailsBodyProps = { + tab: { label: string }; + plugin: GrafanaPlugin> | undefined; + remoteVersions: Array<{ version: string; createdAt: string }>; + readme: string; +}; + +export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: PluginDetailsBodyProps): JSX.Element | null { + const styles = useStyles2(getStyles); + + if (tab?.label === 'Overview') { + return ( +
+ ); + } + + if (tab?.label === 'Version history') { + return ( +
+ +
+ ); + } + + if (tab?.label === 'Config' && plugin?.angularConfigCtrl) { + return ( +
+ +
+ ); + } + + if (plugin?.configPages) { + for (const configPage of plugin.configPages) { + if (tab?.label === configPage.title) { + return ( +
+ +
+ ); + } + } + } + + if (tab?.label === 'Dashboards' && plugin) { + return ( +
+ +
+ ); + } + + return null; +} + +export const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + padding: ${theme.spacing(3, 4)}; + `, + readme: css` + & img { + max-width: 100%; + } + + h1, + h2, + h3 { + margin-top: ${theme.spacing(3)}; + margin-bottom: ${theme.spacing(2)}; + } + + *:first-child { + margin-top: 0; + } + + li { + margin-left: ${theme.spacing(2)}; + & > p { + margin: ${theme.spacing()} 0; + } + } + `, +}); diff --git a/public/app/features/plugins/admin/components/VersionList.tsx b/public/app/features/plugins/admin/components/VersionList.tsx index d20d2c42dd9..310c9a7da56 100644 --- a/public/app/features/plugins/admin/components/VersionList.tsx +++ b/public/app/features/plugins/admin/components/VersionList.tsx @@ -13,30 +13,28 @@ export const VersionList = ({ versions }: Props) => { const styles = useStyles2(getStyles); if (versions.length === 0) { - return
No version history was found.
; + return

No version history was found.

; } return ( -
- - - - - - - - - {versions.map((version) => { - return ( - - - - - ); - })} - -
VersionLast updated
{version.version}{dateTimeFormatTimeAgo(version.createdAt)}
-
+ + + + + + + + + {versions.map((version) => { + return ( + + + + + ); + })} + +
VersionLast updated
{version.version}{dateTimeFormatTimeAgo(version.createdAt)}
); }; diff --git a/public/app/features/plugins/admin/hooks/usePluginDetails.tsx b/public/app/features/plugins/admin/hooks/usePluginDetails.tsx new file mode 100644 index 00000000000..74a6e2f4d42 --- /dev/null +++ b/public/app/features/plugins/admin/hooks/usePluginDetails.tsx @@ -0,0 +1,155 @@ +import { useReducer, useEffect } from 'react'; +import { PluginType, PluginIncludeType } from '@grafana/data'; +import { api } from '../api'; +import { loadPlugin } from '../../PluginPage'; +import { getCatalogPluginDetails, isGrafanaAdmin } from '../helpers'; +import { ActionTypes, PluginDetailsActions, PluginDetailsState } from '../types'; + +const defaultTabs = [{ label: 'Overview' }, { label: 'Version history' }]; + +const initialState = { + hasInstalledPanel: false, + hasUpdate: false, + isAdmin: isGrafanaAdmin(), + isInstalled: false, + isInflight: false, + loading: false, + error: undefined, + plugin: undefined, + pluginConfig: undefined, + tabs: defaultTabs, + activeTab: 0, +}; + +const reducer = (state: PluginDetailsState, action: PluginDetailsActions) => { + switch (action.type) { + case ActionTypes.LOADING: { + return { ...state, loading: true }; + } + case ActionTypes.INFLIGHT: { + return { ...state, isInflight: true }; + } + case ActionTypes.ERROR: { + return { ...state, loading: false, error: action.payload }; + } + case ActionTypes.FETCHED_PLUGIN: { + return { + ...state, + loading: false, + plugin: action.payload, + isInstalled: action.payload.isInstalled, + hasUpdate: action.payload.hasUpdate, + }; + } + case ActionTypes.FETCHED_PLUGIN_CONFIG: { + return { + ...state, + loading: false, + pluginConfig: action.payload, + }; + } + case ActionTypes.UPDATE_TABS: { + return { + ...state, + tabs: action.payload, + }; + } + case ActionTypes.INSTALLED: { + return { + ...state, + isInflight: false, + isInstalled: true, + hasInstalledPanel: action.payload, + }; + } + case ActionTypes.UNINSTALLED: { + return { + ...state, + isInflight: false, + isInstalled: false, + }; + } + case ActionTypes.UPDATED: { + return { + ...state, + hasUpdate: false, + isInflight: false, + }; + } + case ActionTypes.SET_ACTIVE_TAB: { + return { + ...state, + activeTab: action.payload, + }; + } + } +}; + +export const usePluginDetails = (id: string) => { + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + const fetchPlugin = async () => { + dispatch({ type: ActionTypes.LOADING }); + try { + const value = await api.getPlugin(id); + const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions); + dispatch({ type: ActionTypes.FETCHED_PLUGIN, payload: plugin }); + } catch (error) { + dispatch({ type: ActionTypes.ERROR, payload: error }); + } + }; + fetchPlugin(); + }, [id]); + + useEffect(() => { + const fetchPluginConfig = async () => { + if (state.isInstalled) { + dispatch({ type: ActionTypes.LOADING }); + try { + const pluginConfig = await loadPlugin(id); + dispatch({ type: ActionTypes.FETCHED_PLUGIN_CONFIG, payload: pluginConfig }); + } catch (error) { + dispatch({ type: ActionTypes.ERROR, payload: error }); + } + } else { + // reset tabs + dispatch({ type: ActionTypes.FETCHED_PLUGIN_CONFIG, payload: undefined }); + dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: 0 }); + } + }; + fetchPluginConfig(); + }, [state.isInstalled, id]); + + useEffect(() => { + const pluginConfig = state.pluginConfig; + const tabs: Array<{ label: string }> = [...defaultTabs]; + + if (pluginConfig && state.isAdmin) { + if (pluginConfig.meta.type === PluginType.app) { + if (pluginConfig.angularConfigCtrl) { + tabs.push({ + label: 'Config', + }); + } + + if (pluginConfig.configPages) { + for (const page of pluginConfig.configPages) { + tabs.push({ + label: page.title, + }); + } + } + + if (pluginConfig.meta.includes?.find((include) => include.type === PluginIncludeType.dashboard)) { + tabs.push({ + label: 'Dashboards', + }); + } + } + } + dispatch({ type: ActionTypes.UPDATE_TABS, payload: tabs }); + }, [state.isAdmin, state.pluginConfig, id]); + + return { state, dispatch }; +}; diff --git a/public/app/features/plugins/admin/hooks/usePlugins.tsx b/public/app/features/plugins/admin/hooks/usePlugins.tsx index 7da25701916..f290b5d9495 100644 --- a/public/app/features/plugins/admin/hooks/usePlugins.tsx +++ b/public/app/features/plugins/admin/hooks/usePlugins.tsx @@ -1,8 +1,8 @@ import { useMemo } from 'react'; import { useAsync } from 'react-use'; -import { CatalogPlugin, CatalogPluginDetails } from '../types'; +import { CatalogPlugin } from '../types'; import { api } from '../api'; -import { mapLocalToCatalog, mapRemoteToCatalog, getCatalogPluginDetails, applySearchFilter } from '../helpers'; +import { mapLocalToCatalog, mapRemoteToCatalog, applySearchFilter } from '../helpers'; type CatalogPluginsState = { loading: boolean; @@ -77,21 +77,3 @@ export const usePluginsByFilter = (searchBy: string, filterBy: string): Filtered plugins: applySearchFilter(searchBy, plugins), }; }; - -type PluginState = { - isLoading: boolean; - plugin?: CatalogPluginDetails; -}; - -export const usePlugin = (slug: string): PluginState => { - const { loading, value } = useAsync(async () => { - return await api.getPlugin(slug); - }, [slug]); - - const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions); - - return { - isLoading: loading, - plugin, - }; -}; diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index eb6e9346dc2..499ffa36d19 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -1,32 +1,41 @@ -import React, { useState } from 'react'; +import React from 'react'; import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui'; +import { useStyles2, TabsBar, TabContent, Tab, Icon, Alert } from '@grafana/ui'; -import { VersionList } from '../components/VersionList'; +import { AppNotificationSeverity } from 'app/types'; import { InstallControls } from '../components/InstallControls'; -import { usePlugin } from '../hooks/usePlugins'; +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'; type PluginDetailsProps = GrafanaRouteComponentProps<{ pluginId?: string }>; export default function PluginDetails({ match }: PluginDetailsProps): JSX.Element | null { const { pluginId } = match.params; - - const [tabs, setTabs] = useState([ - { label: 'Overview', active: true }, - { label: 'Version history', active: false }, - ]); - - const { isLoading, plugin } = usePlugin(pluginId!); + const { state, dispatch } = usePluginDetails(pluginId!); + const { + loading, + error, + plugin, + pluginConfig, + tabs, + activeTab, + isInflight, + hasUpdate, + isInstalled, + hasInstalledPanel, + } = state; + const tab = tabs[activeTab]; const styles = useStyles2(getStyles); - if (isLoading) { + if (loading) { return ( @@ -69,33 +78,41 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen {plugin.version && {plugin.version}}

{plugin.description}

- +
- {tabs.map((tab, key) => ( + {tabs.map((tab: { label: string }, idx: number) => ( { - setTabs(tabs.map((tab, index) => ({ ...tab, active: index === key }))); - }} + active={idx === activeTab} + onChangeTab={() => dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: idx })} /> ))} - {tabs.find((_) => _.label === 'Overview')?.active && ( -
- )} - {tabs.find((_) => _.label === 'Version history')?.active && ( - + {error && ( + + <> + Check the server startup logs for more information.
+ If this plugin was loaded from git, make sure it was compiled. + +
)} + @@ -139,30 +156,5 @@ export const getStyles = (theme: GrafanaTheme2) => { headerOrgName: css` font-size: ${theme.typography.h4.fontSize}; `, - readme: css` - padding: ${theme.spacing(3, 4)}; - - & img { - max-width: 100%; - } - - h1, - h2, - h3 { - margin-top: ${theme.spacing(3)}; - margin-bottom: ${theme.spacing(2)}; - } - - *:first-child { - margin-top: 0; - } - - li { - margin-left: ${theme.spacing(2)}; - & > p { - margin: ${theme.spacing()} 0; - } - } - `, }; }; diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index dd703c9d49c..cb76223ec33 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -1,5 +1,5 @@ +import { GrafanaPlugin, PluginMeta } from '@grafana/data'; export type PluginTypeCode = 'app' | 'panel' | 'datasource'; - export interface CatalogPlugin { description: string; downloads: number; @@ -133,3 +133,44 @@ export interface Org { avatar: string; avatarUrl: string; } + +export interface PluginDetailsState { + hasInstalledPanel: boolean; + hasUpdate: boolean; + isAdmin: boolean; + isInstalled: boolean; + isInflight: boolean; + loading: boolean; + error?: Error; + plugin?: CatalogPluginDetails; + pluginConfig?: GrafanaPlugin>; + tabs: Array<{ label: string }>; + activeTab: number; +} + +export enum ActionTypes { + LOADING = 'LOADING', + INFLIGHT = 'INFLIGHT', + INSTALLED = 'INSTALLED', + UNINSTALLED = 'UNINSTALLED', + UPDATED = 'UPDATED', + ERROR = 'ERROR', + FETCHED_PLUGIN = 'FETCHED_PLUGIN', + FETCHED_PLUGIN_CONFIG = 'FETCHED_PLUGIN_CONFIG', + UPDATE_TABS = 'UPDATE_TABS', + SET_ACTIVE_TAB = 'SET_ACTIVE_TAB', +} + +export type PluginDetailsActions = + | { type: ActionTypes.FETCHED_PLUGIN; payload: CatalogPluginDetails } + | { type: ActionTypes.ERROR; payload: Error } + | { type: ActionTypes.FETCHED_PLUGIN_CONFIG; payload?: GrafanaPlugin> } + | { + type: ActionTypes.UPDATE_TABS; + payload: Array<{ label: string }>; + } + | { type: ActionTypes.INSTALLED; payload: boolean } + | { type: ActionTypes.SET_ACTIVE_TAB; payload: number } + | { + type: ActionTypes.LOADING | ActionTypes.INFLIGHT | ActionTypes.UNINSTALLED | ActionTypes.UPDATED; + };