Plugins: Add update all plugins feature (#92229)

This commit is contained in:
Hugo Kiyodi Oshiro
2024-08-27 13:38:39 +02:00
committed by GitHub
parent 37f0c0d00f
commit 0f24c89c91
8 changed files with 350 additions and 4 deletions
@@ -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;
+16
View File
@@ -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",
+16
View File
@@ -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čįęş",