fix(plugins): block updates while connections are active

This commit is contained in:
t8y2 committed 2026-09-17 22:32:47 +08:00
1 parent c040f9c9c3
commit d1a2d51351
15 files changed
+571 -40

No files matched your search

@@ -204,7 +204,7 @@ async function installMarketplaceListing(listing: MarketplacePluginListing) {
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 8000);
toast(translateBackendError(t, cause), 8000);
// A failed install can still have mutated the store (a partially replaced version directory, for
// instance), so re-read the installed list instead of leaving the card on state it may no longer
// describe.
@@ -218,8 +218,9 @@ function batchSummaryKey(outcome: { succeeded: unknown[]; failed: { name: string
return outcome.failed.length ? "pluginPlatform.batchSummaryWithFailures" : "pluginPlatform.batchSummary";
}
function reportBatchSummary(outcome: { succeeded: unknown[]; failed: { name: string }[] }) {
function reportBatchSummary(outcome: { succeeded: unknown[]; failed: { name: string; error: string }[] }) {
const failedNames = outcome.failed.map((failure) => failure.name).join("、");
error.value = outcome.failed.map((failure) => `${failure.name}: ${translateBackendError(t, failure.error)}`).join("\n");
toast(t(batchSummaryKey(outcome), { success: outcome.succeeded.length, failed: outcome.failed.length, names: failedNames }), outcome.failed.length ? 8000 : 4000);
}
@@ -227,7 +228,7 @@ async function refreshAfterBatch() {
try {
installedPlugins.value = await api.listPlugins();
} catch (cause) {
error.value = t("pluginPlatform.batchRefreshFailed", { error: cause instanceof Error ? cause.message : String(cause) });
error.value = [error.value, t("pluginPlatform.batchRefreshFailed", { error: cause instanceof Error ? cause.message : String(cause) })].filter(Boolean).join("\n");
}
}
@@ -661,7 +662,7 @@ async function rollbackSelectedPlugin() {
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 5000);
toast(translateBackendError(t, cause), 8000);
} finally {
operating.value = false;
}
@@ -732,7 +733,7 @@ onBeforeUnmount(() => {
<template>
<div ref="panelRootRef" class="plugin-center-view relative mx-auto flex h-full w-full max-w-6xl flex-col gap-4 overflow-hidden px-6 py-6" @dragenter="onWebDragEnter" @dragover="onWebDragOver" @dragleave="onWebDragLeave" @drop="onWebDrop">
<input ref="webFileInput" type="file" accept=".dbxp" class="hidden" @change="handleWebPackage" />
<div v-if="error" class="shrink-0 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ error }}</div>
<div v-if="error" class="shrink-0 whitespace-pre-wrap rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ error }}</div>
<Tabs v-model="activeSection" class="min-h-0 flex-1 gap-3">
<TabsList class="grid h-9 w-full grid-cols-3">
@@ -366,6 +366,33 @@ describe("PluginContributionsPanel mutation exclusion", () => {
});
describe("PluginContributionsPanel completed batch outcomes", () => {
it.each(["marketplace", "package", "url", "rollback"] as const)("shows related connection names when %s is blocked", async (entry) => {
const blocked = new Error("Plugin update blocked by active connections: Production S3");
const mutations = {
marketplace: mocks.installMarketplacePlugin,
package: mocks.installPluginPackage,
url: mocks.installPluginPackageFromUrl,
rollback: mocks.rollbackPlugin,
};
mutations[entry].mockRejectedValueOnce(blocked);
if (entry === "marketplace") await state.installMarketplaceListing(state.marketplaceListings[0]);
else if (entry === "package") await state.installPlugin("plugin.dbxp");
else if (entry === "url") await state.installPluginFromUrl();
else await state.rollbackSelectedPlugin();
expect(mocks.toast).toHaveBeenLastCalledWith('pluginPlatform.updateBlockedByConnections:{"labels":"Production S3"}', 8000);
expect(mocks.listPlugins).toHaveBeenCalledTimes(entry === "marketplace" ? 1 : 0);
});
it("shows per-plugin blockers while continuing other batch updates", async () => {
state.selectAllUpdatable();
mocks.installMarketplacePlugin.mockRejectedValueOnce(new Error("Plugin update blocked by active connections: Production S3"));
await state.runBatchInstallUpdate();
expect(mocks.installMarketplacePlugin).toHaveBeenCalledTimes(3);
expect(state.error).toBe('a: pluginPlatform.updateBlockedByConnections:{"labels":"Production S3"}');
await nextTick();
expect(host.textContent).toContain("Production S3");
});
it.each(batches.flatMap((batch) => [0, 1, 3].map((failures) => ({ batch, failures }))))("preserves batch $batch outcomes with $failures failures when refresh rejects", async ({ batch, failures }) => {
state.selectAllUpdatable();
state.selectedInstalledIds = new Set(["a", "b", "c"]);
@@ -390,7 +417,7 @@ describe("PluginContributionsPanel completed batch outcomes", () => {
expect(mutation).toHaveBeenCalledTimes(3);
expect(mocks.toast).toHaveBeenCalledTimes(1);
expect(mocks.toast).toHaveBeenLastCalledWith(summary, failures ? 8000 : 4000);
expect(state.error).toBe('pluginPlatform.batchRefreshFailed:{"error":"refresh offline"}');
expect(state.error).toBe([...["a", "b", "c"].slice(0, failures).map((name) => `${name}: denied`), 'pluginPlatform.batchRefreshFailed:{"error":"refresh offline"}'].join("\n"));
await nextTick();
expect(host.textContent).toContain(state.error);
expect(state.batchRunning).toBe(false);
@@ -414,7 +441,7 @@ describe("PluginContributionsPanel completed batch outcomes", () => {
expect(mutation.mock.calls.map(([request]) => (batch === "install" ? request.pluginId : request))).toEqual(["a", "b", "c"]);
expect(mocks.listPlugins).toHaveBeenCalledTimes(1);
expect(mocks.toast).toHaveBeenLastCalledWith('pluginPlatform.batchSummaryWithFailures:{"success":2,"failed":1,"names":"b"}', 8000);
expect(state.error).toBe("");
expect(state.error).toBe("b: denied");
expect(state.batchRunning).toBe(false);
expect(state.selectedListingKeys.size).toBe(0);
expect(state.selectedInstalledIds.size).toBe(0);
+16
View File
@@ -60,6 +60,22 @@ const WINDOWS_JRE_REMOVE_ERROR = [
// Every backend message changed away from hardcoded Chinese, paired with the
// key and params it must resolve to.
const CASES: { name: string; message: string; key: string; params?: Record<string, string> }[] = [
{
name: "plugin update requires closing related connections",
message: "Plugin update blocked by active connections: Production S3, Local storage",
key: "pluginPlatform.updateBlockedByConnections",
params: { labels: "Production S3, Local storage" },
},
{
name: "plugin update waits for active operations",
message: "Plugin update blocked by active operations. Please wait for them to finish.",
key: "pluginPlatform.updateBlockedByOperations",
},
{
name: "connection admission waits for plugin update",
message: "Plugin update is in progress. Please try again after it finishes.",
key: "pluginPlatform.updateInProgress",
},
{
name: "Nacos ordinary user must configure managed namespaces when namespace discovery is forbidden",
message: "Failed to list Nacos namespaces: NACOS_ERROR[v3ManagedNamespacesRequired]: access denied",
+4
View File
@@ -82,11 +82,14 @@ const taggedAiCliErrorKeys: Record<string, string> = {
};
const exactMessageKeys: Record<string, string> = {
"Plugin update is in progress. Please try again after it finishes.": "pluginPlatform.updateInProgress",
"Plugin update blocked by active operations. Please wait for them to finish.": "pluginPlatform.updateBlockedByOperations",
[PHOENIX_DRIVER_NOT_INSTALLED_ERROR]: "connection.phoenixDriverNotInstalled",
[PHOENIX_JDBC_PLUGIN_NOT_INSTALLED_ERROR]: "connection.phoenixDriverNotInstalled",
};
const patterns: [RegExp, string][] = [
[/^Plugin update blocked by active connections: ([\s\S]+)$/, "pluginPlatform.updateBlockedByConnections"],
[/^(.+?) driver is not installed\. Please install it from the Driver Manager\.$/, "connection.driverNotInstalled"],
[/^JRE (.+?) runtime is not installed\. Please install it from the Driver Manager\.$/, "connection.jreNotInstalled"],
[/^System Java runtime was not found on PATH\. Please install Java or choose a custom Java executable\.$/, "connection.systemJavaNotFound"],
@@ -164,6 +167,7 @@ const patterns: [RegExp, string][] = [
// Named placeholders for each pattern's capture groups, in capture order.
// A bare string is shorthand for a single capture group.
const paramNames: Record<string, string | string[]> = {
"pluginPlatform.updateBlockedByConnections": "labels",
"connection.driverNotInstalled": "driver",
"connection.jreNotInstalled": "jre",
"ai.configNameExists": "name",
+3
View File
@@ -195,6 +195,9 @@ export default {
unsupported: "Unsupported",
},
updateSuccess: "Updated {name} to {version}",
updateBlockedByConnections: "Close these connections before updating or rolling back the plugin: {labels}",
updateBlockedByOperations: "Wait for this plugin's active operations to finish before updating or rolling back.",
updateInProgress: "This plugin is being updated. Try again after the update finishes.",
noInstalledPlugins: "No plugins installed",
browseMarketplace: "Browse Marketplace",
localInstallTitle: "Install a plugin package",
+3
View File
@@ -115,6 +115,9 @@ export default withEnglishFallback({
pluginHomepage: "主页",
marketplaceStatus: { install: "安装", installed: "已安装", update: "更新", unsupported: "不支持" },
updateSuccess: "已将 {name} 更新到 {version}",
updateBlockedByConnections: "请先关闭以下连接后再更新或回滚插件:{labels}",
updateBlockedByOperations: "该插件仍有操作正在执行,请等待完成后再更新或回滚。",
updateInProgress: "该插件正在更新,请等待完成后重试。",
noInstalledPlugins: "尚未安装插件",
browseMarketplace: "浏览插件商店",
localInstallTitle: "安装插件包",
+3
View File
@@ -193,6 +193,9 @@ export default withEnglishFallback({
pluginHomepage: "主頁",
marketplaceStatus: { install: "安裝", installed: "已安裝", update: "更新", unsupported: "不支援" },
updateSuccess: "已將 {name} 更新至 {version}",
updateBlockedByConnections: "請先關閉以下連線後再更新或回復外掛:{labels}",
updateBlockedByOperations: "此外掛仍有操作正在執行,請等待完成後再更新或回復。",
updateInProgress: "此外掛正在更新,請等待完成後重試。",
noInstalledPlugins: "尚未安裝外掛",
browseMarketplace: "瀏覽外掛商店",
localInstallTitle: "安裝外掛套件",