fix(plugins): poll runtime state after plugin changes

This commit is contained in:
LTbinglingfeng
2026-06-17 01:10:18 +08:00
parent b0db1dfd5d
commit dbeefda721
7 changed files with 200 additions and 49 deletions
+56 -6
View File
@@ -24,9 +24,11 @@ import {
buildRepositoryURL,
isDefaultPluginStoreSource,
isOfficialPlugin,
notifyPluginResourcesChanged,
resolvePluginAssetURL,
} from './pluginResources';
import { PluginInstallGateModal } from './components/PluginInstallGateModal';
import { waitForPluginStoreState } from './pluginPolling';
import styles from './PluginStorePage.module.scss';
type StoreStatusFilter = 'all' | 'installed' | 'notInstalled' | 'updates';
@@ -260,18 +262,66 @@ export function PluginStorePage() {
setInstallingKey(entryKey);
try {
const result = await pluginStoreApi.install(entry.id, entry.sourceId || undefined);
showNotification(
isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
'success'
clearConfigCache();
const sourceId = result.sourceId || entry.sourceId;
const installedState = await waitForPluginStoreState(
entry.id,
sourceId,
(plugin) => plugin.installed && plugin.configured
);
setData(installedState.response);
if (
installedState.timedOut ||
!installedState.plugin?.installed ||
!installedState.plugin.configured
) {
showNotification(t('plugin_store.status_pending'), 'warning');
return;
}
if (result.restartRequired) {
setRestartRequiredKeys((current) =>
current.includes(entryKey) ? current : [...current, entryKey]
);
showNotification(
isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
'success'
);
showNotification(t('plugin_store.restart_required_notice'), 'warning');
return;
}
clearConfigCache();
await loadStore();
if (!installedState.response.pluginsEnabled) {
showNotification(
isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
'success'
);
showNotification(t('plugin_store.global_disabled_hint'), 'warning');
return;
}
if (installedState.plugin.enabled) {
const registeredState = await waitForPluginStoreState(
entry.id,
sourceId,
(plugin) => plugin.registered && plugin.effectiveEnabled
);
setData(registeredState.response);
if (
registeredState.timedOut ||
!registeredState.plugin?.registered ||
!registeredState.plugin.effectiveEnabled
) {
showNotification(t('plugin_store.registration_pending'), 'warning');
return;
}
notifyPluginResourcesChanged();
}
showNotification(
isUpdate ? t('plugin_store.update_success') : t('plugin_store.install_success'),
'success'
);
} catch (err: unknown) {
showNotification(`${t(failedKey)}: ${getErrorMessage(err, t(failedKey))}`, 'error');
throw err;
@@ -279,7 +329,7 @@ export function PluginStorePage() {
setInstallingKey('');
}
},
[clearConfigCache, loadStore, showNotification, t]
[clearConfigCache, showNotification, t]
);
const handleInstall = (entry: PluginStoreEntry) => {
+58 -43
View File
@@ -32,9 +32,11 @@ import {
notifyPluginResourcesChanged,
resolvePluginAssetURL,
} from './pluginResources';
import { waitForPluginState } from './pluginPolling';
import styles from './PluginsPage.module.scss';
type PluginDraftValue = string | boolean | string[];
type PluginRuntimeWaitStatus = 'ready' | 'globalDisabled' | 'timeout';
interface PluginConfigDraft {
enabled: boolean;
@@ -43,13 +45,6 @@ interface PluginConfigDraft {
errors: Record<string, string>;
}
const PLUGIN_ENABLE_REFRESH_DELAY_MS = 1600;
const wait = (ms: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, ms);
});
function PluginCardLogo({ src }: { src: string }) {
const [failed, setFailed] = useState(false);
const showImage = Boolean(src) && !failed;
@@ -61,11 +56,9 @@ function PluginCardLogo({ src }: { src: string }) {
);
}
const hasStatus = (error: unknown, status: number) =>
isRecord(error) && error.status === status;
const hasStatus = (error: unknown, status: number) => isRecord(error) && error.status === status;
const hasRestartRequired = (value: unknown) =>
isRecord(value) && value.restart_required === true;
const hasRestartRequired = (value: unknown) => isRecord(value) && value.restart_required === true;
const hasRestartRequiredError = (error: unknown) =>
isRecord(error) && (hasRestartRequired(error.details) || hasRestartRequired(error.data));
@@ -104,7 +97,8 @@ const buildDraft = (
plugin: PluginListEntry,
currentConfig: PluginConfigObject
): PluginConfigDraft => {
const enabled = typeof currentConfig.enabled === 'boolean' ? currentConfig.enabled : plugin.enabled;
const enabled =
typeof currentConfig.enabled === 'boolean' ? currentConfig.enabled : plugin.enabled;
const priority =
typeof currentConfig.priority === 'number' || typeof currentConfig.priority === 'string'
? String(currentConfig.priority)
@@ -176,9 +170,7 @@ const buildConfigPayload = (
}
if (fieldType === 'array') {
const items = Array.isArray(value)
? value.map((item) => item.trim()).filter(Boolean)
: [];
const items = Array.isArray(value) ? value.map((item) => item.trim()).filter(Boolean) : [];
if (items.length === 0) {
delete nextConfig[field.name];
} else {
@@ -280,14 +272,20 @@ export function PluginsPage() {
}
}, [connected, t]);
const loadPluginsAfterMutation = useCallback(
async (waitForRegistration: boolean) => {
if (waitForRegistration) {
await wait(PLUGIN_ENABLE_REFRESH_DELAY_MS);
const waitForPluginRuntimeState = useCallback(
async (id: string, enabled: boolean): Promise<PluginRuntimeWaitStatus> => {
const result = await waitForPluginState(id, (item, response) =>
enabled
? !response.pluginsEnabled || (item.registered && item.effectiveEnabled)
: !item.effectiveEnabled
);
setData(result.response);
if (enabled && !result.response.pluginsEnabled) {
return 'globalDisabled';
}
await loadPlugins();
return result.timedOut ? 'timeout' : 'ready';
},
[loadPlugins]
[]
);
useHeaderRefresh(loadPlugins, connected);
@@ -388,9 +386,20 @@ export function PluginsPage() {
try {
await pluginsApi.updateEnabled(plugin.id, enabled);
clearConfigCache();
await loadPluginsAfterMutation(enabled);
notifyPluginResourcesChanged();
showNotification(t('plugin_management.toggle_success'), 'success');
const status = await waitForPluginRuntimeState(plugin.id, enabled);
if (status === 'ready') {
notifyPluginResourcesChanged();
showNotification(t('plugin_management.toggle_success'), 'success');
} else {
showNotification(
t(
status === 'globalDisabled'
? 'plugin_management.global_disabled_hint'
: 'plugin_management.runtime_pending'
),
'warning'
);
}
} catch (err: unknown) {
showNotification(
`${t('plugin_management.toggle_failed')}: ${getErrorMessage(
@@ -424,7 +433,7 @@ export function PluginsPage() {
setEditingConfig({});
setDraft(null);
}
await loadPluginsAfterMutation(false);
await loadPlugins();
notifyPluginResourcesChanged();
showNotification(t('plugin_management.delete_success'), 'success');
if (result.restartRequired) {
@@ -466,14 +475,29 @@ export function PluginsPage() {
try {
await pluginsApi.putConfig(editingPlugin.id, nextConfig);
clearConfigCache();
await loadPluginsAfterMutation(
nextConfig.enabled === true && editingPlugin.enabled !== true
);
notifyPluginResourcesChanged();
const enabledChanged =
typeof nextConfig.enabled === 'boolean' && nextConfig.enabled !== editingPlugin.enabled;
const status = enabledChanged
? await waitForPluginRuntimeState(editingPlugin.id, nextConfig.enabled === true)
: await loadPlugins().then((): PluginRuntimeWaitStatus => 'ready');
if (status === 'ready') {
notifyPluginResourcesChanged();
}
setEditingPlugin(null);
setEditingConfig({});
setDraft(null);
showNotification(t('plugin_management.save_success'), 'success');
if (status === 'ready') {
showNotification(t('plugin_management.save_success'), 'success');
} else {
showNotification(
t(
status === 'globalDisabled'
? 'plugin_management.global_disabled_hint'
: 'plugin_management.runtime_pending'
),
'warning'
);
}
} catch (err: unknown) {
showNotification(
`${t('plugin_management.save_failed')}: ${getErrorMessage(
@@ -505,10 +529,7 @@ export function PluginsPage() {
}));
};
const updateArrayField = (
fieldName: string,
updater: (items: string[]) => string[]
) => {
const updateArrayField = (fieldName: string, updater: (items: string[]) => string[]) => {
updateDraft((current) => {
const currentValue = current.values[fieldName];
const items = Array.isArray(currentValue) ? currentValue : [''];
@@ -571,9 +592,7 @@ export function PluginsPage() {
}
placeholder={t('plugin_management.select_placeholder')}
/>
{field.description ? (
<div className={styles.fieldHint}>{field.description}</div>
) : null}
{field.description ? <div className={styles.fieldHint}>{field.description}</div> : null}
{errorText ? <div className={styles.fieldError}>{errorText}</div> : null}
</div>
);
@@ -639,9 +658,7 @@ export function PluginsPage() {
</div>
))}
</div>
{field.description ? (
<div className={styles.fieldHint}>{field.description}</div>
) : null}
{field.description ? <div className={styles.fieldHint}>{field.description}</div> : null}
{errorText ? <div className={styles.fieldError}>{errorText}</div> : null}
</div>
);
@@ -659,9 +676,7 @@ export function PluginsPage() {
placeholder="{}"
spellCheck={false}
/>
{field.description ? (
<div className={styles.fieldHint}>{field.description}</div>
) : null}
{field.description ? <div className={styles.fieldHint}>{field.description}</div> : null}
{errorText ? <div className={styles.fieldError}>{errorText}</div> : null}
</div>
);
+74
View File
@@ -0,0 +1,74 @@
import { pluginsApi, pluginStoreApi } from '@/services/api';
import type {
PluginListEntry,
PluginListResponse,
PluginStoreEntry,
PluginStoreResponse,
} from '@/types';
const PLUGIN_STATE_TIMEOUT_MS = 15_000;
const PLUGIN_STATE_INTERVAL_MS = 500;
const wait = (ms: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, ms);
});
export interface PluginStateWaitResult {
response: PluginListResponse;
plugin: PluginListEntry | null;
timedOut: boolean;
}
export interface PluginStoreStateWaitResult {
response: PluginStoreResponse;
plugin: PluginStoreEntry | null;
timedOut: boolean;
}
export async function waitForPluginState(
id: string,
predicate: (plugin: PluginListEntry, response: PluginListResponse) => boolean,
timeoutMs = PLUGIN_STATE_TIMEOUT_MS,
intervalMs = PLUGIN_STATE_INTERVAL_MS
): Promise<PluginStateWaitResult> {
const deadline = Date.now() + timeoutMs;
let latest = await pluginsApi.list();
for (;;) {
const plugin = latest.plugins.find((item) => item.id === id) ?? null;
if (plugin && predicate(plugin, latest)) {
return { response: latest, plugin, timedOut: false };
}
if (Date.now() >= deadline) {
return { response: latest, plugin, timedOut: true };
}
await wait(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
latest = await pluginsApi.list();
}
}
export async function waitForPluginStoreState(
id: string,
sourceId: string,
predicate: (plugin: PluginStoreEntry, response: PluginStoreResponse) => boolean,
timeoutMs = PLUGIN_STATE_TIMEOUT_MS,
intervalMs = PLUGIN_STATE_INTERVAL_MS
): Promise<PluginStoreStateWaitResult> {
const deadline = Date.now() + timeoutMs;
let latest = await pluginStoreApi.list();
for (;;) {
const plugin =
latest.plugins.find((item) => item.id === id && (!sourceId || item.sourceId === sourceId)) ??
null;
if (plugin && predicate(plugin, latest)) {
return { response: latest, plugin, timedOut: false };
}
if (Date.now() >= deadline) {
return { response: latest, plugin, timedOut: true };
}
await wait(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
latest = await pluginStoreApi.list();
}
}
+3
View File
@@ -1162,6 +1162,7 @@
"delete_restart_required": "The loaded plugin cannot be removed until the backend restarts.",
"toggle_success": "Plugin status updated",
"toggle_failed": "Failed to update plugin status",
"runtime_pending": "Plugin change was saved, but the runtime state is still pending. Refresh later or check backend logs.",
"save_success": "Plugin config saved",
"save_failed": "Failed to save plugin config",
"validation_failed": "Fix plugin config form errors first",
@@ -1213,6 +1214,8 @@
"update_success": "Plugin updated",
"install_failed": "Failed to install plugin",
"update_failed": "Failed to update plugin",
"status_pending": "Plugin files were updated, but the local status has not refreshed yet. Refresh later or check backend logs.",
"registration_pending": "Plugin files were updated, but registration is still pending. Refresh later or check backend logs.",
"restart_required_notice": "Restart the service to load the new plugin version",
"restart_required_banner": "Restart the service to apply the new version of: {{plugins}}",
"open_repository": "Open repository",
+3
View File
@@ -1149,6 +1149,7 @@
"delete_restart_required": "Загруженный плагин можно удалить только после перезапуска backend.",
"toggle_success": "Статус плагина обновлён",
"toggle_failed": "Не удалось обновить статус плагина",
"runtime_pending": "Изменение плагина сохранено, но состояние runtime ещё обновляется. Обновите страницу позже или проверьте логи backend.",
"save_success": "Конфигурация плагина сохранена",
"save_failed": "Не удалось сохранить конфигурацию плагина",
"validation_failed": "Сначала исправьте ошибки формы конфигурации плагина",
@@ -1200,6 +1201,8 @@
"update_success": "Плагин обновлён",
"install_failed": "Не удалось установить плагин",
"update_failed": "Не удалось обновить плагин",
"status_pending": "Файлы плагина обновлены, но локальный статус ещё не обновился. Обновите страницу позже или проверьте логи backend.",
"registration_pending": "Файлы плагина обновлены, но регистрация ещё выполняется. Обновите страницу позже или проверьте логи backend.",
"restart_required_notice": "Перезапустите службу, чтобы загрузить новую версию плагина",
"restart_required_banner": "Перезапустите службу, чтобы применить новую версию: {{plugins}}",
"open_repository": "Открыть репозиторий",
+3
View File
@@ -1162,6 +1162,7 @@
"delete_restart_required": "已加载的插件需要重启后端后才能移除。",
"toggle_success": "插件状态已更新",
"toggle_failed": "插件状态更新失败",
"runtime_pending": "插件变更已保存,但运行时状态仍在更新中。请稍后刷新或检查后端日志。",
"save_success": "插件配置已保存",
"save_failed": "插件配置保存失败",
"validation_failed": "请先修复插件配置表单错误",
@@ -1213,6 +1214,8 @@
"update_success": "插件更新成功",
"install_failed": "插件安装失败",
"update_failed": "插件更新失败",
"status_pending": "插件文件已更新,但本地状态尚未刷新。请稍后刷新或检查后端日志。",
"registration_pending": "插件文件已更新,但注册仍在进行中。请稍后刷新或检查后端日志。",
"restart_required_notice": "需要重启服务才能加载新的插件版本",
"restart_required_banner": "以下插件需重启服务后才能应用新版本:{{plugins}}",
"open_repository": "打开仓库",
+3
View File
@@ -1188,6 +1188,7 @@
"delete_restart_required": "已載入的插件需要重新啟動後端後才能移除。",
"toggle_success": "插件狀態已更新",
"toggle_failed": "插件狀態更新失敗",
"runtime_pending": "插件變更已儲存,但執行時狀態仍在更新中。請稍後重新整理或檢查後端日誌。",
"save_success": "插件設定已儲存",
"save_failed": "插件設定儲存失敗",
"validation_failed": "請先修復插件設定表單錯誤",
@@ -1239,6 +1240,8 @@
"update_success": "插件更新成功",
"install_failed": "插件安裝失敗",
"update_failed": "插件更新失敗",
"status_pending": "插件檔案已更新,但本機狀態尚未重新整理。請稍後重新整理或檢查後端日誌。",
"registration_pending": "插件檔案已更新,但註冊仍在進行中。請稍後重新整理或檢查後端日誌。",
"restart_required_notice": "需要重新啟動服務才能載入新的插件版本",
"restart_required_banner": "以下插件需重新啟動服務後才能套用新版本:{{plugins}}",
"open_repository": "開啟儲存庫",