Plugins: Render app plugin config pages in catalog (#37063)

* feat(catalog): introduce PluginDetailsBody component and useLoadPluginConfig hook

* feat(catalog): use PluginDetailsBody and useLoadPluginConfig hook in PluginDetails

* feat(catalog): introduce usePluginDetails hook to handle PluginDetails state

* feat(catalog): wire usePluginDetails hook to PluginDetails and InstallControls

* refactor(catalog): fix typescript errors related to usePluginDetails hook

* chore(catalog): update types for PluginDetailsActions
This commit is contained in:
Jack Westbrook 2021-07-21 19:44:43 +02:00 committed by GitHub
parent 02b5a18da2
commit 1d37d675d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 384 additions and 123 deletions

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import { css, cx } from '@emotion/css';
import { satisfies } from 'semver';
@ -7,19 +7,20 @@ import { Button, HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { CatalogPluginDetails } from '../types';
import { CatalogPluginDetails, ActionTypes } from '../types';
import { api } from '../api';
import { isGrafanaAdmin } from '../helpers';
interface Props {
plugin: CatalogPluginDetails;
isInflight: boolean;
hasUpdate: boolean;
hasInstalledPanel: boolean;
isInstalled: boolean;
dispatch: React.Dispatch<any>;
}
export const InstallControls = ({ plugin }: Props) => {
const [loading, setLoading] = useState(false);
const [isInstalled, setIsInstalled] = useState(plugin.isInstalled || false);
const [shouldUpdate, setShouldUpdate] = useState(plugin.hasUpdate || false);
const [hasInstalledPanel, setHasInstalledPanel] = useState(false);
export const InstallControls = ({ plugin, isInflight, hasUpdate, isInstalled, hasInstalledPanel, dispatch }: Props) => {
const isExternallyManaged = config.pluginAdminExternalManageEnabled;
const externalManageLink = getExternalManageLink(plugin);
@ -30,39 +31,35 @@ export const InstallControls = ({ plugin }: Props) => {
}
const onInstall = async () => {
setLoading(true);
dispatch({ type: ActionTypes.INFLIGHT });
try {
await api.installPlugin(plugin.id, plugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Installed ${plugin.name}`]);
setLoading(false);
setIsInstalled(true);
setHasInstalledPanel(plugin.type === 'panel');
dispatch({ type: ActionTypes.INSTALLED, payload: plugin.type === 'panel' });
} catch (error) {
setLoading(false);
dispatch({ type: ActionTypes.ERROR, payload: { error } });
}
};
const onUninstall = async () => {
setLoading(true);
dispatch({ type: ActionTypes.INFLIGHT });
try {
await api.uninstallPlugin(plugin.id);
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${plugin.name}`]);
setLoading(false);
setIsInstalled(false);
dispatch({ type: ActionTypes.UNINSTALLED });
} catch (error) {
setLoading(false);
dispatch({ type: ActionTypes.ERROR, payload: error });
}
};
const onUpdate = async () => {
setLoading(true);
dispatch({ type: ActionTypes.INFLIGHT });
try {
await api.installPlugin(plugin.id, plugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Updated ${plugin.name}`]);
setLoading(false);
setShouldUpdate(false);
dispatch({ type: ActionTypes.UPDATED });
} catch (error) {
setLoading(false);
dispatch({ type: ActionTypes.ERROR, payload: error });
}
};
@ -100,14 +97,14 @@ export const InstallControls = ({ plugin }: Props) => {
if (isInstalled) {
return (
<HorizontalGroup height="auto">
{shouldUpdate &&
{hasUpdate &&
(isExternallyManaged ? (
<LinkButton href={externalManageLink} target="_blank" rel="noopener noreferrer">
{'Update via grafana.com'}
</LinkButton>
) : (
<Button disabled={loading || !hasPermission} onClick={onUpdate}>
{loading ? 'Updating' : 'Update'}
<Button disabled={isInflight || !hasPermission} onClick={onUpdate}>
{isInflight ? 'Updating' : 'Update'}
</Button>
))}
@ -117,8 +114,8 @@ export const InstallControls = ({ plugin }: Props) => {
</LinkButton>
) : (
<>
<Button variant="destructive" disabled={loading || !hasPermission} onClick={onUninstall}>
{loading && !shouldUpdate ? 'Uninstalling' : 'Uninstall'}
<Button variant="destructive" disabled={isInflight || !hasPermission} onClick={onUninstall}>
{isInflight && !hasUpdate ? 'Uninstalling' : 'Uninstall'}
</Button>
{hasInstalledPanel && (
<div className={cx(styles.message, styles.messageMargin)}>
@ -149,8 +146,8 @@ export const InstallControls = ({ plugin }: Props) => {
</LinkButton>
) : (
<>
<Button disabled={loading || !hasPermission} onClick={onInstall}>
{loading ? 'Installing' : 'Install'}
<Button disabled={isInflight || !hasPermission} onClick={onInstall}>
{isInflight ? 'Installing' : 'Install'}
</Button>
{!hasPermission && <div className={styles.message}>You need admin privileges to install this plugin.</div>}
</>

View File

@ -0,0 +1,96 @@
import React from 'react';
import { css, cx } from '@emotion/css';
import { AppPlugin, GrafanaTheme2, GrafanaPlugin, PluginMeta } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { VersionList } from '../components/VersionList';
import { AppConfigCtrlWrapper } from '../../wrappers/AppConfigWrapper';
import { PluginDashboards } from '../../PluginDashboards';
type PluginDetailsBodyProps = {
tab: { label: string };
plugin: GrafanaPlugin<PluginMeta<{}>> | undefined;
remoteVersions: Array<{ version: string; createdAt: string }>;
readme: string;
};
export function PluginDetailsBody({ tab, plugin, remoteVersions, readme }: PluginDetailsBodyProps): JSX.Element | null {
const styles = useStyles2(getStyles);
if (tab?.label === 'Overview') {
return (
<div
className={cx(styles.readme, styles.container)}
dangerouslySetInnerHTML={{ __html: readme ?? 'No plugin help or readme markdown file was found' }}
/>
);
}
if (tab?.label === 'Version history') {
return (
<div className={styles.container}>
<VersionList versions={remoteVersions ?? []} />
</div>
);
}
if (tab?.label === 'Config' && plugin?.angularConfigCtrl) {
return (
<div className={styles.container}>
<AppConfigCtrlWrapper app={plugin as AppPlugin} />
</div>
);
}
if (plugin?.configPages) {
for (const configPage of plugin.configPages) {
if (tab?.label === configPage.title) {
return (
<div className={styles.container}>
<configPage.body plugin={plugin} query={{}} />
</div>
);
}
}
}
if (tab?.label === 'Dashboards' && plugin) {
return (
<div className={styles.container}>
<PluginDashboards plugin={plugin.meta} />
</div>
);
}
return null;
}
export const getStyles = (theme: GrafanaTheme2) => ({
container: css`
padding: ${theme.spacing(3, 4)};
`,
readme: css`
& img {
max-width: 100%;
}
h1,
h2,
h3 {
margin-top: ${theme.spacing(3)};
margin-bottom: ${theme.spacing(2)};
}
*:first-child {
margin-top: 0;
}
li {
margin-left: ${theme.spacing(2)};
& > p {
margin: ${theme.spacing()} 0;
}
}
`,
});

View File

@ -13,30 +13,28 @@ export const VersionList = ({ versions }: Props) => {
const styles = useStyles2(getStyles);
if (versions.length === 0) {
return <div className={styles.container}>No version history was found.</div>;
return <p>No version history was found.</p>;
}
return (
<div className={styles.container}>
<table className={styles.table}>
<thead>
<tr>
<th>Version</th>
<th>Last updated</th>
</tr>
</thead>
<tbody>
{versions.map((version) => {
return (
<tr key={version.version}>
<td>{version.version}</td>
<td>{dateTimeFormatTimeAgo(version.createdAt)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<table className={styles.table}>
<thead>
<tr>
<th>Version</th>
<th>Last updated</th>
</tr>
</thead>
<tbody>
{versions.map((version) => {
return (
<tr key={version.version}>
<td>{version.version}</td>
<td>{dateTimeFormatTimeAgo(version.createdAt)}</td>
</tr>
);
})}
</tbody>
</table>
);
};

View File

@ -0,0 +1,155 @@
import { useReducer, useEffect } from 'react';
import { PluginType, PluginIncludeType } from '@grafana/data';
import { api } from '../api';
import { loadPlugin } from '../../PluginPage';
import { getCatalogPluginDetails, isGrafanaAdmin } from '../helpers';
import { ActionTypes, PluginDetailsActions, PluginDetailsState } from '../types';
const defaultTabs = [{ label: 'Overview' }, { label: 'Version history' }];
const initialState = {
hasInstalledPanel: false,
hasUpdate: false,
isAdmin: isGrafanaAdmin(),
isInstalled: false,
isInflight: false,
loading: false,
error: undefined,
plugin: undefined,
pluginConfig: undefined,
tabs: defaultTabs,
activeTab: 0,
};
const reducer = (state: PluginDetailsState, action: PluginDetailsActions) => {
switch (action.type) {
case ActionTypes.LOADING: {
return { ...state, loading: true };
}
case ActionTypes.INFLIGHT: {
return { ...state, isInflight: true };
}
case ActionTypes.ERROR: {
return { ...state, loading: false, error: action.payload };
}
case ActionTypes.FETCHED_PLUGIN: {
return {
...state,
loading: false,
plugin: action.payload,
isInstalled: action.payload.isInstalled,
hasUpdate: action.payload.hasUpdate,
};
}
case ActionTypes.FETCHED_PLUGIN_CONFIG: {
return {
...state,
loading: false,
pluginConfig: action.payload,
};
}
case ActionTypes.UPDATE_TABS: {
return {
...state,
tabs: action.payload,
};
}
case ActionTypes.INSTALLED: {
return {
...state,
isInflight: false,
isInstalled: true,
hasInstalledPanel: action.payload,
};
}
case ActionTypes.UNINSTALLED: {
return {
...state,
isInflight: false,
isInstalled: false,
};
}
case ActionTypes.UPDATED: {
return {
...state,
hasUpdate: false,
isInflight: false,
};
}
case ActionTypes.SET_ACTIVE_TAB: {
return {
...state,
activeTab: action.payload,
};
}
}
};
export const usePluginDetails = (id: string) => {
const [state, dispatch] = useReducer(reducer, initialState);
useEffect(() => {
const fetchPlugin = async () => {
dispatch({ type: ActionTypes.LOADING });
try {
const value = await api.getPlugin(id);
const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions);
dispatch({ type: ActionTypes.FETCHED_PLUGIN, payload: plugin });
} catch (error) {
dispatch({ type: ActionTypes.ERROR, payload: error });
}
};
fetchPlugin();
}, [id]);
useEffect(() => {
const fetchPluginConfig = async () => {
if (state.isInstalled) {
dispatch({ type: ActionTypes.LOADING });
try {
const pluginConfig = await loadPlugin(id);
dispatch({ type: ActionTypes.FETCHED_PLUGIN_CONFIG, payload: pluginConfig });
} catch (error) {
dispatch({ type: ActionTypes.ERROR, payload: error });
}
} else {
// reset tabs
dispatch({ type: ActionTypes.FETCHED_PLUGIN_CONFIG, payload: undefined });
dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: 0 });
}
};
fetchPluginConfig();
}, [state.isInstalled, id]);
useEffect(() => {
const pluginConfig = state.pluginConfig;
const tabs: Array<{ label: string }> = [...defaultTabs];
if (pluginConfig && state.isAdmin) {
if (pluginConfig.meta.type === PluginType.app) {
if (pluginConfig.angularConfigCtrl) {
tabs.push({
label: 'Config',
});
}
if (pluginConfig.configPages) {
for (const page of pluginConfig.configPages) {
tabs.push({
label: page.title,
});
}
}
if (pluginConfig.meta.includes?.find((include) => include.type === PluginIncludeType.dashboard)) {
tabs.push({
label: 'Dashboards',
});
}
}
}
dispatch({ type: ActionTypes.UPDATE_TABS, payload: tabs });
}, [state.isAdmin, state.pluginConfig, id]);
return { state, dispatch };
};

View File

@ -1,8 +1,8 @@
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { CatalogPlugin, CatalogPluginDetails } from '../types';
import { CatalogPlugin } from '../types';
import { api } from '../api';
import { mapLocalToCatalog, mapRemoteToCatalog, getCatalogPluginDetails, applySearchFilter } from '../helpers';
import { mapLocalToCatalog, mapRemoteToCatalog, applySearchFilter } from '../helpers';
type CatalogPluginsState = {
loading: boolean;
@ -77,21 +77,3 @@ export const usePluginsByFilter = (searchBy: string, filterBy: string): Filtered
plugins: applySearchFilter(searchBy, plugins),
};
};
type PluginState = {
isLoading: boolean;
plugin?: CatalogPluginDetails;
};
export const usePlugin = (slug: string): PluginState => {
const { loading, value } = useAsync(async () => {
return await api.getPlugin(slug);
}, [slug]);
const plugin = getCatalogPluginDetails(value?.local, value?.remote, value?.remoteVersions);
return {
isLoading: loading,
plugin,
};
};

View File

@ -1,32 +1,41 @@
import React, { useState } from 'react';
import React from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui';
import { useStyles2, TabsBar, TabContent, Tab, Icon, Alert } from '@grafana/ui';
import { VersionList } from '../components/VersionList';
import { AppNotificationSeverity } from 'app/types';
import { InstallControls } from '../components/InstallControls';
import { usePlugin } from '../hooks/usePlugins';
import { usePluginDetails } from '../hooks/usePluginDetails';
import { Page as PluginPage } from '../components/Page';
import { Loader } from '../components/Loader';
import { Page } from 'app/core/components/Page/Page';
import { PluginLogo } from '../components/PluginLogo';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { ActionTypes } from '../types';
import { PluginDetailsBody } from '../components/PluginDetailsBody';
type PluginDetailsProps = GrafanaRouteComponentProps<{ pluginId?: string }>;
export default function PluginDetails({ match }: PluginDetailsProps): JSX.Element | null {
const { pluginId } = match.params;
const [tabs, setTabs] = useState([
{ label: 'Overview', active: true },
{ label: 'Version history', active: false },
]);
const { isLoading, plugin } = usePlugin(pluginId!);
const { state, dispatch } = usePluginDetails(pluginId!);
const {
loading,
error,
plugin,
pluginConfig,
tabs,
activeTab,
isInflight,
hasUpdate,
isInstalled,
hasInstalledPanel,
} = state;
const tab = tabs[activeTab];
const styles = useStyles2(getStyles);
if (isLoading) {
if (loading) {
return (
<Page>
<Loader />
@ -69,33 +78,41 @@ export default function PluginDetails({ match }: PluginDetailsProps): JSX.Elemen
{plugin.version && <span>{plugin.version}</span>}
</div>
<p>{plugin.description}</p>
<InstallControls plugin={plugin} />
<InstallControls
plugin={plugin}
isInflight={isInflight}
hasUpdate={hasUpdate}
isInstalled={isInstalled}
hasInstalledPanel={hasInstalledPanel}
dispatch={dispatch}
/>
</div>
</div>
<TabsBar>
{tabs.map((tab, key) => (
{tabs.map((tab: { label: string }, idx: number) => (
<Tab
key={key}
key={tab.label}
label={tab.label}
active={tab.active}
onChangeTab={() => {
setTabs(tabs.map((tab, index) => ({ ...tab, active: index === key })));
}}
active={idx === activeTab}
onChangeTab={() => dispatch({ type: ActionTypes.SET_ACTIVE_TAB, payload: idx })}
/>
))}
</TabsBar>
<TabContent>
{tabs.find((_) => _.label === 'Overview')?.active && (
<div
className={styles.readme}
dangerouslySetInnerHTML={{
__html: plugin?.readme ?? 'No plugin help or readme markdown file was found',
}}
/>
)}
{tabs.find((_) => _.label === 'Version history')?.active && (
<VersionList versions={plugin?.versions ?? []} />
{error && (
<Alert severity={AppNotificationSeverity.Error} title="Error Loading Plugin">
<>
Check the server startup logs for more information. <br />
If this plugin was loaded from git, make sure it was compiled.
</>
</Alert>
)}
<PluginDetailsBody
tab={tab}
plugin={pluginConfig}
remoteVersions={plugin.versions}
readme={plugin.readme}
/>
</TabContent>
</PluginPage>
</Page>
@ -139,30 +156,5 @@ export const getStyles = (theme: GrafanaTheme2) => {
headerOrgName: css`
font-size: ${theme.typography.h4.fontSize};
`,
readme: css`
padding: ${theme.spacing(3, 4)};
& img {
max-width: 100%;
}
h1,
h2,
h3 {
margin-top: ${theme.spacing(3)};
margin-bottom: ${theme.spacing(2)};
}
*:first-child {
margin-top: 0;
}
li {
margin-left: ${theme.spacing(2)};
& > p {
margin: ${theme.spacing()} 0;
}
}
`,
};
};

View File

@ -1,5 +1,5 @@
import { GrafanaPlugin, PluginMeta } from '@grafana/data';
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
export interface CatalogPlugin {
description: string;
downloads: number;
@ -133,3 +133,44 @@ export interface Org {
avatar: string;
avatarUrl: string;
}
export interface PluginDetailsState {
hasInstalledPanel: boolean;
hasUpdate: boolean;
isAdmin: boolean;
isInstalled: boolean;
isInflight: boolean;
loading: boolean;
error?: Error;
plugin?: CatalogPluginDetails;
pluginConfig?: GrafanaPlugin<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;
};