mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
e4ca6f2445
commit
1133e56006
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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';
|
||||
|
@ -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}`;
|
||||
|
16
public/app/features/plugins/admin/hooks/usePluginConfig.tsx
Normal file
16
public/app/features/plugins/admin/hooks/usePluginConfig.tsx
Normal 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]);
|
||||
};
|
@ -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 };
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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: '',
|
||||
},
|
||||
{
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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>
|
||||
|
93
public/app/features/plugins/admin/state/actions.ts
Normal file
93
public/app/features/plugins/admin/state/actions.ts
Normal 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;
|
||||
};
|
||||
};
|
106
public/app/features/plugins/admin/state/hooks.ts
Normal file
106
public/app/features/plugins/admin/state/hooks.ts
Normal 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
|
||||
};
|
89
public/app/features/plugins/admin/state/reducer.ts
Normal file
89
public/app/features/plugins/admin/state/reducer.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
62
public/app/features/plugins/admin/state/selectors.ts
Normal file
62
public/app/features/plugins/admin/state/selectors.ts
Normal 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);
|
@ -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 };
|
||||
|
@ -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;
|
||||
|
@ -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([
|
||||
{
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user