Plugins: Add update all plugins feature (#92229)
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Checkbox, ConfirmModal, EmptyState, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
|
||||
import { useInstall, useInstallStatus } from '../state/hooks';
|
||||
import { CatalogPlugin } from '../types';
|
||||
|
||||
type UpdateError = {
|
||||
id: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
function getIcon({
|
||||
id,
|
||||
inProgress,
|
||||
errorMap,
|
||||
selectedPlugins,
|
||||
}: {
|
||||
id: string;
|
||||
inProgress: boolean;
|
||||
errorMap: Map<string, UpdateError>;
|
||||
selectedPlugins?: Set<string>;
|
||||
}) {
|
||||
if (errorMap && errorMap.has(id)) {
|
||||
return (
|
||||
<Tooltip
|
||||
content={`${t('plugins.catalog.update-all.error', 'Error updating plugin:')} ${errorMap.get(id)?.message}`}
|
||||
>
|
||||
<Icon size="xl" name="exclamation-circle" />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (inProgress && selectedPlugins?.has(id)) {
|
||||
return <Spinner />;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
table: css({
|
||||
marginTop: theme.spacing(2),
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
}),
|
||||
tableRow: css({
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
td: {
|
||||
paddingRight: theme.spacing(1),
|
||||
},
|
||||
}),
|
||||
icon: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}),
|
||||
header: css({
|
||||
textAlign: 'left',
|
||||
padding: theme.spacing(1),
|
||||
borderBottom: `2px solid ${theme.colors.border.strong}`,
|
||||
th: {
|
||||
paddingRight: theme.spacing(1),
|
||||
},
|
||||
}),
|
||||
data: css({
|
||||
padding: '10px',
|
||||
}),
|
||||
footer: css({
|
||||
fontSize: theme.typography.bodySmall.fontSize,
|
||||
marginTop: theme.spacing(3),
|
||||
}),
|
||||
noPluginsMessage: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
}),
|
||||
tableContainer: css({
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
height: theme.spacing(32),
|
||||
}),
|
||||
modalContainer: css({
|
||||
height: theme.spacing(41),
|
||||
}),
|
||||
});
|
||||
|
||||
type ModalBodyProps = {
|
||||
plugins: CatalogPlugin[];
|
||||
inProgress: boolean;
|
||||
selectedPlugins?: Set<string>;
|
||||
onCheckboxChange: (id: string) => void;
|
||||
errorMap: Map<string, UpdateError>;
|
||||
};
|
||||
|
||||
const ModalBody = ({ plugins, inProgress, selectedPlugins, onCheckboxChange, errorMap }: ModalBodyProps) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.modalContainer}>
|
||||
{plugins.length === 0 ? (
|
||||
<EmptyState
|
||||
variant="completed"
|
||||
message={t('plugins.catalog.update-all.all-plugins-updated', 'All plugins updated!')}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Trans i18nKey="plugins.catalog.update-all.header">The following plugins have update available</Trans>
|
||||
</div>
|
||||
<div className={styles.tableContainer}>
|
||||
<table className={styles.table}>
|
||||
<thead className={styles.header}>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.update-header">Update</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.name-header">Name</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.installed-header">Installed</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans i18nKey="plugins.catalog.update-all.available-header">Available</Trans>
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plugins.map(({ id, name, installedVersion, latestVersion }: CatalogPlugin) => (
|
||||
<tr key={id} className={styles.tableRow}>
|
||||
<td>
|
||||
<Checkbox onChange={() => onCheckboxChange(id)} value={selectedPlugins?.has(id)} />
|
||||
</td>
|
||||
<td>{name}</td>
|
||||
<td>{installedVersion}</td>
|
||||
<td>{latestVersion}</td>
|
||||
<td className={styles.icon}>{getIcon({ id, inProgress, errorMap, selectedPlugins })}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{config.pluginAdminExternalManageEnabled && config.featureToggles.managedPluginsInstall && (
|
||||
<footer className={styles.footer}>
|
||||
<Trans i18nKey="plugins.catalog.update-all.cloud-update-message">
|
||||
* It may take a few minutes for the plugins to be available for usage.
|
||||
</Trans>
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onDismiss: () => void;
|
||||
plugins: CatalogPlugin[];
|
||||
};
|
||||
|
||||
export const UpdateAllModal = ({ isOpen, onDismiss, plugins }: Props) => {
|
||||
const install = useInstall();
|
||||
const { error } = useInstallStatus();
|
||||
const [errorMap, setErrorMap] = useState(new Map<string, UpdateError>());
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const [selectedPlugins, setSelectedPlugins] = useState<Set<string>>();
|
||||
|
||||
const pluginsSet = useMemo(() => new Set(plugins.map((plugin) => plugin.id)), [plugins]);
|
||||
const installsRemaining = plugins.length;
|
||||
|
||||
// Updates the component state on every plugins change, since the installation will change the store content
|
||||
useEffect(() => {
|
||||
if (inProgress) {
|
||||
selectedPlugins?.forEach((id) => {
|
||||
if (!pluginsSet.has(id)) {
|
||||
setSelectedPlugins((prevSelectedPlugins) => {
|
||||
const newSelectedPlugins = new Set(prevSelectedPlugins);
|
||||
newSelectedPlugins.delete(id);
|
||||
return newSelectedPlugins;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (selectedPlugins?.size === 0) {
|
||||
setInProgress(false);
|
||||
}
|
||||
}
|
||||
}, [inProgress, pluginsSet, selectedPlugins]);
|
||||
|
||||
// Initialize the component with all the plugins selected
|
||||
useEffect(() => {
|
||||
if (selectedPlugins === undefined && plugins.length > 0) {
|
||||
const initialSelectedPlugins = new Set(plugins.map((plugin) => plugin.id));
|
||||
setSelectedPlugins(initialSelectedPlugins);
|
||||
}
|
||||
}, [plugins, selectedPlugins]);
|
||||
|
||||
// Updates the component state on every error that comes from the store
|
||||
useEffect(() => {
|
||||
if (inProgress && error && !errorMap.has(error.id) && selectedPlugins?.has(error.id)) {
|
||||
setErrorMap((prevErrorMap) => {
|
||||
const newErrorMap = new Map(prevErrorMap);
|
||||
newErrorMap.set(error.id, error);
|
||||
return newErrorMap;
|
||||
});
|
||||
|
||||
setSelectedPlugins((prevSelectedPlugins) => {
|
||||
const newSelectedPlugins = new Set(prevSelectedPlugins);
|
||||
newSelectedPlugins.delete(error.id);
|
||||
return newSelectedPlugins;
|
||||
});
|
||||
}
|
||||
}, [error, errorMap, inProgress, selectedPlugins]);
|
||||
|
||||
const onConfirm = () => {
|
||||
if (!inProgress) {
|
||||
setInProgress(true);
|
||||
plugins.forEach((plugin) => {
|
||||
if (selectedPlugins?.has(plugin.id)) {
|
||||
install(plugin.id, plugin.latestVersion, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onDismissClick = () => {
|
||||
setErrorMap(new Map());
|
||||
setInProgress(false);
|
||||
setSelectedPlugins(undefined);
|
||||
onDismiss();
|
||||
};
|
||||
|
||||
const onCheckboxChange = (id: string) => {
|
||||
setSelectedPlugins((prevSelectedPlugins) => {
|
||||
const newSelectedPlugins = new Set(prevSelectedPlugins);
|
||||
if (newSelectedPlugins.has(id)) {
|
||||
newSelectedPlugins.delete(id);
|
||||
} else {
|
||||
newSelectedPlugins.add(id);
|
||||
}
|
||||
return newSelectedPlugins;
|
||||
});
|
||||
if (errorMap.has(id)) {
|
||||
setErrorMap((prevErrorMap) => {
|
||||
const newErrorMap = new Map(prevErrorMap);
|
||||
newErrorMap.delete(id);
|
||||
return newErrorMap;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
isOpen={isOpen}
|
||||
title={t('plugins.catalog.update-all.modal-title', 'Update Plugins')}
|
||||
body={
|
||||
<ModalBody
|
||||
plugins={plugins}
|
||||
inProgress={inProgress}
|
||||
errorMap={errorMap}
|
||||
onCheckboxChange={onCheckboxChange}
|
||||
selectedPlugins={selectedPlugins}
|
||||
/>
|
||||
}
|
||||
onConfirm={installsRemaining > 0 ? onConfirm : onDismissClick}
|
||||
onDismiss={onDismissClick}
|
||||
disabled={selectedPlugins?.size === 0 || inProgress}
|
||||
confirmText={
|
||||
installsRemaining > 0
|
||||
? `${t('plugins.catalog.update-all.modal-confirmation', 'Update')} (${selectedPlugins?.size})`
|
||||
: t('plugins.catalog.update-all.modal-dismiss', 'Close')
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateAllModal;
|
||||
@@ -201,6 +201,7 @@ export function mapLocalToCatalog(plugin: LocalPlugin, error?: PluginError): Cat
|
||||
angularDetected,
|
||||
isFullyInstalled: true,
|
||||
iam: plugin.iam,
|
||||
latestVersion: plugin.latestVersion,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { ReactElement } from 'react';
|
||||
import { ReactElement, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom-v5-compat';
|
||||
|
||||
import { SelectableValue, GrafanaTheme2, PluginType } from '@grafana/data';
|
||||
import { locationSearchToObject } from '@grafana/runtime';
|
||||
import { Select, RadioButtonGroup, useStyles2, Tooltip, Field } from '@grafana/ui';
|
||||
import { Select, RadioButtonGroup, useStyles2, Tooltip, Field, Button } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import { ROUTES as CONNECTIONS_ROUTES } from 'app/features/connections/constants';
|
||||
@@ -15,9 +16,10 @@ import { HorizontalGroup } from '../components/HorizontalGroup';
|
||||
import { PluginList } from '../components/PluginList';
|
||||
import { RoadmapLinks } from '../components/RoadmapLinks';
|
||||
import { SearchField } from '../components/SearchField';
|
||||
import { UpdateAllModal } from '../components/UpdateAllModal';
|
||||
import { Sorters } from '../helpers';
|
||||
import { useHistory } from '../hooks/useHistory';
|
||||
import { useGetAll, useIsRemotePluginsAvailable } from '../state/hooks';
|
||||
import { useGetAll, useGetUpdatable, useIsRemotePluginsAvailable } from '../state/hooks';
|
||||
|
||||
export default function Browse({ route }: GrafanaRouteComponentProps): ReactElement | null {
|
||||
const location = useLocation();
|
||||
@@ -46,6 +48,10 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
||||
{ value: 'has-update', label: 'New Updates' },
|
||||
];
|
||||
|
||||
const updatablePlugins = useGetUpdatable();
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
||||
const disableUpdateAllButton = updatablePlugins.length <= 0;
|
||||
|
||||
const onSortByChange = (value: SelectableValue<string>) => {
|
||||
history.push({ query: { sortBy: value.value } });
|
||||
};
|
||||
@@ -62,6 +68,10 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
||||
history.push({ query: { filterBy, filterByType, q } });
|
||||
};
|
||||
|
||||
const onUpdateAll = () => {
|
||||
setShowUpdateModal(true);
|
||||
};
|
||||
|
||||
// How should we handle errors?
|
||||
if (error) {
|
||||
console.error(error.message);
|
||||
@@ -77,9 +87,15 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
||||
.
|
||||
</div>
|
||||
);
|
||||
const updateAll = (
|
||||
<Button disabled={disableUpdateAllButton} onClick={onUpdateAll}>
|
||||
<Trans i18nKey="plugins.catalog.update-all.button">Update all</Trans>
|
||||
{!disableUpdateAllButton ? ` (${updatablePlugins.length})` : ''}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Page navModel={navModel} subTitle={subTitle}>
|
||||
<Page navModel={navModel} actions={updateAll} subTitle={subTitle}>
|
||||
<Page.Contents>
|
||||
<HorizontalGroup wrap>
|
||||
<Field label="Search">
|
||||
@@ -147,6 +163,11 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
|
||||
<PluginList plugins={plugins} isLoading={isLoading} />
|
||||
</div>
|
||||
<RoadmapLinks />
|
||||
<UpdateAllModal
|
||||
isOpen={showUpdateModal}
|
||||
onDismiss={() => setShowUpdateModal(false)}
|
||||
plugins={updatablePlugins}
|
||||
/>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -213,6 +213,8 @@ export const install = createAsyncThunk<
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
if (isFetchError(e)) {
|
||||
// add id to identify errors in multiple requests
|
||||
e.data.id = id;
|
||||
return thunkApi.rejectWithValue(e.data);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ export const useGetAll = (filters: PluginFilters, sortBy: Sorters = Sorters.name
|
||||
};
|
||||
};
|
||||
|
||||
export const useGetUpdatable = () => {
|
||||
const { plugins: installed } = useGetAll({ isInstalled: true });
|
||||
return installed.filter((p) => !p.isCore && !p.isManaged && !p.isProvisioned && p.hasUpdate && p.latestVersion);
|
||||
};
|
||||
|
||||
export const useGetSingle = (id: string): CatalogPlugin | undefined => {
|
||||
useFetchAll();
|
||||
useFetchDetails(id);
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface CatalogPlugin extends WithAccessControlMetadata {
|
||||
isPreinstalled: { found: boolean; withVersion: boolean }; // Indicates that the plugin is pre-installed
|
||||
// `isPublished` is TRUE if the plugin is published to grafana.com
|
||||
isPublished: boolean;
|
||||
latestVersion?: string;
|
||||
name: string;
|
||||
orgName: string;
|
||||
signature: PluginSignatureStatus;
|
||||
@@ -156,6 +157,7 @@ export type LocalPlugin = WithAccessControlMetadata & {
|
||||
dev?: boolean;
|
||||
enabled: boolean;
|
||||
hasUpdate: boolean;
|
||||
latestVersion: string;
|
||||
id: string;
|
||||
info: {
|
||||
author: Rel;
|
||||
|
||||
@@ -1829,6 +1829,22 @@
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"catalog": {
|
||||
"update-all": {
|
||||
"all-plugins-updated": "All plugins updated!",
|
||||
"available-header": "Available",
|
||||
"button": "Update all",
|
||||
"cloud-update-message": "* It may take a few minutes for the plugins to be available for usage.",
|
||||
"error": "Error updating plugin:",
|
||||
"header": "The following plugins have update available",
|
||||
"installed-header": "Installed",
|
||||
"modal-confirmation": "Update",
|
||||
"modal-dismiss": "Close",
|
||||
"modal-title": "Update Plugins",
|
||||
"name-header": "Name",
|
||||
"update-header": "Update"
|
||||
}
|
||||
},
|
||||
"details": {
|
||||
"labels": {
|
||||
"dependencies": "Dependencies",
|
||||
|
||||
@@ -1829,6 +1829,22 @@
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"catalog": {
|
||||
"update-all": {
|
||||
"all-plugins-updated": "Åľľ pľūģįʼnş ūpđäŧęđ!",
|
||||
"available-header": "Åväįľäþľę",
|
||||
"button": "Ůpđäŧę äľľ",
|
||||
"cloud-update-message": "* Ĩŧ mäy ŧäĸę ä ƒęŵ mįʼnūŧęş ƒőř ŧĥę pľūģįʼnş ŧő þę äväįľäþľę ƒőř ūşäģę.",
|
||||
"error": "Ēřřőř ūpđäŧįʼnģ pľūģįʼn:",
|
||||
"header": "Ŧĥę ƒőľľőŵįʼnģ pľūģįʼnş ĥävę ūpđäŧę äväįľäþľę",
|
||||
"installed-header": "Ĩʼnşŧäľľęđ",
|
||||
"modal-confirmation": "Ůpđäŧę",
|
||||
"modal-dismiss": "Cľőşę",
|
||||
"modal-title": "Ůpđäŧę Pľūģįʼnş",
|
||||
"name-header": "Ńämę",
|
||||
"update-header": "Ůpđäŧę"
|
||||
}
|
||||
},
|
||||
"details": {
|
||||
"labels": {
|
||||
"dependencies": "Đępęʼnđęʼnčįęş",
|
||||
|
||||
Reference in New Issue
Block a user