diff --git a/public/app/features/plugins/admin/api.ts b/public/app/features/plugins/admin/api.ts index 3d6ba7d5244..33ddce546cb 100644 --- a/public/app/features/plugins/admin/api.ts +++ b/public/app/features/plugins/admin/api.ts @@ -1,6 +1,33 @@ import { getBackendSrv } from '@grafana/runtime'; import { API_ROOT, GRAFANA_API_ROOT } from './constants'; -import { PluginDetails, Org, LocalPlugin, RemotePlugin } from './types'; +import { PluginDetails, Org, LocalPlugin, RemotePlugin, CatalogPlugin, CatalogPluginDetails } from './types'; +import { mergeLocalsAndRemotes, mergeLocalAndRemote } from './helpers'; + +export async function getCatalogPlugins(): Promise { + const [localPlugins, remotePlugins] = await Promise.all([getLocalPlugins(), getRemotePlugins()]); + + return mergeLocalsAndRemotes(localPlugins, remotePlugins); +} + +export async function getCatalogPlugin(id: string): Promise { + const { local, remote } = await getPlugin(id); + + return mergeLocalAndRemote(local, remote); +} + +export async function getPluginDetails(id: string): Promise { + const localPlugins = await getLocalPlugins(); // /api/plugins//settings + const local = localPlugins.find((p) => p.id === id); + const isInstalled = Boolean(local); + const [remote, versions] = await Promise.all([getRemotePlugin(id, isInstalled), getPluginVersions(id)]); + + return { + grafanaDependency: remote?.json?.dependencies?.grafanaDependency || '', + links: remote?.json?.info.links || local?.info.links || [], + readme: remote?.readme, + versions, + }; +} async function getRemotePlugins(): Promise { const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`); @@ -8,13 +35,13 @@ async function getRemotePlugins(): Promise { } async function getPlugin(slug: string): Promise { - const installed = await getInstalledPlugins(); + const installed = await getLocalPlugins(); const localPlugin = installed?.find((plugin: LocalPlugin) => { return plugin.id === slug; }); - const [remote, versions] = await Promise.all([getRemotePlugin(slug, localPlugin), getPluginVersions(slug)]); + const [remote, versions] = await Promise.all([getRemotePlugin(slug, Boolean(localPlugin)), getPluginVersions(slug)]); return { remote: remote, @@ -23,12 +50,12 @@ async function getPlugin(slug: string): Promise { }; } -async function getRemotePlugin(slug: string, local: LocalPlugin | undefined): Promise { +async function getRemotePlugin(id: string, isInstalled: boolean): Promise { try { - return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${slug}`); + return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}`); } catch (error) { // this might be a plugin that doesn't exist on gcom. - error.isHandled = !!local; + error.isHandled = isInstalled; return; } } @@ -42,7 +69,7 @@ async function getPluginVersions(id: string): Promise { } } -async function getInstalledPlugins(): Promise { +async function getLocalPlugins(): Promise { const installed = await getBackendSrv().get(`${API_ROOT}`, { embedded: 0 }); return installed; } @@ -52,20 +79,20 @@ async function getOrg(slug: string): Promise { return { ...org, avatarUrl: `${GRAFANA_API_ROOT}/orgs/${slug}/avatar` }; } -async function installPlugin(id: string, version: string) { +export async function installPlugin(id: string, version: string) { return await getBackendSrv().post(`${API_ROOT}/${id}/install`, { version, }); } -async function uninstallPlugin(id: string) { +export async function uninstallPlugin(id: string) { return await getBackendSrv().post(`${API_ROOT}/${id}/uninstall`); } export const api = { getRemotePlugins, getPlugin, - getInstalledPlugins, + getInstalledPlugins: getLocalPlugins, getOrg, installPlugin, uninstallPlugin, diff --git a/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.tsx b/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.tsx index 55ed72aa9ff..ce69db3fa0d 100644 --- a/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.tsx +++ b/public/app/features/plugins/admin/components/InstallControls/InstallControlsButton.tsx @@ -1,68 +1,56 @@ -import React from 'react'; -import { AppEvents } from '@grafana/data'; +import React, { useState } from 'react'; +import { useMountedState } from 'react-use'; +import { AppEvents, PluginType } from '@grafana/data'; import { Button, HorizontalGroup, useStyles2 } from '@grafana/ui'; import appEvents from 'app/core/app_events'; -import { api } from '../../api'; -import { ActionTypes, CatalogPlugin, PluginStatus } from '../../types'; + +import { CatalogPlugin, PluginStatus } from '../../types'; import { getStyles } from './index'; +import { useInstallStatus, useUninstallStatus, useInstall, useUninstall } from '../../state/hooks'; type InstallControlsButtonProps = { - isInProgress: boolean; - hasInstalledPanel: boolean; - dispatch: React.Dispatch; plugin: CatalogPlugin; pluginStatus: PluginStatus; }; -export function InstallControlsButton({ - isInProgress, - dispatch, - plugin, - pluginStatus, - hasInstalledPanel, -}: InstallControlsButtonProps) { - const uninstallBtnText = isInProgress ? 'Uninstalling' : 'Uninstall'; - const updateBtnText = isInProgress ? 'Updating' : 'Update'; - const installBtnText = isInProgress ? 'Installing' : 'Install'; +export function InstallControlsButton({ plugin, pluginStatus }: InstallControlsButtonProps) { + const { isInstalling, error: errorInstalling } = useInstallStatus(); + const { isUninstalling, error: errorUninstalling } = useUninstallStatus(); + const install = useInstall(); + const uninstall = useUninstall(); + const [hasInstalledPanel, setHasInstalledPanel] = useState(false); const styles = useStyles2(getStyles); + const uninstallBtnText = isUninstalling ? 'Uninstalling' : 'Uninstall'; + const isMounted = useMountedState(); const onInstall = async () => { - dispatch({ type: ActionTypes.INFLIGHT }); - try { - await api.installPlugin(plugin.id, plugin.version); + await install(plugin.id, plugin.version); + if (!errorInstalling) { + if (isMounted() && plugin.type === PluginType.panel) { + setHasInstalledPanel(true); + } appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]); - dispatch({ type: ActionTypes.INSTALLED, payload: plugin.type === 'panel' }); - } catch (error) { - dispatch({ type: ActionTypes.ERROR, payload: { error } }); } }; const onUninstall = async () => { - dispatch({ type: ActionTypes.INFLIGHT }); - try { - await api.uninstallPlugin(plugin.id); + await uninstall(plugin.id); + if (!errorUninstalling) { appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]); - dispatch({ type: ActionTypes.UNINSTALLED }); - } catch (error) { - dispatch({ type: ActionTypes.ERROR, payload: error }); } }; const onUpdate = async () => { - dispatch({ type: ActionTypes.INFLIGHT }); - try { - await api.installPlugin(plugin.id, plugin.version); + await install(plugin.id, plugin.version, true); + if (!errorInstalling) { appEvents.emit(AppEvents.alertSuccess, [`Updated ${plugin.name}`]); - dispatch({ type: ActionTypes.UPDATED }); - } catch (error) { - dispatch({ type: ActionTypes.ERROR, payload: error }); } }; if (pluginStatus === PluginStatus.UNINSTALL) { return ( - {hasInstalledPanel && ( @@ -75,10 +63,10 @@ export function InstallControlsButton({ if (pluginStatus === PluginStatus.UPDATE) { return ( - - @@ -86,8 +74,8 @@ export function InstallControlsButton({ } return ( - ); } diff --git a/public/app/features/plugins/admin/components/InstallControls/index.tsx b/public/app/features/plugins/admin/components/InstallControls/index.tsx index adc1e57622e..7aad502ee27 100644 --- a/public/app/features/plugins/admin/components/InstallControls/index.tsx +++ b/public/app/features/plugins/admin/components/InstallControls/index.tsx @@ -6,32 +6,31 @@ import { config } from '@grafana/runtime'; import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data'; -import { CatalogPluginDetails, PluginStatus } from '../../types'; +import { CatalogPlugin, PluginStatus } from '../../types'; import { isGrafanaAdmin, getExternalManageLink } from '../../helpers'; import { ExternallyManagedButton } from './ExternallyManagedButton'; import { InstallControlsButton } from './InstallControlsButton'; interface Props { - plugin: CatalogPluginDetails; - isInflight: boolean; - hasUpdate: boolean; - hasInstalledPanel: boolean; - isInstalled: boolean; - dispatch: React.Dispatch; + plugin: CatalogPlugin; } -export const InstallControls = ({ plugin, isInflight, hasUpdate, isInstalled, hasInstalledPanel, dispatch }: Props) => { +export const InstallControls = ({ plugin }: Props) => { const styles = useStyles2(getStyles); const isExternallyManaged = config.pluginAdminExternalManageEnabled; const hasPermission = isGrafanaAdmin(); - const grafanaDependency = plugin.grafanaDependency; + const grafanaDependency = plugin.details?.grafanaDependency; const unsupportedGrafanaVersion = grafanaDependency ? !satisfies(config.buildInfo.version, grafanaDependency, { - // needed for when running against master + // needed for when running against main includePrerelease: true, }) : false; - const pluginStatus = isInstalled ? (hasUpdate ? PluginStatus.UPDATE : PluginStatus.UNINSTALL) : PluginStatus.INSTALL; + const pluginStatus = plugin.isInstalled + ? plugin.hasUpdate + ? PluginStatus.UPDATE + : PluginStatus.UNINSTALL + : PluginStatus.INSTALL; if (plugin.isCore) { return null; @@ -79,15 +78,7 @@ export const InstallControls = ({ plugin, isInflight, hasUpdate, isInstalled, ha return ; } - return ( - - ); + return ; }; export const getStyles = (theme: GrafanaTheme2) => { diff --git a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx index 504fb3b01b4..2b68d3e7ba9 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsBody.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsBody.tsx @@ -1,29 +1,31 @@ import React from 'react'; import { css, cx } from '@emotion/css'; -import { AppPlugin, GrafanaTheme2, GrafanaPlugin, PluginMeta } from '@grafana/data'; +import { AppPlugin, GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; -import { PluginTabLabels } from '../types'; +import { CatalogPlugin, PluginTabLabels } from '../types'; import { VersionList } from '../components/VersionList'; +import { usePluginConfig } from '../hooks/usePluginConfig'; import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper'; import { PluginDashboards } from '../../PluginDashboards'; -type PluginDetailsBodyProps = { +type Props = { tab: { label: string }; - plugin: GrafanaPlugin> | undefined; - remoteVersions: Array<{ version: string; createdAt: string }>; - readme: string; + plugin: CatalogPlugin; }; -export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: PluginDetailsBodyProps): JSX.Element | null { +export function PluginDetailsBody({ tab, plugin }: Props): JSX.Element | null { const styles = useStyles2(getStyles); + const { value: pluginConfig } = usePluginConfig(plugin); if (tab?.label === PluginTabLabels.OVERVIEW) { return (
); } @@ -31,35 +33,36 @@ export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: Plugi if (tab?.label === PluginTabLabels.VERSIONS) { return (
- +
); } - if (tab?.label === PluginTabLabels.CONFIG && plugin?.angularConfigCtrl) { + if (tab?.label === PluginTabLabels.CONFIG && pluginConfig?.angularConfigCtrl) { return (
- +
); } - if (plugin?.configPages) { - for (const configPage of plugin.configPages) { + if (pluginConfig?.configPages) { + for (const configPage of pluginConfig.configPages) { if (tab?.label === configPage.title) { return (
- + {/* TODO: we should pass the query params down */} +
); } } } - if (tab?.label === PluginTabLabels.DASHBOARDS && plugin) { + if (tab?.label === PluginTabLabels.DASHBOARDS && pluginConfig) { return (
- +
); } diff --git a/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx b/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx index e168d1f28b9..8fa58a85564 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsHeader.tsx @@ -4,24 +4,18 @@ 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'; +import { CatalogPlugin } from '../types'; type Props = { - parentUrl: string; currentUrl: string; - pluginId?: string; + parentUrl: string; + plugin: CatalogPlugin; }; -export function PluginDetailsHeader({ pluginId, parentUrl, currentUrl }: Props): React.ReactElement | null { +export function PluginDetailsHeader({ plugin, currentUrl, parentUrl }: Props): React.ReactElement { const styles = useStyles2(getStyles); - const { state, dispatch } = usePluginDetails(pluginId!); - const { plugin, pluginConfig, isInflight, hasUpdate, isInstalled, hasInstalledPanel } = state; - - if (!plugin) { - return null; - } return (
@@ -58,7 +52,7 @@ export function PluginDetailsHeader({ pluginId, parentUrl, currentUrl }: Props): {plugin.orgName} {/* Links */} - {plugin.links.map((link: any) => ( + {plugin.details?.links.map((link: any) => ( {link.name} @@ -76,19 +70,12 @@ export function PluginDetailsHeader({ pluginId, parentUrl, currentUrl }: Props): {plugin.version && {plugin.version}} {/* Signature information */} - +

{plugin.description}

- +
); diff --git a/public/app/features/plugins/admin/components/PluginDetailsHeaderSignature.tsx b/public/app/features/plugins/admin/components/PluginDetailsHeaderSignature.tsx index a45d1a62cb0..93fe8d5d114 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsHeaderSignature.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsHeaderSignature.tsx @@ -1,31 +1,25 @@ import React from 'react'; -import { GrafanaPlugin, PluginMeta, PluginSignatureStatus } from '@grafana/data'; +import { PluginSignatureStatus } from '@grafana/data'; import { PluginSignatureBadge } from '@grafana/ui'; import { PluginSignatureDetailsBadge } from './PluginSignatureDetailsBadge'; +import { CatalogPlugin } from '../types'; type Props = { - installedPlugin?: GrafanaPlugin>; + plugin: CatalogPlugin; }; // 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; +export function PluginDetailsHeaderSignature({ plugin }: Props): React.ReactElement { + const isSignatureValid = plugin.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 index 4070dce9906..99ce7f2c420 100644 --- a/public/app/features/plugins/admin/components/PluginDetailsSignature.tsx +++ b/public/app/features/plugins/admin/components/PluginDetailsSignature.tsx @@ -1,24 +1,18 @@ import React from 'react'; import { selectors } from '@grafana/e2e-selectors'; -import { GrafanaPlugin, PluginMeta, PluginSignatureStatus } from '@grafana/data'; +import { PluginSignatureStatus } from '@grafana/data'; import { Alert } from '@grafana/ui'; +import { CatalogPlugin } from '../types'; -type PluginDetailsSignatureProps = { +type Props = { className?: string; - installedPlugin?: GrafanaPlugin>; + plugin: CatalogPlugin; }; // 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; +export function PluginDetailsSignature({ className, plugin }: Props): React.ReactElement | null { + const isSignatureValid = plugin.signature === PluginSignatureStatus.valid; + const isCore = plugin.signature === PluginSignatureStatus.internal; // The basic information is already available in the header if (isSignatureValid || isCore) { diff --git a/public/app/features/plugins/admin/components/VersionList.tsx b/public/app/features/plugins/admin/components/VersionList.tsx index 310c9a7da56..ad20241c804 100644 --- a/public/app/features/plugins/admin/components/VersionList.tsx +++ b/public/app/features/plugins/admin/components/VersionList.tsx @@ -6,10 +6,10 @@ import { useStyles2 } from '@grafana/ui'; import { Version } from '../types'; interface Props { - versions: Version[]; + versions?: Version[]; } -export const VersionList = ({ versions }: Props) => { +export const VersionList = ({ versions = [] }: Props) => { const styles = useStyles2(getStyles); if (versions.length === 0) { diff --git a/public/app/features/plugins/admin/constants.ts b/public/app/features/plugins/admin/constants.ts index 8a37d9b97f0..df10b477e1c 100644 --- a/public/app/features/plugins/admin/constants.ts +++ b/public/app/features/plugins/admin/constants.ts @@ -1,2 +1,5 @@ export const API_ROOT = '/api/plugins'; export const GRAFANA_API_ROOT = '/api/gnet'; + +// Used for prefixing the Redux actions +export const STATE_PREFIX = 'plugins'; diff --git a/public/app/features/plugins/admin/helpers.ts b/public/app/features/plugins/admin/helpers.ts index 0b9f4bddccb..3862ca06599 100644 --- a/public/app/features/plugins/admin/helpers.ts +++ b/public/app/features/plugins/admin/helpers.ts @@ -1,7 +1,7 @@ import { config } from '@grafana/runtime'; import { gt } from 'semver'; -import { PluginSignatureStatus } from '@grafana/data'; -import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, RemotePlugin, Version, PluginFilter } from './types'; +import { PluginSignatureStatus, dateTimeParse } from '@grafana/data'; +import { CatalogPlugin, LocalPlugin, RemotePlugin } from './types'; import { contextSrv } from 'app/core/services/context_srv'; export function isGrafanaAdmin(): boolean { @@ -12,6 +12,40 @@ export function isOrgAdmin() { return contextSrv.hasRole('Admin'); } +export function mergeLocalsAndRemotes(local: LocalPlugin[] = [], remote: RemotePlugin[] = []): CatalogPlugin[] { + const catalogPlugins: CatalogPlugin[] = []; + + // add locals + local.forEach((l) => { + const remotePlugin = remote.find((r) => r.slug === l.id); + + if (!remotePlugin) { + catalogPlugins.push(mergeLocalAndRemote(l)); + } + }); + + // add remote + remote.forEach((r) => { + const localPlugin = local.find((l) => l.id === r.slug); + + catalogPlugins.push(mergeLocalAndRemote(localPlugin, r)); + }); + + return catalogPlugins; +} + +export function mergeLocalAndRemote(local?: LocalPlugin, remote?: RemotePlugin): CatalogPlugin { + if (!local && remote) { + return mapRemoteToCatalog(remote); + } + + if (local && !remote) { + return mapLocalToCatalog(local); + } + + return mapToCatalogPlugin(local, remote); +} + export function mapRemoteToCatalog(plugin: RemotePlugin): CatalogPlugin { const { name, @@ -65,7 +99,10 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin { signature, dev, type, + signatureOrg, + signatureType, } = plugin; + return { description, downloads: 0, @@ -76,6 +113,8 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin { popularity: 0, publishedAt: '', signature, + signatureOrg, + signatureType, updatedAt: updated, version, hasUpdate: false, @@ -125,46 +164,37 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin): publishedAt: remote?.createdAt || '', type: remote?.typeCode || local?.type, signature: local?.signature || (hasRemoteSignature ? PluginSignatureStatus.valid : PluginSignatureStatus.missing), + signatureOrg: local?.signatureOrg || remote?.versionSignedByOrgName, + signatureType: local?.signatureType || remote?.versionSignatureType || remote?.signatureType || undefined, updatedAt: remote?.updatedAt || local?.info.updated || '', version, }; } -export function getCatalogPluginDetails( - local: LocalPlugin | undefined, - remote: RemotePlugin | undefined, - pluginVersions: Version[] = [] -): CatalogPluginDetails { - const plugin = mapToCatalogPlugin(local, remote); +export const getExternalManageLink = (pluginId: string) => `https://grafana.com/grafana/plugins/${pluginId}`; - return { - ...plugin, - grafanaDependency: remote?.json?.dependencies?.grafanaDependency || '', - links: remote?.json?.info.links || local?.info.links || [], - readme: remote?.readme || 'No plugin help or readme markdown file was found', - versions: pluginVersions, - }; +export enum Sorters { + nameAsc = 'nameAsc', + nameDesc = 'nameDesc', + updated = 'updated', + published = 'published', + downloads = 'downloads', } -export const isInstalled: PluginFilter = (plugin, query) => - query === 'installed' ? plugin.isInstalled : !plugin.isCore; +export const sortPlugins = (plugins: CatalogPlugin[], sortBy: Sorters) => { + const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = { + nameAsc: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name), + nameDesc: (a: CatalogPlugin, b: CatalogPlugin) => b.name.localeCompare(a.name), + updated: (a: CatalogPlugin, b: CatalogPlugin) => + dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(), + published: (a: CatalogPlugin, b: CatalogPlugin) => + dateTimeParse(b.publishedAt).valueOf() - dateTimeParse(a.publishedAt).valueOf(), + downloads: (a: CatalogPlugin, b: CatalogPlugin) => b.downloads - a.downloads, + }; -export const isType: PluginFilter = (plugin, query) => query === 'all' || plugin.type === query; - -export const matchesKeyword: PluginFilter = (plugin, query) => { - if (!query) { - return true; - } - const fields: String[] = []; - if (plugin.name) { - fields.push(plugin.name.toLowerCase()); + if (sorters[sortBy]) { + return plugins.sort(sorters[sortBy]); } - if (plugin.orgName) { - fields.push(plugin.orgName.toLowerCase()); - } - - return fields.some((f) => f.includes(query.toLowerCase())); + return plugins; }; - -export const getExternalManageLink = (pluginId: string) => `https://grafana.com/grafana/plugins/${pluginId}`; diff --git a/public/app/features/plugins/admin/hooks/usePluginConfig.tsx b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx new file mode 100644 index 00000000000..cae8a2e50c3 --- /dev/null +++ b/public/app/features/plugins/admin/hooks/usePluginConfig.tsx @@ -0,0 +1,16 @@ +import { useAsync } from 'react-use'; +import { CatalogPlugin } from '../types'; +import { loadPlugin } from '../../PluginPage'; + +export const usePluginConfig = (plugin?: CatalogPlugin) => { + return useAsync(async () => { + if (!plugin) { + return null; + } + + if (plugin.isInstalled) { + return loadPlugin(plugin.id); + } + return null; + }, [plugin?.id, plugin?.isInstalled]); +}; diff --git a/public/app/features/plugins/admin/hooks/usePluginDetails.tsx b/public/app/features/plugins/admin/hooks/usePluginDetails.tsx deleted file mode 100644 index c14a0b9a972..00000000000 --- a/public/app/features/plugins/admin/hooks/usePluginDetails.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useReducer, useEffect } from 'react'; -import { PluginType, PluginIncludeType, GrafanaPlugin, PluginMeta } from '@grafana/data'; -import { api } from '../api'; -import { loadPlugin } from '../../PluginPage'; -import { getCatalogPluginDetails, isOrgAdmin } from '../helpers'; -import { ActionTypes, CatalogPluginDetails, PluginDetailsActions, PluginDetailsState, PluginTabLabels } from '../types'; - -type Tab = { - label: PluginTabLabels; -}; - -const defaultTabs: Tab[] = [{ label: PluginTabLabels.OVERVIEW }, { label: PluginTabLabels.VERSIONS }]; - -const initialState = { - hasInstalledPanel: false, - hasUpdate: false, - 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, - }; - } - } -}; - -const pluginCache: Record = {}; -const pluginConfigCache: Record>> = {}; - -export const usePluginDetails = (id: string) => { - const [state, dispatch] = useReducer(reducer, initialState); - const userCanConfigurePlugins = isOrgAdmin(); - - useEffect(() => { - const fetchPlugin = async () => { - dispatch({ type: ActionTypes.LOADING }); - try { - 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 }); - } - }; - fetchPlugin(); - }, [id]); - - useEffect(() => { - const fetchPluginConfig = async () => { - if (state.isInstalled) { - dispatch({ type: ActionTypes.LOADING }); - try { - 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 }); - } - } 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: Tab[] = [...defaultTabs]; - - if (pluginConfig && userCanConfigurePlugins) { - if (pluginConfig.meta.type === PluginType.app) { - if (pluginConfig.angularConfigCtrl) { - tabs.push({ - label: PluginTabLabels.CONFIG, - }); - } - - // Configuration pages with custom labels - if (pluginConfig.configPages) { - for (const page of pluginConfig.configPages) { - tabs.push({ - label: page.title as PluginTabLabels, - }); - } - } - - if (pluginConfig.meta.includes?.find((include) => include.type === PluginIncludeType.dashboard)) { - tabs.push({ - label: PluginTabLabels.DASHBOARDS, - }); - } - } - } - dispatch({ type: ActionTypes.UPDATE_TABS, payload: tabs }); - }, [userCanConfigurePlugins, state.pluginConfig, id]); - - return { state, dispatch }; -}; diff --git a/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx new file mode 100644 index 00000000000..6cbfe243e24 --- /dev/null +++ b/public/app/features/plugins/admin/hooks/usePluginDetailsTabs.tsx @@ -0,0 +1,56 @@ +import { useMemo } from 'react'; +import { PluginIncludeType, PluginType } from '@grafana/data'; +import { CatalogPlugin, PluginDetailsTab } from '../types'; +import { isOrgAdmin } from '../helpers'; +import { usePluginConfig } from '../hooks/usePluginConfig'; + +type ReturnType = { + error: Error | undefined; + loading: boolean; + tabs: PluginDetailsTab[]; +}; + +export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: PluginDetailsTab[] = []): ReturnType => { + const { loading, error, value: pluginConfig } = usePluginConfig(plugin); + const tabs = useMemo(() => { + const canConfigurePlugins = isOrgAdmin(); + const tabs: PluginDetailsTab[] = [...defaultTabs]; + + // Not extending the tabs with the config pages if the plugin is not installed + if (!pluginConfig) { + return tabs; + } + + if (canConfigurePlugins) { + 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', + }); + } + } + } + + return tabs; + }, [pluginConfig, defaultTabs]); + + return { + error, + loading, + tabs, + }; +}; diff --git a/public/app/features/plugins/admin/hooks/usePlugins.tsx b/public/app/features/plugins/admin/hooks/usePlugins.tsx index 3713462b2ee..475bf1e4330 100644 --- a/public/app/features/plugins/admin/hooks/usePlugins.tsx +++ b/public/app/features/plugins/admin/hooks/usePlugins.tsx @@ -1,15 +1,8 @@ import { useMemo } from 'react'; import { useAsync } from 'react-use'; -import { CatalogPlugin, CatalogPluginsState, PluginsByFilterType, FilteredPluginsState } from '../types'; +import { CatalogPlugin, CatalogPluginsState } from '../types'; import { api } from '../api'; -import { - mapLocalToCatalog, - mapRemoteToCatalog, - mapToCatalogPlugin, - isInstalled, - isType, - matchesKeyword, -} from '../helpers'; +import { mapLocalToCatalog, mapRemoteToCatalog, mapToCatalogPlugin } from '../helpers'; export function usePlugins(): CatalogPluginsState { const { loading, value, error } = useAsync(async () => { @@ -54,25 +47,3 @@ export function usePlugins(): CatalogPluginsState { plugins, }; } - -const URLFilterHandlers = { - filterBy: isInstalled, - filterByType: isType, - searchBy: matchesKeyword, -}; - -export const usePluginsByFilter = (queries: PluginsByFilterType): FilteredPluginsState => { - const { loading, error, plugins } = usePlugins(); - - const filteredPlugins = plugins.filter((plugin) => - (Object.keys(queries) as Array).every((query) => - typeof URLFilterHandlers[query] === 'function' ? URLFilterHandlers[query](plugin, queries[query]) : true - ) - ); - - return { - isLoading: loading, - error, - plugins: filteredPlugins, - }; -}; diff --git a/public/app/features/plugins/admin/pages/Browse.test.tsx b/public/app/features/plugins/admin/pages/Browse.test.tsx index ad33e6af3d4..c0dbd0b5d50 100644 --- a/public/app/features/plugins/admin/pages/Browse.test.tsx +++ b/public/app/features/plugins/admin/pages/Browse.test.tsx @@ -10,21 +10,28 @@ import { configureStore } from 'app/store/configureStore'; import { LocalPlugin, RemotePlugin, PluginAdminRoutes } from '../types'; import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; -jest.mock('@grafana/runtime', () => ({ - ...(jest.requireActual('@grafana/runtime') as object), - getBackendSrv: () => ({ - get: (path: string) => { - switch (path) { - case `${GRAFANA_API_ROOT}/plugins`: - return Promise.resolve({ items: remote }); - case API_ROOT: - return Promise.resolve(installed); - default: - return Promise.reject(); - } +jest.mock('@grafana/runtime', () => { + const original = jest.requireActual('@grafana/runtime'); + return { + ...original, + getBackendSrv: () => ({ + get: (path: string) => { + switch (path) { + case `${GRAFANA_API_ROOT}/plugins`: + return Promise.resolve({ items: remote }); + case API_ROOT: + return Promise.resolve(installed); + default: + return Promise.reject(); + } + }, + }), + config: { + ...original.config, + pluginAdminEnabled: true, }, - }), -})); + }; +}); function setup(path = '/plugins'): RenderResult { const store = configureStore(); @@ -247,7 +254,7 @@ const installed: LocalPlugin[] = [ category: '', state: 'alpha', signature: PluginSignatureStatus.internal, - signatureType: '', + signatureType: PluginSignatureType.core, signatureOrg: '', }, { @@ -284,7 +291,7 @@ const installed: LocalPlugin[] = [ category: '', state: '', signature: PluginSignatureStatus.missing, - signatureType: '', + signatureType: PluginSignatureType.core, signatureOrg: '', }, { diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx index 8c2690cc01f..a824d7d89b6 100644 --- a/public/app/features/plugins/admin/pages/Browse.tsx +++ b/public/app/features/plugins/admin/pages/Browse.tsx @@ -1,6 +1,6 @@ import React, { ReactElement } from 'react'; import { css } from '@emotion/css'; -import { SelectableValue, dateTimeParse, GrafanaTheme2 } from '@grafana/data'; +import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; import { LoadingPlaceholder, Select, RadioButtonGroup, useStyles2 } from '@grafana/ui'; import { useLocation } from 'react-router-dom'; import { locationSearchToObject } from '@grafana/runtime'; @@ -8,30 +8,34 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { PluginList } from '../components/PluginList'; import { SearchField } from '../components/SearchField'; import { useHistory } from '../hooks/useHistory'; -import { CatalogPlugin, PluginAdminRoutes } from '../types'; +import { PluginAdminRoutes } from '../types'; import { Page as PluginPage } from '../components/Page'; import { HorizontalGroup } from '../components/HorizontalGroup'; import { Page } from 'app/core/components/Page/Page'; -import { usePluginsByFilter } from '../hooks/usePlugins'; import { useSelector } from 'react-redux'; import { StoreState } from 'app/types/store'; import { getNavModel } from 'app/core/selectors/navModel'; +import { useGetAll, useGetAllWithFilters } from '../state/hooks'; +import { Sorters } from '../helpers'; export default function Browse({ route }: GrafanaRouteComponentProps): ReactElement | null { + useGetAll(); const location = useLocation(); - const query = locationSearchToObject(location.search); + const locationSearch = locationSearchToObject(location.search); const navModelId = getNavModelId(route.routeName); const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, navModelId)); const styles = useStyles2(getStyles); - - const q = (query.q as string) ?? ''; - const filterBy = (query.filterBy as string) ?? 'installed'; - const filterByType = (query.filterByType as string) ?? 'all'; - const sortBy = (query.sortBy as string) ?? 'nameAsc'; - - const { plugins, isLoading, error } = usePluginsByFilter({ searchBy: q, filterBy, filterByType }); - const sortedPlugins = plugins.sort(sorters[sortBy]); const history = useHistory(); + const query = (locationSearch.q as string) || ''; + const filterBy = (locationSearch.filterBy as string) || 'installed'; + const filterByType = (locationSearch.filterByType as string) || 'all'; + const sortBy = (locationSearch.sortBy as Sorters) || Sorters.nameAsc; + const { isLoading, error, plugins } = useGetAllWithFilters({ + query, + filterBy, + filterByType, + sortBy, + }); const onSortByChange = (value: SelectableValue) => { history.push({ query: { sortBy: value.value } }); @@ -60,7 +64,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem - +
) : ( - + )}
@@ -139,13 +143,3 @@ const getNavModelId = (routeName?: string) => { return 'plugins'; }; - -const sorters: { [name: string]: (a: CatalogPlugin, b: CatalogPlugin) => number } = { - nameAsc: (a: CatalogPlugin, b: CatalogPlugin) => a.name.localeCompare(b.name), - nameDesc: (a: CatalogPlugin, b: CatalogPlugin) => b.name.localeCompare(a.name), - updated: (a: CatalogPlugin, b: CatalogPlugin) => - dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(), - published: (a: CatalogPlugin, b: CatalogPlugin) => - dateTimeParse(b.publishedAt).valueOf() - dateTimeParse(a.publishedAt).valueOf(), - downloads: (a: CatalogPlugin, b: CatalogPlugin) => b.downloads - a.downloads, -}; diff --git a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx index e2430123e37..b1dea4f98bd 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.test.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.test.tsx @@ -1,8 +1,10 @@ import React from 'react'; +import { Provider } from 'react-redux'; import { render, RenderResult, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { config } from '@grafana/runtime'; import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data'; +import { configureStore } from 'app/store/configureStore'; import PluginDetailsPage from './PluginDetails'; import { API_ROOT, GRAFANA_API_ROOT } from '../constants'; import { LocalPlugin, RemotePlugin } from '../types'; @@ -45,6 +47,15 @@ jest.mock('@grafana/runtime', () => { return Promise.resolve(remotePlugin({ slug: 'installed' })); case `${GRAFANA_API_ROOT}/plugins/enterprise`: return Promise.resolve(remotePlugin({ status: 'enterprise' })); + case `${GRAFANA_API_ROOT}/plugins`: + return Promise.resolve({ + items: [ + remotePlugin({ slug: 'not-installed' }), + remotePlugin({ slug: 'installed' }), + remotePlugin({ slug: 'has-update', version: '2.0.0' }), + remotePlugin({ slug: 'enterprise', status: 'enterprise' }), + ], + }); default: return Promise.reject(); } @@ -63,13 +74,19 @@ jest.mock('@grafana/runtime', () => { ...original.config.buildInfo, version: 'v7.5.0', }, + pluginAdminEnabled: true, }, }; }); function setup(pluginId: string): RenderResult { const props = getRouteComponentProps({ match: { params: { pluginId }, isExact: true, url: '', path: '' } }); - return render(); + const store = configureStore(); + return render( + + + + ); } describe('Plugin details page', () => { @@ -89,6 +106,7 @@ describe('Plugin details page', () => { it('should display an overview (plugin readme) by default', async () => { const { queryByText } = setup('not-installed'); + await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument()); }); @@ -262,7 +280,7 @@ function localPlugin(plugin: Partial = {}): LocalPlugin { category: '', state: '', signature: PluginSignatureStatus.valid, - signatureType: 'community', + signatureType: PluginSignatureType.core, signatureOrg: 'Grafana Labs', ...plugin, }; diff --git a/public/app/features/plugins/admin/pages/PluginDetails.tsx b/public/app/features/plugins/admin/pages/PluginDetails.tsx index 2856d8f631d..5a2cd879432 100644 --- a/public/app/features/plugins/admin/pages/PluginDetails.tsx +++ b/public/app/features/plugins/admin/pages/PluginDetails.tsx @@ -1,30 +1,51 @@ -import React from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2, TabsBar, TabContent, Tab, Alert } from '@grafana/ui'; - -import { AppNotificationSeverity } from 'app/types'; -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 { Layout } from '@grafana/ui/src/components/Layout/Layout'; import { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { ActionTypes } from '../types'; +import { PluginDetailsSignature } from '../components/PluginDetailsSignature'; +import { PluginDetailsHeader } from '../components/PluginDetailsHeader'; import { PluginDetailsBody } from '../components/PluginDetailsBody'; +import { Page as PluginPage } from '../components/Page'; +import { Loader } from '../components/Loader'; +import { PluginTabLabels, PluginDetailsTab } from '../types'; +import { useGetSingle, useFetchStatus } from '../state/hooks'; +import { usePluginDetailsTabs } from '../hooks/usePluginDetailsTabs'; +import { AppNotificationSeverity } from 'app/types'; -type PluginDetailsProps = GrafanaRouteComponentProps<{ pluginId?: string }>; +type Props = 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 } = state; - const tab = tabs[activeTab]; +type State = { + tabs: PluginDetailsTab[]; + activeTabIndex: number; +}; + +const DefaultState = { + tabs: [{ label: PluginTabLabels.OVERVIEW }, { label: PluginTabLabels.VERSIONS }], + activeTabIndex: 0, +}; + +export default function PluginDetails({ match }: Props): JSX.Element | null { + const { pluginId = '' } = match.params; + const [state, setState] = useState(DefaultState); + const plugin = useGetSingle(pluginId); // fetches the localplugin settings + const { tabs } = usePluginDetailsTabs(plugin, DefaultState.tabs); + const { activeTabIndex } = state; + const { isLoading } = useFetchStatus(); const styles = useStyles2(getStyles); + const setActiveTab = useCallback((activeTabIndex: number) => setState({ ...state, activeTabIndex }), [state]); const parentUrl = match.url.substring(0, match.url.lastIndexOf('/')); - if (loading) { + // If an app plugin is uninstalled we need to reset the active tab when the config / dashboards tabs are removed. + useEffect(() => { + if (activeTabIndex > tabs.length - 1) { + setActiveTab(0); + } + }, [setActiveTab, activeTabIndex, tabs]); + + if (isLoading) { return ( @@ -33,38 +54,37 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen } if (!plugin) { - return null; + return ( + + + That plugin cannot be found. Please check the url is correct or
+ go to the plugin catalog. +
+
+ ); } return ( - + {/* Tab navigation */} - {tabs.map((tab: { label: string }, idx: number) => ( + {tabs.map((tab: PluginDetailsTab, idx: number) => ( dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: idx })} + active={idx === activeTabIndex} + onChangeTab={() => setActiveTab(idx)} /> ))} {/* Active tab */} - {error && ( - - <> - Check the server startup logs for more information.
- If this plugin was loaded from git, make sure it was compiled. - -
- )} - - + +
diff --git a/public/app/features/plugins/admin/state/actions.ts b/public/app/features/plugins/admin/state/actions.ts new file mode 100644 index 00000000000..67db4275c71 --- /dev/null +++ b/public/app/features/plugins/admin/state/actions.ts @@ -0,0 +1,93 @@ +import { createAsyncThunk, Update } from '@reduxjs/toolkit'; +import { getBackendSrv } from '@grafana/runtime'; +import { PanelPlugin } from '@grafana/data'; +import { StoreState, ThunkResult } from 'app/types'; +import { importPanelPlugin } from 'app/features/plugins/plugin_loader'; +import { getCatalogPlugins, getPluginDetails, installPlugin, uninstallPlugin } from '../api'; +import { STATE_PREFIX } from '../constants'; +import { CatalogPlugin } from '../types'; + +export const fetchAll = createAsyncThunk(`${STATE_PREFIX}/fetchAll`, async (_, thunkApi) => { + try { + return await getCatalogPlugins(); + } catch (e) { + return thunkApi.rejectWithValue('Unknown error.'); + } +}); + +export const fetchDetails = createAsyncThunk(`${STATE_PREFIX}/fetchDetails`, async (id: string, thunkApi) => { + try { + const details = await getPluginDetails(id); + + return { + id, + changes: { details }, + } as Update; + } catch (e) { + return thunkApi.rejectWithValue('Unknown error.'); + } +}); + +export const install = createAsyncThunk( + `${STATE_PREFIX}/install`, + async ({ id, version, isUpdating = false }: { id: string; version: string; isUpdating?: boolean }, thunkApi) => { + const changes = isUpdating ? { isInstalled: true, hasUpdate: false } : { isInstalled: true }; + try { + await installPlugin(id, version); + return { + id, + changes, + } as Update; + } catch (e) { + return thunkApi.rejectWithValue('Unknown error.'); + } + } +); + +export const uninstall = createAsyncThunk(`${STATE_PREFIX}/uninstall`, async (id: string, thunkApi) => { + try { + await uninstallPlugin(id); + return { + id, + changes: { isInstalled: false }, + } as Update; + } catch (e) { + return thunkApi.rejectWithValue('Unknown error.'); + } +}); + +// We need this to be backwards-compatible with other parts of Grafana. +// (Originally in "public/app/features/plugins/state/actions.ts") +// TODO +export const loadPluginDashboards = createAsyncThunk(`${STATE_PREFIX}/loadPluginDashboards`, async (_, thunkApi) => { + const state = thunkApi.getState() as StoreState; + const dataSourceType = state.dataSources.dataSource.type; + const url = `api/plugins/${dataSourceType}/dashboards`; + + return getBackendSrv().get(url); +}); + +// We need this to be backwards-compatible with other parts of Grafana. +// (Originally in "public/app/features/plugins/state/actions.ts") +// It cannot be constructed with `createAsyncThunk()` as we need the return value on the call-site, +// and we cannot easily change the call-site to unwrap the result. +// TODO +export const loadPanelPlugin = (id: string): ThunkResult> => { + return async (dispatch, getStore) => { + let plugin = getStore().plugins.panels[id]; + + if (!plugin) { + plugin = await importPanelPlugin(id); + + // second check to protect against raise condition + if (!getStore().plugins.panels[id]) { + dispatch({ + type: `${STATE_PREFIX}/loadPanelPlugin/fulfilled`, + payload: plugin, + }); + } + } + + return plugin; + }; +}; diff --git a/public/app/features/plugins/admin/state/hooks.ts b/public/app/features/plugins/admin/state/hooks.ts new file mode 100644 index 00000000000..e7bf87d6ab7 --- /dev/null +++ b/public/app/features/plugins/admin/state/hooks.ts @@ -0,0 +1,106 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchAll, fetchDetails, install, uninstall } from './actions'; +import { CatalogPlugin, PluginCatalogStoreState } from '../types'; +import { + find, + selectAll, + selectById, + selectIsRequestPending, + selectRequestError, + selectIsRequestNotFetched, +} from './selectors'; +import { sortPlugins, Sorters } from '../helpers'; + +type Filters = { + query?: string; + filterBy?: string; + filterByType?: string; + sortBy?: Sorters; +}; + +export const useGetAllWithFilters = ({ + query = '', + filterBy = 'installed', + filterByType = 'all', + sortBy = Sorters.nameAsc, +}: Filters) => { + useFetchAll(); + + const filtered = useSelector(find(query, filterBy, filterByType)); + const { isLoading, error } = useFetchStatus(); + const sortedAndFiltered = sortPlugins(filtered, sortBy); + + return { + isLoading, + error, + plugins: sortedAndFiltered, + }; +}; + +export const useGetAll = (): CatalogPlugin[] => { + useFetchAll(); + + return useSelector(selectAll); +}; + +export const useGetSingle = (id: string): CatalogPlugin | undefined => { + useFetchAll(); + useFetchDetails(id); + + return useSelector((state: PluginCatalogStoreState) => selectById(state, id)); +}; + +export const useInstall = () => { + const dispatch = useDispatch(); + + return (id: string, version: string, isUpdating?: boolean) => dispatch(install({ id, version, isUpdating })); +}; + +export const useUninstall = () => { + const dispatch = useDispatch(); + + return (id: string) => dispatch(uninstall(id)); +}; + +export const useFetchStatus = () => { + const isLoading = useSelector(selectIsRequestPending(fetchAll.typePrefix)); + const error = useSelector(selectRequestError(fetchAll.typePrefix)); + + return { isLoading, error }; +}; + +export const useInstallStatus = () => { + const isInstalling = useSelector(selectIsRequestPending(install.typePrefix)); + const error = useSelector(selectRequestError(install.typePrefix)); + + return { isInstalling, error }; +}; + +export const useUninstallStatus = () => { + const isUninstalling = useSelector(selectIsRequestPending(uninstall.typePrefix)); + const error = useSelector(selectRequestError(uninstall.typePrefix)); + + return { isUninstalling, error }; +}; + +// Only fetches in case they were not fetched yet +export const useFetchAll = () => { + const dispatch = useDispatch(); + const isNotFetched = useSelector(selectIsRequestNotFetched(fetchAll.typePrefix)); + + useEffect(() => { + isNotFetched && dispatch(fetchAll()); + }, []); // eslint-disable-line +}; + +export const useFetchDetails = (id: string) => { + const dispatch = useDispatch(); + const plugin = useSelector((state: PluginCatalogStoreState) => selectById(state, id)); + const isNotFetching = !useSelector(selectIsRequestPending(fetchDetails.typePrefix)); + const shouldFetch = isNotFetching && plugin && !plugin.details; + + useEffect(() => { + shouldFetch && dispatch(fetchDetails(id)); + }, [plugin]); // eslint-disable-line +}; diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts new file mode 100644 index 00000000000..9cc73007319 --- /dev/null +++ b/public/app/features/plugins/admin/state/reducer.ts @@ -0,0 +1,89 @@ +import { createSlice, createEntityAdapter, AnyAction } from '@reduxjs/toolkit'; +import { fetchAll, fetchDetails, install, uninstall, loadPluginDashboards } from './actions'; +import { CatalogPlugin, ReducerState, RequestStatus } from '../types'; +import { STATE_PREFIX } from '../constants'; + +export const pluginsAdapter = createEntityAdapter(); + +const isPendingRequest = (action: AnyAction) => new RegExp(`${STATE_PREFIX}\/(.*)\/pending`).test(action.type); + +const isFulfilledRequest = (action: AnyAction) => new RegExp(`${STATE_PREFIX}\/(.*)\/fulfilled`).test(action.type); + +const isRejectedRequest = (action: AnyAction) => new RegExp(`${STATE_PREFIX}\/(.*)\/rejected`).test(action.type); + +// Extract the trailing '/pending', '/rejected', or '/fulfilled' +const getOriginalActionType = (type: string) => { + const separator = type.lastIndexOf('/'); + + return type.substring(0, separator); +}; + +export const { reducer } = createSlice({ + name: 'plugins', + initialState: { + items: pluginsAdapter.getInitialState(), + requests: {}, + // Backwards compatibility + // (we need to have the following fields in the store as well to be backwards compatible with other parts of Grafana) + // TODO + plugins: [], + errors: [], + searchQuery: '', + hasFetched: false, + dashboards: [], + isLoadingPluginDashboards: false, + panels: {}, + } as ReducerState, + reducers: {}, + extraReducers: (builder) => + builder + // Fetch All + .addCase(fetchAll.fulfilled, (state, action) => { + pluginsAdapter.upsertMany(state.items, action.payload); + }) + // Fetch Details + .addCase(fetchDetails.fulfilled, (state, action) => { + pluginsAdapter.updateOne(state.items, action.payload); + }) + // Install + .addCase(install.fulfilled, (state, action) => { + pluginsAdapter.updateOne(state.items, action.payload); + }) + // Uninstall + .addCase(uninstall.fulfilled, (state, action) => { + pluginsAdapter.updateOne(state.items, action.payload); + }) + // Load a panel plugin (backward-compatibility) + // TODO + .addCase(`${STATE_PREFIX}/loadPanelPlugin/fulfilled`, (state, action: AnyAction) => { + state.panels[action.payload.meta!.id] = action.payload; + }) + // Start loading panel dashboards (backward-compatibility) + // TODO + .addCase(loadPluginDashboards.pending, (state, action) => { + state.isLoadingPluginDashboards = true; + state.dashboards = []; + }) + // Load panel dashboards (backward-compatibility) + // TODO + .addCase(loadPluginDashboards.fulfilled, (state, action) => { + state.isLoadingPluginDashboards = false; + state.dashboards = action.payload; + }) + .addMatcher(isPendingRequest, (state, action) => { + state.requests[getOriginalActionType(action.type)] = { + status: RequestStatus.Pending, + }; + }) + .addMatcher(isFulfilledRequest, (state, action) => { + state.requests[getOriginalActionType(action.type)] = { + status: RequestStatus.Fulfilled, + }; + }) + .addMatcher(isRejectedRequest, (state, action) => { + state.requests[getOriginalActionType(action.type)] = { + status: RequestStatus.Rejected, + error: action.payload, + }; + }), +}); diff --git a/public/app/features/plugins/admin/state/selectors.ts b/public/app/features/plugins/admin/state/selectors.ts new file mode 100644 index 00000000000..fa0ba0281b1 --- /dev/null +++ b/public/app/features/plugins/admin/state/selectors.ts @@ -0,0 +1,62 @@ +import { createSelector } from 'reselect'; +import { RequestStatus, PluginCatalogStoreState } from '../types'; +import { pluginsAdapter } from './reducer'; + +export const selectRoot = (state: PluginCatalogStoreState) => state.plugins; + +export const selectItems = createSelector(selectRoot, ({ items }) => items); + +export const { selectAll, selectById } = pluginsAdapter.getSelectors(selectItems); + +const selectInstalled = (filterBy: string) => + createSelector(selectAll, (plugins) => + plugins.filter((plugin) => (filterBy === 'installed' ? plugin.isInstalled : !plugin.isCore)) + ); + +const findByInstallAndType = (filterBy: string, filterByType: string) => + createSelector(selectInstalled(filterBy), (plugins) => + plugins.filter((plugin) => filterByType === 'all' || plugin.type === filterByType) + ); + +const findByKeyword = (searchBy: string) => + createSelector(selectAll, (plugins) => { + if (searchBy === '') { + return []; + } + + return plugins.filter((plugin) => { + const fields: String[] = []; + if (plugin.name) { + fields.push(plugin.name.toLowerCase()); + } + + if (plugin.orgName) { + fields.push(plugin.orgName.toLowerCase()); + } + + return fields.some((f) => f.includes(searchBy.toLowerCase())); + }); + }); + +export const find = (searchBy: string, filterBy: string, filterByType: string) => + createSelector( + findByInstallAndType(filterBy, filterByType), + findByKeyword(searchBy), + (filteredPlugins, searchedPlugins) => { + return searchBy === '' ? filteredPlugins : searchedPlugins; + } + ); + +export const selectRequest = (actionType: string) => + createSelector(selectRoot, ({ requests = {} }) => requests[actionType]); + +export const selectIsRequestPending = (actionType: string) => + createSelector(selectRequest(actionType), (request) => request?.status === RequestStatus.Pending); + +export const selectRequestError = (actionType: string) => + createSelector(selectRequest(actionType), (request) => + request?.status === RequestStatus.Rejected ? request?.error : null + ); + +export const selectIsRequestNotFetched = (actionType: string) => + createSelector(selectRequest(actionType), (request) => request === undefined); diff --git a/public/app/features/plugins/admin/types.ts b/public/app/features/plugins/admin/types.ts index 78ddbc7b7a5..6fa9f122039 100644 --- a/public/app/features/plugins/admin/types.ts +++ b/public/app/features/plugins/admin/types.ts @@ -1,4 +1,7 @@ -import { GrafanaPlugin, PluginMeta, PluginType, PluginSignatureStatus, PluginSignatureType } from '@grafana/data'; +import { EntityState } from '@reduxjs/toolkit'; +import { PluginType, PluginSignatureStatus, PluginSignatureType } from '@grafana/data'; +import { StoreState, PluginsState } from 'app/types'; + export type PluginTypeCode = 'app' | 'panel' | 'datasource'; export enum PluginAdminRoutes { @@ -23,16 +26,19 @@ export interface CatalogPlugin { name: string; orgName: string; signature: PluginSignatureStatus; + signatureType?: PluginSignatureType; + signatureOrg?: string; popularity: number; publishedAt: string; type?: PluginType; updatedAt: string; version: string; + details?: CatalogPluginDetails; } -export interface CatalogPluginDetails extends CatalogPlugin { - readme: string; - versions: Version[]; +export interface CatalogPluginDetails { + readme?: string; + versions?: Version[]; links: Array<{ name: string; url: string; @@ -126,7 +132,7 @@ export type LocalPlugin = { pinned: boolean; signature: PluginSignatureStatus; signatureOrg: string; - signatureType: string; + signatureType: PluginSignatureType; state: string; type: PluginType; }; @@ -164,66 +170,12 @@ export interface Org { avatarUrl: string; } -export interface PluginDetailsState { - hasInstalledPanel: boolean; - hasUpdate: 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; - }; - export type CatalogPluginsState = { loading: boolean; error?: Error; plugins: CatalogPlugin[]; }; -export type FilteredPluginsState = { - isLoading: boolean; - error?: Error; - plugins: CatalogPlugin[]; -}; - -export type PluginsByFilterType = { - searchBy: string; - filterBy: string; - filterByType: string; -}; - -export type PluginFilter = (plugin: CatalogPlugin, query: string) => boolean; - export enum PluginStatus { INSTALL = 'INSTALL', UNINSTALL = 'UNINSTALL', @@ -236,3 +188,30 @@ export enum PluginTabLabels { CONFIG = 'Config', DASHBOARDS = 'Dashboards', } + +export enum RequestStatus { + Pending = 'Pending', + Fulfilled = 'Fulfilled', + Rejected = 'Rejected', +} + +export type RequestInfo = { + status: RequestStatus; + // The whole error object + error?: any; + // An optional error message + errorMessage?: string; +}; + +export type PluginDetailsTab = { + label: PluginTabLabels | string; +}; + +// TODO +export type ReducerState = PluginsState & { + items: EntityState; + requests: Record; +}; + +// TODO +export type PluginCatalogStoreState = StoreState & { plugins: ReducerState }; diff --git a/public/app/features/plugins/state/actions.ts b/public/app/features/plugins/state/actions.ts index 0be091b14ae..d96d11edddd 100644 --- a/public/app/features/plugins/state/actions.ts +++ b/public/app/features/plugins/state/actions.ts @@ -1,6 +1,12 @@ import { getBackendSrv } from '@grafana/runtime'; import { PanelPlugin } from '@grafana/data'; import { ThunkResult } from 'app/types'; +import { config } from 'app/core/config'; +import { importPanelPlugin } from 'app/features/plugins/plugin_loader'; +import { + loadPanelPlugin as loadPanelPluginNew, + loadPluginDashboards as loadPluginDashboardsNew, +} from '../admin/state/actions'; import { pluginDashboardsLoad, pluginDashboardsLoaded, @@ -8,7 +14,6 @@ import { panelPluginLoaded, pluginsErrorsLoaded, } from './reducers'; -import { importPanelPlugin } from 'app/features/plugins/plugin_loader'; export function loadPlugins(): ThunkResult { return async (dispatch) => { @@ -24,7 +29,7 @@ export function loadPluginsErrors(): ThunkResult { }; } -export function loadPluginDashboards(): ThunkResult { +function loadPluginDashboardsOriginal(): ThunkResult { return async (dispatch, getStore) => { dispatch(pluginDashboardsLoad()); const dataSourceType = getStore().dataSources.dataSource.type; @@ -33,7 +38,7 @@ export function loadPluginDashboards(): ThunkResult { }; } -export function loadPanelPlugin(pluginId: string): ThunkResult> { +function loadPanelPluginOriginal(pluginId: string): ThunkResult> { return async (dispatch, getStore) => { let plugin = getStore().plugins.panels[pluginId]; @@ -49,3 +54,6 @@ export function loadPanelPlugin(pluginId: string): ThunkResult { describe('when pluginsLoaded is dispatched', () => { it('then state should be correct', () => { reducerTester() - .givenReducer(pluginsReducer, { ...initialState }) + .givenReducer(pluginsReducer as Reducer, { ...initialState }) .whenActionIsDispatched( pluginsLoaded([ { @@ -48,7 +49,7 @@ describe('pluginsReducer', () => { describe('when setPluginsSearchQuery is dispatched', () => { it('then state should be correct', () => { reducerTester() - .givenReducer(pluginsReducer, { ...initialState }) + .givenReducer(pluginsReducer as Reducer, { ...initialState }) .whenActionIsDispatched(setPluginsSearchQuery('A query')) .thenStateShouldEqual({ ...initialState, @@ -60,7 +61,7 @@ describe('pluginsReducer', () => { describe('when pluginDashboardsLoad is dispatched', () => { it('then state should be correct', () => { reducerTester() - .givenReducer(pluginsReducer, { + .givenReducer(pluginsReducer as Reducer, { ...initialState, dashboards: [ { @@ -92,7 +93,10 @@ describe('pluginsReducer', () => { describe('when pluginDashboardsLoad is dispatched', () => { it('then state should be correct', () => { reducerTester() - .givenReducer(pluginsReducer, { ...initialState, isLoadingPluginDashboards: true }) + .givenReducer(pluginsReducer as Reducer, { + ...initialState, + isLoadingPluginDashboards: true, + }) .whenActionIsDispatched( pluginDashboardsLoaded([ { diff --git a/public/app/features/plugins/state/reducers.ts b/public/app/features/plugins/state/reducers.ts index 3efe068f8df..00c3ddd870f 100644 --- a/public/app/features/plugins/state/reducers.ts +++ b/public/app/features/plugins/state/reducers.ts @@ -1,6 +1,8 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { PluginMeta, PanelPlugin, PluginError } from '@grafana/data'; import { PluginsState } from 'app/types'; +import { config } from 'app/core/config'; +import { reducer as pluginCatalogReducer } from '../admin/state/reducer'; import { PluginDashboard } from '../../../types/plugins'; export const initialState: PluginsState = { @@ -50,7 +52,7 @@ export const { panelPluginLoaded, } = pluginsSlice.actions; -export const pluginsReducer = pluginsSlice.reducer; +export const pluginsReducer = config.pluginAdminEnabled ? pluginCatalogReducer : pluginsSlice.reducer; export default { plugins: pluginsReducer,