diff --git a/src/features/plugins/PluginStorePage.tsx b/src/features/plugins/PluginStorePage.tsx index d336cf83..f2743787 100644 --- a/src/features/plugins/PluginStorePage.tsx +++ b/src/features/plugins/PluginStorePage.tsx @@ -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) => { diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index e63bb59f..32c0bb46 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -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; } -const PLUGIN_ENABLE_REFRESH_DELAY_MS = 1600; - -const wait = (ms: number) => - new Promise((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 => { + 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 ? ( -
{field.description}
- ) : null} + {field.description ?
{field.description}
: null} {errorText ?
{errorText}
: null} ); @@ -639,9 +658,7 @@ export function PluginsPage() { ))} - {field.description ? ( -
{field.description}
- ) : null} + {field.description ?
{field.description}
: null} {errorText ?
{errorText}
: null} ); @@ -659,9 +676,7 @@ export function PluginsPage() { placeholder="{}" spellCheck={false} /> - {field.description ? ( -
{field.description}
- ) : null} + {field.description ?
{field.description}
: null} {errorText ?
{errorText}
: null} ); diff --git a/src/features/plugins/pluginPolling.ts b/src/features/plugins/pluginPolling.ts new file mode 100644 index 00000000..b12fe123 --- /dev/null +++ b/src/features/plugins/pluginPolling.ts @@ -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((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 { + 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 { + 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(); + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index da8e2378..7ed094d2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 13ee47c5..68d963f2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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": "Открыть репозиторий", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 097da25e..9c4432b5 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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": "打开仓库", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index a81c7e92..414391fa 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -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": "開啟儲存庫",