feat: add batch operation feature for plugins (#4482)

#### What type of PR is this?

/area console
/kind feature
/milestone 2.9.x

#### What this PR does / why we need it:

支持对插件进行批量操作。

<img width="577" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/ec3ed727-e151-44d0-8c18-b6ec8a309ea9">

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/4475

#### Special notes for your reviewer:

测试批量对插件进行启用、停用、卸载。

#### Does this PR introduce a user-facing change?

```release-note
Console 端的插件管理支持批量操作。
```
pull/4495/head
Ryan Wang 2023-08-25 11:00:13 -05:00 committed by GitHub
parent e40b5d2388
commit f01e04f5a0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 247 additions and 37 deletions

View File

@ -732,11 +732,20 @@ core:
toast_success: Reset configuration successfully toast_success: Reset configuration successfully
uninstall: uninstall:
title: Are you sure you want to uninstall this plugin? title: Are you sure you want to uninstall this plugin?
uninstall_in_batch:
title: Are you sure you want to uninstall these plugin?
uninstall_and_delete_config: uninstall_and_delete_config:
button: Uninstall and delete config
title: Are you sure you want to uninstall this plugin and its corresponding configuration? title: Are you sure you want to uninstall this plugin and its corresponding configuration?
uninstall_and_delete_config_in_batch:
button: Uninstall and delete config
title: Are you sure you want to uninstall these plugin and its corresponding configuration?
uninstall_when_enabled: uninstall_when_enabled:
confirm_text: Stop running and uninstall confirm_text: Stop running and uninstall
description: The current plugin is still in the enabled state and will be uninstalled after it stops running. This operation cannot be undone. description: The current plugin is still in the enabled state and will be uninstalled after it stops running. This operation cannot be undone.
change_status_in_batch:
activate_title: Are you sure you want to activate these plugins?
inactivate_title: Are you sure you want to inactivate these plugins?
remote_download: remote_download:
title: Remote download address detected, do you want to download? title: Remote download address detected, do you want to download?
description: "Please carefully verify whether this address can be trusted: {url}" description: "Please carefully verify whether this address can be trusted: {url}"
@ -749,9 +758,6 @@ core:
items: items:
create_time_desc: Latest Installed create_time_desc: Latest Installed
create_time_asc: Earliest Installed create_time_asc: Earliest Installed
list:
actions:
uninstall_and_delete_config: Uninstall and delete config
upload_modal: upload_modal:
titles: titles:
install: Install plugin install: Install plugin
@ -1197,7 +1203,8 @@ core:
preview: Preview preview: Preview
recovery: Recovery recovery: Recovery
delete_permanently: Delete Permanently delete_permanently: Delete Permanently
active: Active activate: Activate
inactivate: Inactivate
download: Download download: Download
copy: Copy copy: Copy
upload: Upload upload: Upload

View File

@ -732,11 +732,20 @@ core:
toast_success: 重置配置成功 toast_success: 重置配置成功
uninstall: uninstall:
title: 确定要卸载该插件吗? title: 确定要卸载该插件吗?
uninstall_in_batch:
title: 确定要卸载所选插件吗?
uninstall_and_delete_config: uninstall_and_delete_config:
button: 卸载并删除配置
title: 确定要卸载该插件以及对应的配置吗? title: 确定要卸载该插件以及对应的配置吗?
uninstall_and_delete_config_in_batch:
button: 卸载并删除配置
title: 确定要卸载所选插件以及对应的配置吗?
uninstall_when_enabled: uninstall_when_enabled:
confirm_text: 停止运行并卸载 confirm_text: 停止运行并卸载
description: 当前插件还在启用状态,将在停止运行后卸载,该操作不可恢复。 description: 当前插件还在启用状态,将在停止运行后卸载,该操作不可恢复。
change_status_in_batch:
activate_title: 确定要启用所选插件吗?
inactivate_title: 确定要停用所选插件吗?
remote_download: remote_download:
title: 检测到了远程下载地址,是否需要下载? title: 检测到了远程下载地址,是否需要下载?
description: 请仔细鉴别此地址是否可信:{url} description: 请仔细鉴别此地址是否可信:{url}
@ -749,9 +758,6 @@ core:
items: items:
create_time_desc: 较近安装 create_time_desc: 较近安装
create_time_asc: 较早安装 create_time_asc: 较早安装
list:
actions:
uninstall_and_delete_config: 卸载并删除配置
upload_modal: upload_modal:
titles: titles:
install: 安装插件 install: 安装插件
@ -1197,7 +1203,8 @@ core:
preview: 预览 preview: 预览
recovery: 恢复 recovery: 恢复
delete_permanently: 永久删除 delete_permanently: 永久删除
active: 启用 activate: 启用
inactivate: 停用
download: 下载 download: 下载
copy: 复制 copy: 复制
upload: 上传 upload: 上传

View File

@ -732,11 +732,20 @@ core:
toast_success: 重置配置成功 toast_success: 重置配置成功
uninstall: uninstall:
title: 確定要卸載該插件嗎? title: 確定要卸載該插件嗎?
uninstall_in_batch:
title: 確定要卸載所選插件嗎?
uninstall_and_delete_config: uninstall_and_delete_config:
button: 卸載並刪除配置
title: 確定要卸載該插件以及對應的配置嗎? title: 確定要卸載該插件以及對應的配置嗎?
uninstall_and_delete_config_in_batch:
button: 卸載並刪除配置
title: 確定要卸載所選插件以及對應的配置嗎?
uninstall_when_enabled: uninstall_when_enabled:
confirm_text: 停止運行並卸載 confirm_text: 停止運行並卸載
description: 當前插件還在啟用狀態,將在停止運行後卸載,該操作不可恢復。 description: 當前插件還在啟用狀態,將在停止運行後卸載,該操作不可恢復。
change_status_in_batch:
activate_title: 確定要啟用所選插件嗎?
inactivate_title: 確定要停用所選插件嗎?
remote_download: remote_download:
title: 偵測到遠端下載地址,是否需要下載? title: 偵測到遠端下載地址,是否需要下載?
description: 請仔細鑑別此地址是否可信:{url} description: 請仔細鑑別此地址是否可信:{url}
@ -749,9 +758,6 @@ core:
items: items:
create_time_desc: 較近安裝 create_time_desc: 較近安裝
create_time_asc: 較早安裝 create_time_asc: 較早安裝
list:
actions:
uninstall_and_delete_config: 卸載並刪除配置
upload_modal: upload_modal:
titles: titles:
install: 安裝插件 install: 安裝插件
@ -1197,7 +1203,8 @@ core:
preview: 預覽 preview: 預覽
recovery: 恢復 recovery: 恢復
delete_permanently: 永久刪除 delete_permanently: 永久刪除
active: 啟用 activate: 啟用
inactivate: 停用
download: 下載 download: 下載
copy: 複製 copy: 複製
upload: 上傳 upload: 上傳

View File

@ -194,7 +194,7 @@ const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
#dropdownItems #dropdownItems
> >
<VDropdownItem v-if="!isActivated" @click="handleActiveTheme(true)"> <VDropdownItem v-if="!isActivated" @click="handleActiveTheme(true)">
{{ $t("core.common.buttons.active") }} {{ $t("core.common.buttons.activate") }}
</VDropdownItem> </VDropdownItem>
<VDropdownItem @click="emit('upgrade')"> <VDropdownItem @click="emit('upgrade')">
{{ $t("core.common.buttons.upgrade") }} {{ $t("core.common.buttons.upgrade") }}

View File

@ -81,7 +81,7 @@ const { isActivated, handleActiveTheme } = useThemeLifeCycle(theme);
<template #dropdownItems> <template #dropdownItems>
<VDropdownItem v-if="!isActivated" @click="handleActiveTheme()"> <VDropdownItem v-if="!isActivated" @click="handleActiveTheme()">
{{ $t("core.common.buttons.active") }} {{ $t("core.common.buttons.activate") }}
</VDropdownItem> </VDropdownItem>
<VDropdownItem @click="emit('open-settings')"> <VDropdownItem @click="emit('open-settings')">
{{ $t("core.common.buttons.setting") }} {{ $t("core.common.buttons.setting") }}

View File

@ -202,7 +202,7 @@ onMounted(() => {
type="primary" type="primary"
@click="handleActiveTheme()" @click="handleActiveTheme()"
> >
{{ $t("core.common.buttons.active") }} {{ $t("core.common.buttons.activate") }}
</VButton> </VButton>
<VButton type="secondary" size="sm" @click="previewModal = true"> <VButton type="secondary" size="sm" @click="previewModal = true">
<template #icon> <template #icon>

View File

@ -10,6 +10,8 @@ import {
VSpace, VSpace,
VLoading, VLoading,
Dialog, Dialog,
VDropdown,
VDropdownItem,
} from "@halo-dev/components"; } from "@halo-dev/components";
import PluginListItem from "./components/PluginListItem.vue"; import PluginListItem from "./components/PluginListItem.vue";
import PluginInstallationModal from "./components/PluginInstallationModal.vue"; import PluginInstallationModal from "./components/PluginInstallationModal.vue";
@ -20,6 +22,10 @@ import { useQuery } from "@tanstack/vue-query";
import type { Plugin } from "@halo-dev/api-client"; import type { Plugin } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import { watch } from "vue";
import { provide } from "vue";
import type { Ref } from "vue";
import { usePluginBatchOperations } from "./composables/use-plugin";
const { t } = useI18n(); const { t } = useI18n();
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
@ -68,6 +74,33 @@ const { data, isLoading, isFetching, refetch } = useQuery<Plugin[]>({
}, },
}); });
// selection
const selectedNames = ref<string[]>([]);
provide<Ref<string[]>>("selectedNames", selectedNames);
const checkedAll = ref(false);
watch(
() => selectedNames.value,
(value) => {
checkedAll.value = value.length === data.value?.length;
}
);
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedNames.value =
data.value?.map((plugin) => {
return plugin.metadata.name;
}) || [];
} else {
selectedNames.value.length = 0;
}
};
const { handleChangeStatusInBatch, handleUninstallInBatch } =
usePluginBatchOperations(selectedNames);
// handle remote download url from route // handle remote download url from route
const routeRemoteDownloadUrl = useRouteQuery<string | null>( const routeRemoteDownloadUrl = useRouteQuery<string | null>(
"remote-download-url" "remote-download-url"
@ -123,8 +156,50 @@ onMounted(() => {
<div <div
class="relative flex flex-col items-start sm:flex-row sm:items-center" class="relative flex flex-col items-start sm:flex-row sm:items-center"
> >
<div
v-permission="['system:posts:manage']"
class="mr-4 hidden items-center sm:flex"
>
<input
v-model="checkedAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center gap-2 sm:w-auto"> <div class="flex w-full flex-1 items-center gap-2 sm:w-auto">
<SearchInput v-model="keyword" /> <SearchInput v-if="!selectedNames.length" v-model="keyword" />
<VSpace v-else>
<VButton @click="handleChangeStatusInBatch(true)">
{{ $t("core.common.buttons.activate") }}
</VButton>
<VButton @click="handleChangeStatusInBatch(false)">
{{ $t("core.common.buttons.inactivate") }}
</VButton>
<VDropdown>
<VButton type="danger">
{{ $t("core.common.buttons.uninstall") }}
</VButton>
<template #popper>
<VDropdownItem
type="danger"
@click="handleUninstallInBatch(false)"
>
{{ $t("core.common.buttons.uninstall") }}
</VDropdownItem>
<VDropdownItem
type="danger"
@click="handleUninstallInBatch(true)"
>
{{
$t(
"core.plugin.operations.uninstall_and_delete_config.button"
)
}}
</VDropdownItem>
</template>
</VDropdown>
</VSpace>
</div> </div>
<div class="mt-4 flex sm:mt-0"> <div class="mt-4 flex sm:mt-0">
<VSpace spacing="lg"> <VSpace spacing="lg">
@ -223,6 +298,7 @@ onMounted(() => {
<li v-for="plugin in data" :key="plugin.metadata.name"> <li v-for="plugin in data" :key="plugin.metadata.name">
<PluginListItem <PluginListItem
:plugin="plugin" :plugin="plugin"
:is-selected="selectedNames.includes(plugin.metadata.name)"
@open-upgrade-modal="handleOpenUploadModal" @open-upgrade-modal="handleOpenUploadModal"
/> />
</li> </li>

View File

@ -10,13 +10,15 @@ import {
VDropdownItem, VDropdownItem,
VDropdownDivider, VDropdownDivider,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { markRaw, toRefs } from "vue"; import { inject, toRefs, markRaw } from "vue";
import { usePluginLifeCycle } from "../composables/use-plugin"; import { usePluginLifeCycle } from "../composables/use-plugin";
import type { Plugin } from "@halo-dev/api-client"; import type { Plugin } from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import type { Ref } from "vue";
import { ref } from "vue";
import { useEntityDropdownItemExtensionPoint } from "@/composables/use-entity-extension-points"; import { useEntityDropdownItemExtensionPoint } from "@/composables/use-entity-extension-points";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue"; import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
@ -27,11 +29,10 @@ const router = useRouter();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
plugin?: Plugin; plugin: Plugin;
isSelected?: boolean;
}>(), }>(),
{ { isSelected: false }
plugin: undefined,
}
); );
const emit = defineEmits<{ const emit = defineEmits<{
@ -40,6 +41,8 @@ const emit = defineEmits<{
const { plugin } = toRefs(props); const { plugin } = toRefs(props);
const selectedNames = inject<Ref<string[]>>("selectedNames", ref([]));
const { getFailedMessage, changeStatus, changingStatus, uninstall } = const { getFailedMessage, changeStatus, changingStatus, uninstall } =
usePluginLifeCycle(plugin); usePluginLifeCycle(plugin);
@ -124,7 +127,7 @@ const { dropdownItems } = useEntityDropdownItemExtensionPoint<Plugin>(
props: { props: {
type: "danger", type: "danger",
}, },
label: t("core.plugin.list.actions.uninstall_and_delete_config"), label: t("core.plugin.operations.uninstall_and_delete_config.button"),
visible: true, visible: true,
action: () => uninstall(true), action: () => uninstall(true),
}, },
@ -146,33 +149,45 @@ const { dropdownItems } = useEntityDropdownItemExtensionPoint<Plugin>(
); );
</script> </script>
<template> <template>
<VEntity> <VEntity :is-selected="isSelected">
<template
v-if="currentUserHasPermission(['system:plugins:manage'])"
#checkbox
>
<input
v-model="selectedNames"
:value="plugin.metadata.name"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
name="post-checkbox"
type="checkbox"
/>
</template>
<template #start> <template #start>
<VEntityField> <VEntityField>
<template #description> <template #description>
<VAvatar <VAvatar
:alt="plugin?.spec.displayName" :alt="plugin.spec.displayName"
:src="plugin?.status?.logo" :src="plugin.status?.logo"
size="md" size="md"
></VAvatar> ></VAvatar>
</template> </template>
</VEntityField> </VEntityField>
<VEntityField <VEntityField
:title="plugin?.spec.displayName" :title="plugin.spec.displayName"
:description="plugin?.spec.description" :description="plugin.spec.description"
:route="{ :route="{
name: 'PluginDetail', name: 'PluginDetail',
params: { name: plugin?.metadata.name }, params: { name: plugin.metadata.name },
}" }"
/> />
</template> </template>
<template #end> <template #end>
<VEntityField v-if="plugin?.status?.phase === 'FAILED'"> <VEntityField v-if="plugin.status?.phase === 'FAILED'">
<template #description> <template #description>
<VStatusDot v-tooltip="getFailedMessage()" state="error" animate /> <VStatusDot v-tooltip="getFailedMessage()" state="error" animate />
</template> </template>
</VEntityField> </VEntityField>
<VEntityField v-if="plugin?.metadata.deletionTimestamp"> <VEntityField v-if="plugin.metadata.deletionTimestamp">
<template #description> <template #description>
<VStatusDot <VStatusDot
v-tooltip="$t('core.common.status.deleting')" v-tooltip="$t('core.common.status.deleting')"
@ -181,22 +196,22 @@ const { dropdownItems } = useEntityDropdownItemExtensionPoint<Plugin>(
/> />
</template> </template>
</VEntityField> </VEntityField>
<VEntityField v-if="plugin?.spec.author"> <VEntityField v-if="plugin.spec.author">
<template #description> <template #description>
<a <a
:href="plugin?.spec.author.website" :href="plugin.spec.author.website"
class="hidden text-sm text-gray-500 hover:text-gray-900 sm:block" class="hidden text-sm text-gray-500 hover:text-gray-900 sm:block"
target="_blank" target="_blank"
> >
@{{ plugin?.spec.author.name }} @{{ plugin.spec.author.name }}
</a> </a>
</template> </template>
</VEntityField> </VEntityField>
<VEntityField :description="plugin?.spec.version" /> <VEntityField :description="plugin.spec.version" />
<VEntityField v-if="plugin?.metadata.creationTimestamp"> <VEntityField v-if="plugin.metadata.creationTimestamp">
<template #description> <template #description>
<span class="truncate text-xs tabular-nums text-gray-500"> <span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(plugin?.metadata.creationTimestamp) }} {{ formatDatetime(plugin.metadata.creationTimestamp) }}
</span> </span>
</template> </template>
</VEntityField> </VEntityField>
@ -204,7 +219,7 @@ const { dropdownItems } = useEntityDropdownItemExtensionPoint<Plugin>(
<template #description> <template #description>
<div class="flex items-center"> <div class="flex items-center">
<VSwitch <VSwitch
:model-value="plugin?.spec.enabled" :model-value="plugin.spec.enabled"
:disabled="changingStatus" :disabled="changingStatus"
@click="changeStatus" @click="changeStatus"
/> />

View File

@ -143,3 +143,101 @@ export function usePluginLifeCycle(
uninstall, uninstall,
}; };
} }
export function usePluginBatchOperations(names: Ref<string[]>) {
const { t } = useI18n();
function handleUninstallInBatch(deleteExtensions: boolean) {
Dialog.warning({
title: `${
deleteExtensions
? t(
"core.plugin.operations.uninstall_and_delete_config_in_batch.title"
)
: t("core.plugin.operations.uninstall_in_batch.title")
}`,
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmType: "danger",
confirmText: t("core.common.buttons.uninstall"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
for (let i = 0; i < names.value.length; i++) {
await apiClient.extension.plugin.deletepluginHaloRunV1alpha1Plugin({
name: names.value[i],
});
if (deleteExtensions) {
const { data: plugin } =
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin(
{
name: names.value[i],
}
);
const { settingName, configMapName } = plugin.spec;
if (settingName) {
await apiClient.extension.setting.deletev1alpha1Setting(
{
name: settingName,
},
{
mute: true,
}
);
}
if (configMapName) {
await apiClient.extension.configMap.deletev1alpha1ConfigMap(
{
name: configMapName,
},
{
mute: true,
}
);
}
}
}
window.location.reload();
} catch (e) {
console.error("Failed to uninstall plugin in batch", e);
}
},
});
}
function handleChangeStatusInBatch(enabled: boolean) {
Dialog.info({
title: enabled
? t("core.plugin.operations.change_status_in_batch.activate_title")
: t("core.plugin.operations.change_status_in_batch.inactivate_title"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
for (let i = 0; i < names.value.length; i++) {
const { data: pluginToUpdate } =
await apiClient.extension.plugin.getpluginHaloRunV1alpha1Plugin({
name: names.value[i],
});
pluginToUpdate.spec.enabled = enabled;
await apiClient.extension.plugin.updatepluginHaloRunV1alpha1Plugin({
name: pluginToUpdate.metadata.name,
plugin: pluginToUpdate,
});
}
window.location.reload();
} catch (e) {
console.error("Failed to change plugin status in batch", e);
}
},
});
}
return { handleUninstallInBatch, handleChangeStatusInBatch };
}