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 { getBackendSrv } from '@grafana/runtime';
|
||||||
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
|
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[]> {
|
async function getRemotePlugins(): Promise<RemotePlugin[]> {
|
||||||
const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`);
|
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> {
|
async function getPlugin(slug: string): Promise<PluginDetails> {
|
||||||
const installed = await getInstalledPlugins();
|
const installed = await getLocalPlugins();
|
||||||
|
|
||||||
const localPlugin = installed?.find((plugin: LocalPlugin) => {
|
const localPlugin = installed?.find((plugin: LocalPlugin) => {
|
||||||
return plugin.id === slug;
|
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 {
|
return {
|
||||||
remote: remote,
|
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 {
|
try {
|
||||||
return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${slug}`);
|
return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// this might be a plugin that doesn't exist on gcom.
|
// this might be a plugin that doesn't exist on gcom.
|
||||||
error.isHandled = !!local;
|
error.isHandled = isInstalled;
|
||||||
return;
|
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 });
|
const installed = await getBackendSrv().get(`${API_ROOT}`, { embedded: 0 });
|
||||||
return installed;
|
return installed;
|
||||||
}
|
}
|
||||||
@ -52,20 +79,20 @@ async function getOrg(slug: string): Promise<Org> {
|
|||||||
return { ...org, avatarUrl: `${GRAFANA_API_ROOT}/orgs/${slug}/avatar` };
|
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`, {
|
return await getBackendSrv().post(`${API_ROOT}/${id}/install`, {
|
||||||
version,
|
version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uninstallPlugin(id: string) {
|
export async function uninstallPlugin(id: string) {
|
||||||
return await getBackendSrv().post(`${API_ROOT}/${id}/uninstall`);
|
return await getBackendSrv().post(`${API_ROOT}/${id}/uninstall`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
getRemotePlugins,
|
getRemotePlugins,
|
||||||
getPlugin,
|
getPlugin,
|
||||||
getInstalledPlugins,
|
getInstalledPlugins: getLocalPlugins,
|
||||||
getOrg,
|
getOrg,
|
||||||
installPlugin,
|
installPlugin,
|
||||||
uninstallPlugin,
|
uninstallPlugin,
|
||||||
|
@ -1,68 +1,56 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AppEvents } from '@grafana/data';
|
import { useMountedState } from 'react-use';
|
||||||
|
import { AppEvents, PluginType } from '@grafana/data';
|
||||||
import { Button, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
import { Button, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
||||||
import appEvents from 'app/core/app_events';
|
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 { getStyles } from './index';
|
||||||
|
import { useInstallStatus, useUninstallStatus, useInstall, useUninstall } from '../../state/hooks';
|
||||||
|
|
||||||
type InstallControlsButtonProps = {
|
type InstallControlsButtonProps = {
|
||||||
isInProgress: boolean;
|
|
||||||
hasInstalledPanel: boolean;
|
|
||||||
dispatch: React.Dispatch<any>;
|
|
||||||
plugin: CatalogPlugin;
|
plugin: CatalogPlugin;
|
||||||
pluginStatus: PluginStatus;
|
pluginStatus: PluginStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function InstallControlsButton({
|
export function InstallControlsButton({ plugin, pluginStatus }: InstallControlsButtonProps) {
|
||||||
isInProgress,
|
const { isInstalling, error: errorInstalling } = useInstallStatus();
|
||||||
dispatch,
|
const { isUninstalling, error: errorUninstalling } = useUninstallStatus();
|
||||||
plugin,
|
const install = useInstall();
|
||||||
pluginStatus,
|
const uninstall = useUninstall();
|
||||||
hasInstalledPanel,
|
const [hasInstalledPanel, setHasInstalledPanel] = useState(false);
|
||||||
}: InstallControlsButtonProps) {
|
|
||||||
const uninstallBtnText = isInProgress ? 'Uninstalling' : 'Uninstall';
|
|
||||||
const updateBtnText = isInProgress ? 'Updating' : 'Update';
|
|
||||||
const installBtnText = isInProgress ? 'Installing' : 'Install';
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const uninstallBtnText = isUninstalling ? 'Uninstalling' : 'Uninstall';
|
||||||
|
const isMounted = useMountedState();
|
||||||
|
|
||||||
const onInstall = async () => {
|
const onInstall = async () => {
|
||||||
dispatch({ type: ActionTypes.INFLIGHT });
|
await install(plugin.id, plugin.version);
|
||||||
try {
|
if (!errorInstalling) {
|
||||||
await api.installPlugin(plugin.id, plugin.version);
|
if (isMounted() && plugin.type === PluginType.panel) {
|
||||||
|
setHasInstalledPanel(true);
|
||||||
|
}
|
||||||
appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]);
|
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 () => {
|
const onUninstall = async () => {
|
||||||
dispatch({ type: ActionTypes.INFLIGHT });
|
await uninstall(plugin.id);
|
||||||
try {
|
if (!errorUninstalling) {
|
||||||
await api.uninstallPlugin(plugin.id);
|
|
||||||
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]);
|
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]);
|
||||||
dispatch({ type: ActionTypes.UNINSTALLED });
|
|
||||||
} catch (error) {
|
|
||||||
dispatch({ type: ActionTypes.ERROR, payload: error });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUpdate = async () => {
|
const onUpdate = async () => {
|
||||||
dispatch({ type: ActionTypes.INFLIGHT });
|
await install(plugin.id, plugin.version, true);
|
||||||
try {
|
if (!errorInstalling) {
|
||||||
await api.installPlugin(plugin.id, plugin.version);
|
|
||||||
appEvents.emit(AppEvents.alertSuccess, [`Updated ${plugin.name}`]);
|
appEvents.emit(AppEvents.alertSuccess, [`Updated ${plugin.name}`]);
|
||||||
dispatch({ type: ActionTypes.UPDATED });
|
|
||||||
} catch (error) {
|
|
||||||
dispatch({ type: ActionTypes.ERROR, payload: error });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (pluginStatus === PluginStatus.UNINSTALL) {
|
if (pluginStatus === PluginStatus.UNINSTALL) {
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup height="auto">
|
<HorizontalGroup height="auto">
|
||||||
<Button variant="destructive" disabled={isInProgress} onClick={onUninstall}>
|
<Button variant="destructive" disabled={isUninstalling} onClick={onUninstall}>
|
||||||
{uninstallBtnText}
|
{uninstallBtnText}
|
||||||
</Button>
|
</Button>
|
||||||
{hasInstalledPanel && (
|
{hasInstalledPanel && (
|
||||||
@ -75,10 +63,10 @@ export function InstallControlsButton({
|
|||||||
if (pluginStatus === PluginStatus.UPDATE) {
|
if (pluginStatus === PluginStatus.UPDATE) {
|
||||||
return (
|
return (
|
||||||
<HorizontalGroup height="auto">
|
<HorizontalGroup height="auto">
|
||||||
<Button disabled={isInProgress} onClick={onUpdate}>
|
<Button disabled={isInstalling} onClick={onUpdate}>
|
||||||
{updateBtnText}
|
{isInstalling ? 'Updating' : 'Update'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" disabled={isInProgress} onClick={onUninstall}>
|
<Button variant="destructive" disabled={isUninstalling} onClick={onUninstall}>
|
||||||
{uninstallBtnText}
|
{uninstallBtnText}
|
||||||
</Button>
|
</Button>
|
||||||
</HorizontalGroup>
|
</HorizontalGroup>
|
||||||
@ -86,8 +74,8 @@ export function InstallControlsButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button disabled={isInProgress} onClick={onInstall}>
|
<Button disabled={isInstalling} onClick={onInstall}>
|
||||||
{installBtnText}
|
{isInstalling ? 'Installing' : 'Install'}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,32 +6,31 @@ import { config } from '@grafana/runtime';
|
|||||||
import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
|
import { HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
|
||||||
import { CatalogPluginDetails, PluginStatus } from '../../types';
|
import { CatalogPlugin, PluginStatus } from '../../types';
|
||||||
import { isGrafanaAdmin, getExternalManageLink } from '../../helpers';
|
import { isGrafanaAdmin, getExternalManageLink } from '../../helpers';
|
||||||
import { ExternallyManagedButton } from './ExternallyManagedButton';
|
import { ExternallyManagedButton } from './ExternallyManagedButton';
|
||||||
import { InstallControlsButton } from './InstallControlsButton';
|
import { InstallControlsButton } from './InstallControlsButton';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
plugin: CatalogPluginDetails;
|
plugin: CatalogPlugin;
|
||||||
isInflight: boolean;
|
|
||||||
hasUpdate: boolean;
|
|
||||||
hasInstalledPanel: boolean;
|
|
||||||
isInstalled: boolean;
|
|
||||||
dispatch: React.Dispatch<any>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InstallControls = ({ plugin, isInflight, hasUpdate, isInstalled, hasInstalledPanel, dispatch }: Props) => {
|
export const InstallControls = ({ plugin }: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
|
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
|
||||||
const hasPermission = isGrafanaAdmin();
|
const hasPermission = isGrafanaAdmin();
|
||||||
const grafanaDependency = plugin.grafanaDependency;
|
const grafanaDependency = plugin.details?.grafanaDependency;
|
||||||
const unsupportedGrafanaVersion = grafanaDependency
|
const unsupportedGrafanaVersion = grafanaDependency
|
||||||
? !satisfies(config.buildInfo.version, grafanaDependency, {
|
? !satisfies(config.buildInfo.version, grafanaDependency, {
|
||||||
// needed for when running against master
|
// needed for when running against main
|
||||||
includePrerelease: true,
|
includePrerelease: true,
|
||||||
})
|
})
|
||||||
: false;
|
: 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) {
|
if (plugin.isCore) {
|
||||||
return null;
|
return null;
|
||||||
@ -79,15 +78,7 @@ export const InstallControls = ({ plugin, isInflight, hasUpdate, isInstalled, ha
|
|||||||
return <ExternallyManagedButton pluginId={plugin.id} pluginStatus={pluginStatus} />;
|
return <ExternallyManagedButton pluginId={plugin.id} pluginStatus={pluginStatus} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <InstallControlsButton plugin={plugin} pluginStatus={pluginStatus} />;
|
||||||
<InstallControlsButton
|
|
||||||
isInProgress={isInflight}
|
|
||||||
dispatch={dispatch}
|
|
||||||
plugin={plugin}
|
|
||||||
pluginStatus={pluginStatus}
|
|
||||||
hasInstalledPanel={hasInstalledPanel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStyles = (theme: GrafanaTheme2) => {
|
export const getStyles = (theme: GrafanaTheme2) => {
|
||||||
|
@ -1,29 +1,31 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { css, cx } from '@emotion/css';
|
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 { useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { PluginTabLabels } from '../types';
|
import { CatalogPlugin, PluginTabLabels } from '../types';
|
||||||
import { VersionList } from '../components/VersionList';
|
import { VersionList } from '../components/VersionList';
|
||||||
|
import { usePluginConfig } from '../hooks/usePluginConfig';
|
||||||
import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper';
|
import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper';
|
||||||
import { PluginDashboards } from '../../PluginDashboards';
|
import { PluginDashboards } from '../../PluginDashboards';
|
||||||
|
|
||||||
type PluginDetailsBodyProps = {
|
type Props = {
|
||||||
tab: { label: string };
|
tab: { label: string };
|
||||||
plugin: GrafanaPlugin<PluginMeta<{}>> | undefined;
|
plugin: CatalogPlugin;
|
||||||
remoteVersions: Array<{ version: string; createdAt: string }>;
|
|
||||||
readme: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 styles = useStyles2(getStyles);
|
||||||
|
const { value: pluginConfig } = usePluginConfig(plugin);
|
||||||
|
|
||||||
if (tab?.label === PluginTabLabels.OVERVIEW) {
|
if (tab?.label === PluginTabLabels.OVERVIEW) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(styles.readme, styles.container)}
|
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) {
|
if (tab?.label === PluginTabLabels.VERSIONS) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<VersionList versions={remoteVersions ?? []} />
|
<VersionList versions={plugin.details?.versions} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab?.label === PluginTabLabels.CONFIG && plugin?.angularConfigCtrl) {
|
if (tab?.label === PluginTabLabels.CONFIG && pluginConfig?.angularConfigCtrl) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<AppConfigCtrlWrapper app={plugin as AppPlugin} />
|
<AppConfigCtrlWrapper app={pluginConfig as AppPlugin} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plugin?.configPages) {
|
if (pluginConfig?.configPages) {
|
||||||
for (const configPage of plugin.configPages) {
|
for (const configPage of pluginConfig.configPages) {
|
||||||
if (tab?.label === configPage.title) {
|
if (tab?.label === configPage.title) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<configPage.body plugin={plugin} query={{}} />
|
{/* TODO: we should pass the query params down */}
|
||||||
|
<configPage.body plugin={pluginConfig} query={{}} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tab?.label === PluginTabLabels.DASHBOARDS && plugin) {
|
if (tab?.label === PluginTabLabels.DASHBOARDS && pluginConfig) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<PluginDashboards plugin={plugin.meta} />
|
<PluginDashboards plugin={pluginConfig?.meta} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,24 +4,18 @@ import { GrafanaTheme2 } from '@grafana/data';
|
|||||||
import { useStyles2, Icon } from '@grafana/ui';
|
import { useStyles2, Icon } from '@grafana/ui';
|
||||||
|
|
||||||
import { InstallControls } from './InstallControls';
|
import { InstallControls } from './InstallControls';
|
||||||
import { usePluginDetails } from '../hooks/usePluginDetails';
|
|
||||||
import { PluginDetailsHeaderSignature } from './PluginDetailsHeaderSignature';
|
import { PluginDetailsHeaderSignature } from './PluginDetailsHeaderSignature';
|
||||||
import { PluginLogo } from './PluginLogo';
|
import { PluginLogo } from './PluginLogo';
|
||||||
|
import { CatalogPlugin } from '../types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
parentUrl: string;
|
|
||||||
currentUrl: 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 styles = useStyles2(getStyles);
|
||||||
const { state, dispatch } = usePluginDetails(pluginId!);
|
|
||||||
const { plugin, pluginConfig, isInflight, hasUpdate, isInstalled, hasInstalledPanel } = state;
|
|
||||||
|
|
||||||
if (!plugin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.headerContainer}>
|
<div className={styles.headerContainer}>
|
||||||
@ -58,7 +52,7 @@ export function PluginDetailsHeader({ pluginId, parentUrl, currentUrl }: Props):
|
|||||||
<span>{plugin.orgName}</span>
|
<span>{plugin.orgName}</span>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
{plugin.links.map((link: any) => (
|
{plugin.details?.links.map((link: any) => (
|
||||||
<a key={link.name} href={link.url}>
|
<a key={link.name} href={link.url}>
|
||||||
{link.name}
|
{link.name}
|
||||||
</a>
|
</a>
|
||||||
@ -76,19 +70,12 @@ export function PluginDetailsHeader({ pluginId, parentUrl, currentUrl }: Props):
|
|||||||
{plugin.version && <span>{plugin.version}</span>}
|
{plugin.version && <span>{plugin.version}</span>}
|
||||||
|
|
||||||
{/* Signature information */}
|
{/* Signature information */}
|
||||||
<PluginDetailsHeaderSignature installedPlugin={pluginConfig} />
|
<PluginDetailsHeaderSignature plugin={plugin} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>{plugin.description}</p>
|
<p>{plugin.description}</p>
|
||||||
|
|
||||||
<InstallControls
|
<InstallControls plugin={plugin} />
|
||||||
plugin={plugin}
|
|
||||||
isInflight={isInflight}
|
|
||||||
hasUpdate={hasUpdate}
|
|
||||||
isInstalled={isInstalled}
|
|
||||||
hasInstalledPanel={hasInstalledPanel}
|
|
||||||
dispatch={dispatch}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,31 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { GrafanaPlugin, PluginMeta, PluginSignatureStatus } from '@grafana/data';
|
import { PluginSignatureStatus } from '@grafana/data';
|
||||||
import { PluginSignatureBadge } from '@grafana/ui';
|
import { PluginSignatureBadge } from '@grafana/ui';
|
||||||
import { PluginSignatureDetailsBadge } from './PluginSignatureDetailsBadge';
|
import { PluginSignatureDetailsBadge } from './PluginSignatureDetailsBadge';
|
||||||
|
import { CatalogPlugin } from '../types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
installedPlugin?: GrafanaPlugin<PluginMeta<{}>>;
|
plugin: CatalogPlugin;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Designed to show plugin signature information in the header on the plugin's details page
|
// Designed to show plugin signature information in the header on the plugin's details page
|
||||||
export function PluginDetailsHeaderSignature({ installedPlugin }: Props): React.ReactElement | null {
|
export function PluginDetailsHeaderSignature({ plugin }: Props): React.ReactElement {
|
||||||
if (!installedPlugin) {
|
const isSignatureValid = plugin.signature === PluginSignatureStatus.valid;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSignatureValid = installedPlugin.meta.signature === PluginSignatureStatus.valid;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<a href="https://grafana.com/docs/grafana/latest/plugins/plugin-signatures/" target="_blank" rel="noreferrer">
|
<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>
|
</a>
|
||||||
|
|
||||||
{isSignatureValid && (
|
{isSignatureValid && (
|
||||||
<PluginSignatureDetailsBadge
|
<PluginSignatureDetailsBadge signatureType={plugin.signatureType} signatureOrg={plugin.signatureOrg} />
|
||||||
signatureType={installedPlugin.meta.signatureType}
|
|
||||||
signatureOrg={installedPlugin.meta.signatureOrg}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,24 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { GrafanaPlugin, PluginMeta, PluginSignatureStatus } from '@grafana/data';
|
import { PluginSignatureStatus } from '@grafana/data';
|
||||||
import { Alert } from '@grafana/ui';
|
import { Alert } from '@grafana/ui';
|
||||||
|
import { CatalogPlugin } from '../types';
|
||||||
|
|
||||||
type PluginDetailsSignatureProps = {
|
type Props = {
|
||||||
className?: string;
|
className?: string;
|
||||||
installedPlugin?: GrafanaPlugin<PluginMeta<{}>>;
|
plugin: CatalogPlugin;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Designed to show signature information inside the active tab on the plugin's details page
|
// Designed to show signature information inside the active tab on the plugin's details page
|
||||||
export function PluginDetailsSignature({
|
export function PluginDetailsSignature({ className, plugin }: Props): React.ReactElement | null {
|
||||||
className,
|
const isSignatureValid = plugin.signature === PluginSignatureStatus.valid;
|
||||||
installedPlugin,
|
const isCore = plugin.signature === PluginSignatureStatus.internal;
|
||||||
}: PluginDetailsSignatureProps): React.ReactElement | null {
|
|
||||||
if (!installedPlugin) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSignatureValid = installedPlugin.meta.signature === PluginSignatureStatus.valid;
|
|
||||||
const isCore = installedPlugin.meta.signature === PluginSignatureStatus.internal;
|
|
||||||
|
|
||||||
// The basic information is already available in the header
|
// The basic information is already available in the header
|
||||||
if (isSignatureValid || isCore) {
|
if (isSignatureValid || isCore) {
|
||||||
|
@ -6,10 +6,10 @@ import { useStyles2 } from '@grafana/ui';
|
|||||||
import { Version } from '../types';
|
import { Version } from '../types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
versions: Version[];
|
versions?: Version[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VersionList = ({ versions }: Props) => {
|
export const VersionList = ({ versions = [] }: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
if (versions.length === 0) {
|
if (versions.length === 0) {
|
||||||
|
@ -1,2 +1,5 @@
|
|||||||
export const API_ROOT = '/api/plugins';
|
export const API_ROOT = '/api/plugins';
|
||||||
export const GRAFANA_API_ROOT = '/api/gnet';
|
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 { config } from '@grafana/runtime';
|
||||||
import { gt } from 'semver';
|
import { gt } from 'semver';
|
||||||
import { PluginSignatureStatus } from '@grafana/data';
|
import { PluginSignatureStatus, dateTimeParse } from '@grafana/data';
|
||||||
import { CatalogPlugin, CatalogPluginDetails, LocalPlugin, RemotePlugin, Version, PluginFilter } from './types';
|
import { CatalogPlugin, LocalPlugin, RemotePlugin } from './types';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
|
|
||||||
export function isGrafanaAdmin(): boolean {
|
export function isGrafanaAdmin(): boolean {
|
||||||
@ -12,6 +12,40 @@ export function isOrgAdmin() {
|
|||||||
return contextSrv.hasRole('Admin');
|
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 {
|
export function mapRemoteToCatalog(plugin: RemotePlugin): CatalogPlugin {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
@ -65,7 +99,10 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
|
|||||||
signature,
|
signature,
|
||||||
dev,
|
dev,
|
||||||
type,
|
type,
|
||||||
|
signatureOrg,
|
||||||
|
signatureType,
|
||||||
} = plugin;
|
} = plugin;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description,
|
description,
|
||||||
downloads: 0,
|
downloads: 0,
|
||||||
@ -76,6 +113,8 @@ export function mapLocalToCatalog(plugin: LocalPlugin): CatalogPlugin {
|
|||||||
popularity: 0,
|
popularity: 0,
|
||||||
publishedAt: '',
|
publishedAt: '',
|
||||||
signature,
|
signature,
|
||||||
|
signatureOrg,
|
||||||
|
signatureType,
|
||||||
updatedAt: updated,
|
updatedAt: updated,
|
||||||
version,
|
version,
|
||||||
hasUpdate: false,
|
hasUpdate: false,
|
||||||
@ -125,46 +164,37 @@ export function mapToCatalogPlugin(local?: LocalPlugin, remote?: RemotePlugin):
|
|||||||
publishedAt: remote?.createdAt || '',
|
publishedAt: remote?.createdAt || '',
|
||||||
type: remote?.typeCode || local?.type,
|
type: remote?.typeCode || local?.type,
|
||||||
signature: local?.signature || (hasRemoteSignature ? PluginSignatureStatus.valid : PluginSignatureStatus.missing),
|
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 || '',
|
updatedAt: remote?.updatedAt || local?.info.updated || '',
|
||||||
version,
|
version,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCatalogPluginDetails(
|
export const getExternalManageLink = (pluginId: string) => `https://grafana.com/grafana/plugins/${pluginId}`;
|
||||||
local: LocalPlugin | undefined,
|
|
||||||
remote: RemotePlugin | undefined,
|
|
||||||
pluginVersions: Version[] = []
|
|
||||||
): CatalogPluginDetails {
|
|
||||||
const plugin = mapToCatalogPlugin(local, remote);
|
|
||||||
|
|
||||||
return {
|
export enum Sorters {
|
||||||
...plugin,
|
nameAsc = 'nameAsc',
|
||||||
grafanaDependency: remote?.json?.dependencies?.grafanaDependency || '',
|
nameDesc = 'nameDesc',
|
||||||
links: remote?.json?.info.links || local?.info.links || [],
|
updated = 'updated',
|
||||||
readme: remote?.readme || 'No plugin help or readme markdown file was found',
|
published = 'published',
|
||||||
versions: pluginVersions,
|
downloads = 'downloads',
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isInstalled: PluginFilter = (plugin, query) =>
|
export const sortPlugins = (plugins: CatalogPlugin[], sortBy: Sorters) => {
|
||||||
query === 'installed' ? plugin.isInstalled : !plugin.isCore;
|
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;
|
if (sorters[sortBy]) {
|
||||||
|
return plugins.sort(sorters[sortBy]);
|
||||||
export const matchesKeyword: PluginFilter = (plugin, query) => {
|
|
||||||
if (!query) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const fields: String[] = [];
|
|
||||||
if (plugin.name) {
|
|
||||||
fields.push(plugin.name.toLowerCase());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plugin.orgName) {
|
return plugins;
|
||||||
fields.push(plugin.orgName.toLowerCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
return fields.some((f) => f.includes(query.toLowerCase()));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 { useMemo } from 'react';
|
||||||
import { useAsync } from 'react-use';
|
import { useAsync } from 'react-use';
|
||||||
import { CatalogPlugin, CatalogPluginsState, PluginsByFilterType, FilteredPluginsState } from '../types';
|
import { CatalogPlugin, CatalogPluginsState } from '../types';
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import {
|
import { mapLocalToCatalog, mapRemoteToCatalog, mapToCatalogPlugin } from '../helpers';
|
||||||
mapLocalToCatalog,
|
|
||||||
mapRemoteToCatalog,
|
|
||||||
mapToCatalogPlugin,
|
|
||||||
isInstalled,
|
|
||||||
isType,
|
|
||||||
matchesKeyword,
|
|
||||||
} from '../helpers';
|
|
||||||
|
|
||||||
export function usePlugins(): CatalogPluginsState {
|
export function usePlugins(): CatalogPluginsState {
|
||||||
const { loading, value, error } = useAsync(async () => {
|
const { loading, value, error } = useAsync(async () => {
|
||||||
@ -54,25 +47,3 @@ export function usePlugins(): CatalogPluginsState {
|
|||||||
plugins,
|
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 { LocalPlugin, RemotePlugin, PluginAdminRoutes } from '../types';
|
||||||
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
|
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
|
||||||
|
|
||||||
jest.mock('@grafana/runtime', () => ({
|
jest.mock('@grafana/runtime', () => {
|
||||||
...(jest.requireActual('@grafana/runtime') as object),
|
const original = jest.requireActual('@grafana/runtime');
|
||||||
getBackendSrv: () => ({
|
return {
|
||||||
get: (path: string) => {
|
...original,
|
||||||
switch (path) {
|
getBackendSrv: () => ({
|
||||||
case `${GRAFANA_API_ROOT}/plugins`:
|
get: (path: string) => {
|
||||||
return Promise.resolve({ items: remote });
|
switch (path) {
|
||||||
case API_ROOT:
|
case `${GRAFANA_API_ROOT}/plugins`:
|
||||||
return Promise.resolve(installed);
|
return Promise.resolve({ items: remote });
|
||||||
default:
|
case API_ROOT:
|
||||||
return Promise.reject();
|
return Promise.resolve(installed);
|
||||||
}
|
default:
|
||||||
|
return Promise.reject();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
config: {
|
||||||
|
...original.config,
|
||||||
|
pluginAdminEnabled: true,
|
||||||
},
|
},
|
||||||
}),
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
function setup(path = '/plugins'): RenderResult {
|
function setup(path = '/plugins'): RenderResult {
|
||||||
const store = configureStore();
|
const store = configureStore();
|
||||||
@ -247,7 +254,7 @@ const installed: LocalPlugin[] = [
|
|||||||
category: '',
|
category: '',
|
||||||
state: 'alpha',
|
state: 'alpha',
|
||||||
signature: PluginSignatureStatus.internal,
|
signature: PluginSignatureStatus.internal,
|
||||||
signatureType: '',
|
signatureType: PluginSignatureType.core,
|
||||||
signatureOrg: '',
|
signatureOrg: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -284,7 +291,7 @@ const installed: LocalPlugin[] = [
|
|||||||
category: '',
|
category: '',
|
||||||
state: '',
|
state: '',
|
||||||
signature: PluginSignatureStatus.missing,
|
signature: PluginSignatureStatus.missing,
|
||||||
signatureType: '',
|
signatureType: PluginSignatureType.core,
|
||||||
signatureOrg: '',
|
signatureOrg: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { ReactElement } from 'react';
|
import React, { ReactElement } from 'react';
|
||||||
import { css } from '@emotion/css';
|
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 { LoadingPlaceholder, Select, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { locationSearchToObject } from '@grafana/runtime';
|
import { locationSearchToObject } from '@grafana/runtime';
|
||||||
@ -8,30 +8,34 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
|||||||
import { PluginList } from '../components/PluginList';
|
import { PluginList } from '../components/PluginList';
|
||||||
import { SearchField } from '../components/SearchField';
|
import { SearchField } from '../components/SearchField';
|
||||||
import { useHistory } from '../hooks/useHistory';
|
import { useHistory } from '../hooks/useHistory';
|
||||||
import { CatalogPlugin, PluginAdminRoutes } from '../types';
|
import { PluginAdminRoutes } from '../types';
|
||||||
import { Page as PluginPage } from '../components/Page';
|
import { Page as PluginPage } from '../components/Page';
|
||||||
import { HorizontalGroup } from '../components/HorizontalGroup';
|
import { HorizontalGroup } from '../components/HorizontalGroup';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { usePluginsByFilter } from '../hooks/usePlugins';
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { StoreState } from 'app/types/store';
|
import { StoreState } from 'app/types/store';
|
||||||
import { getNavModel } from 'app/core/selectors/navModel';
|
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 {
|
export default function Browse({ route }: GrafanaRouteComponentProps): ReactElement | null {
|
||||||
|
useGetAll();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const query = locationSearchToObject(location.search);
|
const locationSearch = locationSearchToObject(location.search);
|
||||||
const navModelId = getNavModelId(route.routeName);
|
const navModelId = getNavModelId(route.routeName);
|
||||||
const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, navModelId));
|
const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, navModelId));
|
||||||
const styles = useStyles2(getStyles);
|
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 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>) => {
|
const onSortByChange = (value: SelectableValue<string>) => {
|
||||||
history.push({ query: { sortBy: value.value } });
|
history.push({ query: { sortBy: value.value } });
|
||||||
@ -60,7 +64,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
|||||||
<Page.Contents>
|
<Page.Contents>
|
||||||
<PluginPage>
|
<PluginPage>
|
||||||
<HorizontalGroup wrap>
|
<HorizontalGroup wrap>
|
||||||
<SearchField value={q} onSearch={onSearch} />
|
<SearchField value={query} onSearch={onSearch} />
|
||||||
<HorizontalGroup wrap className={styles.actionBar}>
|
<HorizontalGroup wrap className={styles.actionBar}>
|
||||||
<div>
|
<div>
|
||||||
<RadioButtonGroup
|
<RadioButtonGroup
|
||||||
@ -110,7 +114,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
|||||||
text="Loading results"
|
text="Loading results"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PluginList plugins={sortedPlugins} />
|
<PluginList plugins={plugins} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PluginPage>
|
</PluginPage>
|
||||||
@ -139,13 +143,3 @@ const getNavModelId = (routeName?: string) => {
|
|||||||
|
|
||||||
return 'plugins';
|
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 React from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
import { render, RenderResult, waitFor } from '@testing-library/react';
|
import { render, RenderResult, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
|
import { PluginSignatureStatus, PluginSignatureType, PluginType } from '@grafana/data';
|
||||||
|
import { configureStore } from 'app/store/configureStore';
|
||||||
import PluginDetailsPage from './PluginDetails';
|
import PluginDetailsPage from './PluginDetails';
|
||||||
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
|
import { API_ROOT, GRAFANA_API_ROOT } from '../constants';
|
||||||
import { LocalPlugin, RemotePlugin } from '../types';
|
import { LocalPlugin, RemotePlugin } from '../types';
|
||||||
@ -45,6 +47,15 @@ jest.mock('@grafana/runtime', () => {
|
|||||||
return Promise.resolve(remotePlugin({ slug: 'installed' }));
|
return Promise.resolve(remotePlugin({ slug: 'installed' }));
|
||||||
case `${GRAFANA_API_ROOT}/plugins/enterprise`:
|
case `${GRAFANA_API_ROOT}/plugins/enterprise`:
|
||||||
return Promise.resolve(remotePlugin({ status: '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:
|
default:
|
||||||
return Promise.reject();
|
return Promise.reject();
|
||||||
}
|
}
|
||||||
@ -63,13 +74,19 @@ jest.mock('@grafana/runtime', () => {
|
|||||||
...original.config.buildInfo,
|
...original.config.buildInfo,
|
||||||
version: 'v7.5.0',
|
version: 'v7.5.0',
|
||||||
},
|
},
|
||||||
|
pluginAdminEnabled: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function setup(pluginId: string): RenderResult {
|
function setup(pluginId: string): RenderResult {
|
||||||
const props = getRouteComponentProps({ match: { params: { pluginId }, isExact: true, url: '', path: '' } });
|
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', () => {
|
describe('Plugin details page', () => {
|
||||||
@ -89,6 +106,7 @@ describe('Plugin details page', () => {
|
|||||||
|
|
||||||
it('should display an overview (plugin readme) by default', async () => {
|
it('should display an overview (plugin readme) by default', async () => {
|
||||||
const { queryByText } = setup('not-installed');
|
const { queryByText } = setup('not-installed');
|
||||||
|
|
||||||
await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument());
|
await waitFor(() => expect(queryByText(/licensed under the apache 2.0 license/i)).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -262,7 +280,7 @@ function localPlugin(plugin: Partial<LocalPlugin> = {}): LocalPlugin {
|
|||||||
category: '',
|
category: '',
|
||||||
state: '',
|
state: '',
|
||||||
signature: PluginSignatureStatus.valid,
|
signature: PluginSignatureStatus.valid,
|
||||||
signatureType: 'community',
|
signatureType: PluginSignatureType.core,
|
||||||
signatureOrg: 'Grafana Labs',
|
signatureOrg: 'Grafana Labs',
|
||||||
...plugin,
|
...plugin,
|
||||||
};
|
};
|
||||||
|
@ -1,30 +1,51 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { useStyles2, TabsBar, TabContent, Tab, Alert } from '@grafana/ui';
|
import { useStyles2, TabsBar, TabContent, Tab, Alert } from '@grafana/ui';
|
||||||
|
import { Layout } from '@grafana/ui/src/components/Layout/Layout';
|
||||||
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 { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
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 { 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 {
|
type State = {
|
||||||
const { pluginId } = match.params;
|
tabs: PluginDetailsTab[];
|
||||||
const { state, dispatch } = usePluginDetails(pluginId!);
|
activeTabIndex: number;
|
||||||
const { loading, error, plugin, pluginConfig, tabs, activeTab } = state;
|
};
|
||||||
const tab = tabs[activeTab];
|
|
||||||
|
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 styles = useStyles2(getStyles);
|
||||||
|
const setActiveTab = useCallback((activeTabIndex: number) => setState({ ...state, activeTabIndex }), [state]);
|
||||||
const parentUrl = match.url.substring(0, match.url.lastIndexOf('/'));
|
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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<Loader />
|
<Loader />
|
||||||
@ -33,38 +54,37 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!plugin) {
|
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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PluginPage>
|
<PluginPage>
|
||||||
<PluginDetailsHeader currentUrl={match.url} parentUrl={parentUrl} pluginId={pluginId} />
|
<PluginDetailsHeader currentUrl={match.url} parentUrl={parentUrl} plugin={plugin} />
|
||||||
|
|
||||||
{/* Tab navigation */}
|
{/* Tab navigation */}
|
||||||
<TabsBar>
|
<TabsBar>
|
||||||
{tabs.map((tab: { label: string }, idx: number) => (
|
{tabs.map((tab: PluginDetailsTab, idx: number) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.label}
|
key={tab.label}
|
||||||
label={tab.label}
|
label={tab.label}
|
||||||
active={idx === activeTab}
|
active={idx === activeTabIndex}
|
||||||
onChangeTab={() => dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: idx })}
|
onChangeTab={() => setActiveTab(idx)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TabsBar>
|
</TabsBar>
|
||||||
|
|
||||||
{/* Active tab */}
|
{/* Active tab */}
|
||||||
<TabContent className={styles.tabContent}>
|
<TabContent className={styles.tabContent}>
|
||||||
{error && (
|
<PluginDetailsSignature plugin={plugin} className={styles.signature} />
|
||||||
<Alert severity={AppNotificationSeverity.Error} title="Error Loading Plugin">
|
<PluginDetailsBody tab={tabs[activeTabIndex]} plugin={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} />
|
|
||||||
</TabContent>
|
</TabContent>
|
||||||
</PluginPage>
|
</PluginPage>
|
||||||
</Page>
|
</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 type PluginTypeCode = 'app' | 'panel' | 'datasource';
|
||||||
|
|
||||||
export enum PluginAdminRoutes {
|
export enum PluginAdminRoutes {
|
||||||
@ -23,16 +26,19 @@ export interface CatalogPlugin {
|
|||||||
name: string;
|
name: string;
|
||||||
orgName: string;
|
orgName: string;
|
||||||
signature: PluginSignatureStatus;
|
signature: PluginSignatureStatus;
|
||||||
|
signatureType?: PluginSignatureType;
|
||||||
|
signatureOrg?: string;
|
||||||
popularity: number;
|
popularity: number;
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
type?: PluginType;
|
type?: PluginType;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
details?: CatalogPluginDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CatalogPluginDetails extends CatalogPlugin {
|
export interface CatalogPluginDetails {
|
||||||
readme: string;
|
readme?: string;
|
||||||
versions: Version[];
|
versions?: Version[];
|
||||||
links: Array<{
|
links: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@ -126,7 +132,7 @@ export type LocalPlugin = {
|
|||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
signature: PluginSignatureStatus;
|
signature: PluginSignatureStatus;
|
||||||
signatureOrg: string;
|
signatureOrg: string;
|
||||||
signatureType: string;
|
signatureType: PluginSignatureType;
|
||||||
state: string;
|
state: string;
|
||||||
type: PluginType;
|
type: PluginType;
|
||||||
};
|
};
|
||||||
@ -164,66 +170,12 @@ export interface Org {
|
|||||||
avatarUrl: string;
|
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 = {
|
export type CatalogPluginsState = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error?: Error;
|
error?: Error;
|
||||||
plugins: CatalogPlugin[];
|
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 {
|
export enum PluginStatus {
|
||||||
INSTALL = 'INSTALL',
|
INSTALL = 'INSTALL',
|
||||||
UNINSTALL = 'UNINSTALL',
|
UNINSTALL = 'UNINSTALL',
|
||||||
@ -236,3 +188,30 @@ export enum PluginTabLabels {
|
|||||||
CONFIG = 'Config',
|
CONFIG = 'Config',
|
||||||
DASHBOARDS = 'Dashboards',
|
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 { getBackendSrv } from '@grafana/runtime';
|
||||||
import { PanelPlugin } from '@grafana/data';
|
import { PanelPlugin } from '@grafana/data';
|
||||||
import { ThunkResult } from 'app/types';
|
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 {
|
import {
|
||||||
pluginDashboardsLoad,
|
pluginDashboardsLoad,
|
||||||
pluginDashboardsLoaded,
|
pluginDashboardsLoaded,
|
||||||
@ -8,7 +14,6 @@ import {
|
|||||||
panelPluginLoaded,
|
panelPluginLoaded,
|
||||||
pluginsErrorsLoaded,
|
pluginsErrorsLoaded,
|
||||||
} from './reducers';
|
} from './reducers';
|
||||||
import { importPanelPlugin } from 'app/features/plugins/plugin_loader';
|
|
||||||
|
|
||||||
export function loadPlugins(): ThunkResult<void> {
|
export function loadPlugins(): ThunkResult<void> {
|
||||||
return async (dispatch) => {
|
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) => {
|
return async (dispatch, getStore) => {
|
||||||
dispatch(pluginDashboardsLoad());
|
dispatch(pluginDashboardsLoad());
|
||||||
const dataSourceType = getStore().dataSources.dataSource.type;
|
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) => {
|
return async (dispatch, getStore) => {
|
||||||
let plugin = getStore().plugins.panels[pluginId];
|
let plugin = getStore().plugins.panels[pluginId];
|
||||||
|
|
||||||
@ -49,3 +54,6 @@ export function loadPanelPlugin(pluginId: string): ThunkResult<Promise<PanelPlug
|
|||||||
return plugin;
|
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 { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||||
import { PluginsState } from '../../../types';
|
import { PluginsState } from '../../../types';
|
||||||
import {
|
import {
|
||||||
@ -14,7 +15,7 @@ describe('pluginsReducer', () => {
|
|||||||
describe('when pluginsLoaded is dispatched', () => {
|
describe('when pluginsLoaded is dispatched', () => {
|
||||||
it('then state should be correct', () => {
|
it('then state should be correct', () => {
|
||||||
reducerTester<PluginsState>()
|
reducerTester<PluginsState>()
|
||||||
.givenReducer(pluginsReducer, { ...initialState })
|
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, { ...initialState })
|
||||||
.whenActionIsDispatched(
|
.whenActionIsDispatched(
|
||||||
pluginsLoaded([
|
pluginsLoaded([
|
||||||
{
|
{
|
||||||
@ -48,7 +49,7 @@ describe('pluginsReducer', () => {
|
|||||||
describe('when setPluginsSearchQuery is dispatched', () => {
|
describe('when setPluginsSearchQuery is dispatched', () => {
|
||||||
it('then state should be correct', () => {
|
it('then state should be correct', () => {
|
||||||
reducerTester<PluginsState>()
|
reducerTester<PluginsState>()
|
||||||
.givenReducer(pluginsReducer, { ...initialState })
|
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, { ...initialState })
|
||||||
.whenActionIsDispatched(setPluginsSearchQuery('A query'))
|
.whenActionIsDispatched(setPluginsSearchQuery('A query'))
|
||||||
.thenStateShouldEqual({
|
.thenStateShouldEqual({
|
||||||
...initialState,
|
...initialState,
|
||||||
@ -60,7 +61,7 @@ describe('pluginsReducer', () => {
|
|||||||
describe('when pluginDashboardsLoad is dispatched', () => {
|
describe('when pluginDashboardsLoad is dispatched', () => {
|
||||||
it('then state should be correct', () => {
|
it('then state should be correct', () => {
|
||||||
reducerTester<PluginsState>()
|
reducerTester<PluginsState>()
|
||||||
.givenReducer(pluginsReducer, {
|
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, {
|
||||||
...initialState,
|
...initialState,
|
||||||
dashboards: [
|
dashboards: [
|
||||||
{
|
{
|
||||||
@ -92,7 +93,10 @@ describe('pluginsReducer', () => {
|
|||||||
describe('when pluginDashboardsLoad is dispatched', () => {
|
describe('when pluginDashboardsLoad is dispatched', () => {
|
||||||
it('then state should be correct', () => {
|
it('then state should be correct', () => {
|
||||||
reducerTester<PluginsState>()
|
reducerTester<PluginsState>()
|
||||||
.givenReducer(pluginsReducer, { ...initialState, isLoadingPluginDashboards: true })
|
.givenReducer(pluginsReducer as Reducer<PluginsState, AnyAction>, {
|
||||||
|
...initialState,
|
||||||
|
isLoadingPluginDashboards: true,
|
||||||
|
})
|
||||||
.whenActionIsDispatched(
|
.whenActionIsDispatched(
|
||||||
pluginDashboardsLoaded([
|
pluginDashboardsLoaded([
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { PluginMeta, PanelPlugin, PluginError } from '@grafana/data';
|
import { PluginMeta, PanelPlugin, PluginError } from '@grafana/data';
|
||||||
import { PluginsState } from 'app/types';
|
import { PluginsState } from 'app/types';
|
||||||
|
import { config } from 'app/core/config';
|
||||||
|
import { reducer as pluginCatalogReducer } from '../admin/state/reducer';
|
||||||
import { PluginDashboard } from '../../../types/plugins';
|
import { PluginDashboard } from '../../../types/plugins';
|
||||||
|
|
||||||
export const initialState: PluginsState = {
|
export const initialState: PluginsState = {
|
||||||
@ -50,7 +52,7 @@ export const {
|
|||||||
panelPluginLoaded,
|
panelPluginLoaded,
|
||||||
} = pluginsSlice.actions;
|
} = pluginsSlice.actions;
|
||||||
|
|
||||||
export const pluginsReducer = pluginsSlice.reducer;
|
export const pluginsReducer = config.pluginAdminEnabled ? pluginCatalogReducer : pluginsSlice.reducer;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
plugins: pluginsReducer,
|
plugins: pluginsReducer,
|
||||||
|
Loading…
Reference in New Issue
Block a user