Plugins Catalog: migrate state handling to Redux (#38876)

* feat(Plugins/Catalog): start adding necessary apis

* feat(PLugins/Catalog): add extra helpers for merging local & remote plugins

* feat(Plugins/Catalog): add plugin details as an optional field of CatalogPlugin

* feat(PLugins/Catalog): add scaffolding for the new redux model

* feat(PLugins/Catalog): export reducers based on a feature-flag

* refactor(Plugins/Admin): rename api methods

* feat(Plugin/Catalog): add an api method for fetching a single plugin

* feat(Plugins/Admin): try cleaning stuff around plugin fetching

* ffeat(Plugins/Catalog): return the catalog reducer when the feature flag is set

* refactor(Plugins/Admin): fix typings

* feat(Plugins/Admin): use the new reducer for the browse page

* feat(catalog): introduce selectors to search and filter plugins list

* refactor(Plugins/Details): rename page prop type

* refactor(Plugins/Admin): add a const for a state prefix

* refactor(Plugins/Admin): use the state prefix in the actions

* feat(Plugins/Admin): add types for the requests

* refactor(Plugins/Admin): add request info to the reducer

* refactor(Plugins/Admin): add request handling to the hooks & selectors

* refactor(Plugins/Details): start using the data stored in Redux

* refactor(Plugins/Admin): rename selector to start with "select"

* fix(Plugins/Admin): only fetch plugins once

* refactor(Plugins/Admin): make the tab selection work in details

* refactor(catalog): put back loading and error states in plugin list

* refactor(Plugins/Admin): use CatalogPlugin for <PluginDetailsSignature />

* feat(Plugins/Admin): add an api method for fetching plugin details

* refactor(Plugins/Admin): add action for updating the details

* irefactor(Plugins/Admin): show basic plugin details info

* refactor(Plugin Details): migrate the plugin details header

* refactor(Plugins/Admin): make the config and dashboards tabs work

* refactor(Plugins/Admin): add old reducer state to the new one

* feat(catalog): introduce actions, reducers and hooks for install & uninstall

* refactor(catalog): wire up InstallControls component to redux

* refactor(catalog): move parentUrl inside PluginDetailsHeader and uncomment InstallControls

* feat(catalog): introduce code for plugin updates to install action

* refactor(Plugins/Admin): add backward compatible actions

* test(catalog): update PluginDetails and Browse tests to work with catalog store

* refactor(Plugins/Admin): make the dashboards and panels work again

* refactor(Plugins/Admin): fix linter and typescript errors

* fix(Plugins/Admin): put the local-only plugins to the beginning of the list

* fix(Plugins/Admin): fix the mocks in the tests for PluginDetails

* refactor(Plugins/Admin): remove unecessary hook usePluginsByFilter()

* refactor(Plugins/Admin): extract the useTabs() hook to its own file

* refactor(Plugins/Admin): remove unused helpers and types

* fix(Plugins/Admin): show the first tab when uninstalling an app plugin

This can cause the user to find themselves on a dissappeared tab, as the
config and dashboards tabs are removed.

* fix(catalog): correct logic for checking if activeTabIndex is greater than total tabs

* fix(Plugins/Admin): fix race-condition between fetching plugin details and all plugins

* fix(Plugins): fix strict type errors

* chore(catalog): remove todos

* feat(catalog): render an alert in PluginDetails when a plugin cannot be found

* feat(catalog): use the proper store state

* refactor(Plugins/Admin): fetch local and remote plugins in parallell

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>

* style(catalog): fix prettier error in api

* fix(catalog): prevent throwing error if InstallControlsButton is unmounted during install

* refactor(Plugins/Admin): add a separate hook for filtering & sorting plugins

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Levente Balogh 2021-09-09 12:20:35 +02:00 committed by GitHub
parent e4ca6f2445
commit 1133e56006
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 781 additions and 518 deletions

View File

@ -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<CatalogPlugin[]> {
const [localPlugins, remotePlugins] = await Promise.all([getLocalPlugins(), getRemotePlugins()]);
return mergeLocalsAndRemotes(localPlugins, remotePlugins);
}
export async function getCatalogPlugin(id: string): Promise<CatalogPlugin> {
const { local, remote } = await getPlugin(id);
return mergeLocalAndRemote(local, remote);
}
export async function getPluginDetails(id: string): Promise<CatalogPluginDetails> {
const localPlugins = await getLocalPlugins(); // /api/plugins/<id>/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<RemotePlugin[]> {
const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`);
@ -8,13 +35,13 @@ async function getRemotePlugins(): Promise<RemotePlugin[]> {
}
async function getPlugin(slug: string): Promise<PluginDetails> {
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<PluginDetails> {
};
}
async function getRemotePlugin(slug: string, local: LocalPlugin | undefined): Promise<RemotePlugin | undefined> {
async function getRemotePlugin(id: string, isInstalled: boolean): Promise<RemotePlugin | undefined> {
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<any[]> {
}
}
async function getInstalledPlugins(): Promise<LocalPlugin[]> {
async function getLocalPlugins(): Promise<LocalPlugin[]> {
const installed = await getBackendSrv().get(`${API_ROOT}`, { embedded: 0 });
return installed;
}
@ -52,20 +79,20 @@ async function getOrg(slug: string): Promise<Org> {
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,

View File

@ -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<any>;
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 (
<HorizontalGroup height="auto">
<Button variant="destructive" disabled={isInProgress} onClick={onUninstall}>
<Button variant="destructive" disabled={isUninstalling} onClick={onUninstall}>
{uninstallBtnText}
</Button>
{hasInstalledPanel && (
@ -75,10 +63,10 @@ export function InstallControlsButton({
if (pluginStatus === PluginStatus.UPDATE) {
return (
<HorizontalGroup height="auto">
<Button disabled={isInProgress} onClick={onUpdate}>
{updateBtnText}
<Button disabled={isInstalling} onClick={onUpdate}>
{isInstalling ? 'Updating' : 'Update'}
</Button>
<Button variant="destructive" disabled={isInProgress} onClick={onUninstall}>
<Button variant="destructive" disabled={isUninstalling} onClick={onUninstall}>
{uninstallBtnText}
</Button>
</HorizontalGroup>
@ -86,8 +74,8 @@ export function InstallControlsButton({
}
return (
<Button disabled={isInProgress} onClick={onInstall}>
{installBtnText}
<Button disabled={isInstalling} onClick={onInstall}>
{isInstalling ? 'Installing' : 'Install'}
</Button>
);
}

View File

@ -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<any>;
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 <ExternallyManagedButton pluginId={plugin.id} pluginStatus={pluginStatus} />;
}
return (
<InstallControlsButton
isInProgress={isInflight}
dispatch={dispatch}
plugin={plugin}
pluginStatus={pluginStatus}
hasInstalledPanel={hasInstalledPanel}
/>
);
return <InstallControlsButton plugin={plugin} pluginStatus={pluginStatus} />;
};
export const getStyles = (theme: GrafanaTheme2) => {

View File

@ -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<PluginMeta<{}>> | 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 (
<div
className={cx(styles.readme, styles.container)}
dangerouslySetInnerHTML={{ __html: readme ?? 'No plugin help or readme markdown file was found' }}
dangerouslySetInnerHTML={{
__html: plugin.details?.readme ?? 'No plugin help or readme markdown file was found',
}}
/>
);
}
@ -31,35 +33,36 @@ export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: Plugi
if (tab?.label === PluginTabLabels.VERSIONS) {
return (
<div className={styles.container}>
<VersionList versions={remoteVersions ?? []} />
<VersionList versions={plugin.details?.versions} />
</div>
);
}
if (tab?.label === PluginTabLabels.CONFIG && plugin?.angularConfigCtrl) {
if (tab?.label === PluginTabLabels.CONFIG && pluginConfig?.angularConfigCtrl) {
return (
<div className={styles.container}>
<AppConfigCtrlWrapper app={plugin as AppPlugin} />
<AppConfigCtrlWrapper app={pluginConfig as AppPlugin} />
</div>
);
}
if (plugin?.configPages) {
for (const configPage of plugin.configPages) {
if (pluginConfig?.configPages) {
for (const configPage of pluginConfig.configPages) {
if (tab?.label === configPage.title) {
return (
<div className={styles.container}>
<configPage.body plugin={plugin} query={{}} />
{/* TODO: we should pass the query params down */}
<configPage.body plugin={pluginConfig} query={{}} />
</div>
);
}
}
}
if (tab?.label === PluginTabLabels.DASHBOARDS && plugin) {
if (tab?.label === PluginTabLabels.DASHBOARDS && pluginConfig) {
return (
<div className={styles.container}>
<PluginDashboards plugin={plugin.meta} />
<PluginDashboards plugin={pluginConfig?.meta} />
</div>
);
}

View File

@ -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 (
<div className={styles.headerContainer}>
@ -58,7 +52,7 @@ export function PluginDetailsHeader({ pluginId, parentUrl, currentUrl }: Props):
<span>{plugin.orgName}</span>
{/* Links */}
{plugin.links.map((link: any) => (
{plugin.details?.links.map((link: any) => (
<a key={link.name} href={link.url}>
{link.name}
</a>
@ -76,19 +70,12 @@ export function PluginDetailsHeader({ pluginId, parentUrl, currentUrl }: Props):
{plugin.version && <span>{plugin.version}</span>}
{/* Signature information */}
<PluginDetailsHeaderSignature installedPlugin={pluginConfig} />
<PluginDetailsHeaderSignature plugin={plugin} />
</div>
<p>{plugin.description}</p>
<InstallControls
plugin={plugin}
isInflight={isInflight}
hasUpdate={hasUpdate}
isInstalled={isInstalled}
hasInstalledPanel={hasInstalledPanel}
dispatch={dispatch}
/>
<InstallControls plugin={plugin} />
</div>
</div>
);

View File

@ -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<PluginMeta<{}>>;
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 (
<div>
<a href="https://grafana.com/docs/grafana/latest/plugins/plugin-signatures/" target="_blank" rel="noreferrer">
<PluginSignatureBadge status={installedPlugin.meta.signature} />
<PluginSignatureBadge status={plugin.signature} />
</a>
{isSignatureValid && (
<PluginSignatureDetailsBadge
signatureType={installedPlugin.meta.signatureType}
signatureOrg={installedPlugin.meta.signatureOrg}
/>
<PluginSignatureDetailsBadge signatureType={plugin.signatureType} signatureOrg={plugin.signatureOrg} />
)}
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, CatalogPluginDetails> = {};
const pluginConfigCache: Record<string, GrafanaPlugin<PluginMeta<{}>>> = {};
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 };
};

View File

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

View File

@ -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<keyof PluginsByFilterType>).every((query) =>
typeof URLFilterHandlers[query] === 'function' ? URLFilterHandlers[query](plugin, queries[query]) : true
)
);
return {
isLoading: loading,
error,
plugins: filteredPlugins,
};
};

View File

@ -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: '',
},
{

View File

@ -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<string>) => {
history.push({ query: { sortBy: value.value } });
@ -60,7 +64,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
<Page.Contents>
<PluginPage>
<HorizontalGroup wrap>
<SearchField value={q} onSearch={onSearch} />
<SearchField value={query} onSearch={onSearch} />
<HorizontalGroup wrap className={styles.actionBar}>
<div>
<RadioButtonGroup
@ -110,7 +114,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
text="Loading results"
/>
) : (
<PluginList plugins={sortedPlugins} />
<PluginList plugins={plugins} />
)}
</div>
</PluginPage>
@ -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,
};

View File

@ -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(<PluginDetailsPage {...props} />);
const store = configureStore();
return render(
<Provider store={store}>
<PluginDetailsPage {...props} />
</Provider>
);
}
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> = {}): LocalPlugin {
category: '',
state: '',
signature: PluginSignatureStatus.valid,
signatureType: 'community',
signatureType: PluginSignatureType.core,
signatureOrg: 'Grafana Labs',
...plugin,
};

View File

@ -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<State>(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 (
<Page>
<Loader />
@ -33,38 +54,37 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
}
if (!plugin) {
return null;
return (
<Layout justify="center" align="center">
<Alert severity={AppNotificationSeverity.Warning} title="Plugin not found">
That plugin cannot be found. Please check the url is correct or <br />
go to the <a href={parentUrl}>plugin catalog</a>.
</Alert>
</Layout>
);
}
return (
<Page>
<PluginPage>
<PluginDetailsHeader currentUrl={match.url} parentUrl={parentUrl} pluginId={pluginId} />
<PluginDetailsHeader currentUrl={match.url} parentUrl={parentUrl} plugin={plugin} />
{/* Tab navigation */}
<TabsBar>
{tabs.map((tab: { label: string }, idx: number) => (
{tabs.map((tab: PluginDetailsTab, idx: number) => (
<Tab
key={tab.label}
label={tab.label}
active={idx === activeTab}
onChangeTab={() => dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: idx })}
active={idx === activeTabIndex}
onChangeTab={() => setActiveTab(idx)}
/>
))}
</TabsBar>
{/* Active tab */}
<TabContent className={styles.tabContent}>
{error && (
<Alert severity={AppNotificationSeverity.Error} title="Error Loading Plugin">
<>
Check the server startup logs for more information. <br />
If this plugin was loaded from git, make sure it was compiled.
</>
</Alert>
)}
<PluginDetailsSignature installedPlugin={pluginConfig} className={styles.signature} />
<PluginDetailsBody tab={tab} plugin={pluginConfig} remoteVersions={plugin.versions} readme={plugin.readme} />
<PluginDetailsSignature plugin={plugin} className={styles.signature} />
<PluginDetailsBody tab={tabs[activeTabIndex]} plugin={plugin} />
</TabContent>
</PluginPage>
</Page>

View File

@ -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<CatalogPlugin>;
} 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<CatalogPlugin>;
} 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<CatalogPlugin>;
} 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<remove once the "plugin_admin_enabled" feature flag is removed>
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<remove once the "plugin_admin_enabled" feature flag is removed>
export const loadPanelPlugin = (id: string): ThunkResult<Promise<PanelPlugin>> => {
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;
};
};

View File

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

View File

@ -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<CatalogPlugin>();
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<remove once the "plugin_admin_enabled" feature flag is removed>
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<remove once the "plugin_admin_enabled" feature flag is removed>
.addCase(`${STATE_PREFIX}/loadPanelPlugin/fulfilled`, (state, action: AnyAction) => {
state.panels[action.payload.meta!.id] = action.payload;
})
// Start loading panel dashboards (backward-compatibility)
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
.addCase(loadPluginDashboards.pending, (state, action) => {
state.isLoadingPluginDashboards = true;
state.dashboards = [];
})
// Load panel dashboards (backward-compatibility)
// TODO<remove once the "plugin_admin_enabled" feature flag is removed>
.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,
};
}),
});

View File

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

View File

@ -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<PluginMeta<{}>>;
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<PluginMeta<{}>> }
| {
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<remove `PluginsState &` when the "plugin_admin_enabled" feature flag is removed>
export type ReducerState = PluginsState & {
items: EntityState<CatalogPlugin>;
requests: Record<string, RequestInfo>;
};
// TODO<remove when the "plugin_admin_enabled" feature flag is removed>
export type PluginCatalogStoreState = StoreState & { plugins: ReducerState };

View File

@ -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<void> {
return async (dispatch) => {
@ -24,7 +29,7 @@ export function loadPluginsErrors(): ThunkResult<void> {
};
}
export function loadPluginDashboards(): ThunkResult<void> {
function loadPluginDashboardsOriginal(): ThunkResult<void> {
return async (dispatch, getStore) => {
dispatch(pluginDashboardsLoad());
const dataSourceType = getStore().dataSources.dataSource.type;
@ -33,7 +38,7 @@ export function loadPluginDashboards(): ThunkResult<void> {
};
}
export function loadPanelPlugin(pluginId: string): ThunkResult<Promise<PanelPlugin>> {
function loadPanelPluginOriginal(pluginId: string): ThunkResult<Promise<PanelPlugin>> {
return async (dispatch, getStore) => {
let plugin = getStore().plugins.panels[pluginId];
@ -49,3 +54,6 @@ export function loadPanelPlugin(pluginId: string): ThunkResult<Promise<PanelPlug
return plugin;
};
}
export const loadPluginDashboards = config.pluginAdminEnabled ? loadPluginDashboardsNew : loadPluginDashboardsOriginal;
export const loadPanelPlugin = config.pluginAdminEnabled ? loadPanelPluginNew : loadPanelPluginOriginal;

View File

@ -1,3 +1,4 @@
import { Reducer, AnyAction } from '@reduxjs/toolkit';
import { reducerTester } from '../../../../test/core/redux/reducerTester';
import { PluginsState } from '../../../types';
import {
@ -14,7 +15,7 @@ describe('pluginsReducer', () => {
describe('when pluginsLoaded is dispatched', () => {
it('then state should be correct', () => {
reducerTester<PluginsState>()
.givenReducer(pluginsReducer, { ...initialState })
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, { ...initialState })
.whenActionIsDispatched(
pluginsLoaded([
{
@ -48,7 +49,7 @@ describe('pluginsReducer', () => {
describe('when setPluginsSearchQuery is dispatched', () => {
it('then state should be correct', () => {
reducerTester<PluginsState>()
.givenReducer(pluginsReducer, { ...initialState })
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, { ...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<PluginsState>()
.givenReducer(pluginsReducer, {
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, {
...initialState,
dashboards: [
{
@ -92,7 +93,10 @@ describe('pluginsReducer', () => {
describe('when pluginDashboardsLoad is dispatched', () => {
it('then state should be correct', () => {
reducerTester<PluginsState>()
.givenReducer(pluginsReducer, { ...initialState, isLoadingPluginDashboards: true })
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, {
...initialState,
isLoadingPluginDashboards: true,
})
.whenActionIsDispatched(
pluginDashboardsLoaded([
{

View File

@ -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,