mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-24 16:04:35 +08:00
fix(plugins): poll runtime state after plugin changes
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Открыть репозиторий",
|
||||
|
||||
@@ -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": "打开仓库",
|
||||
|
||||
@@ -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": "開啟儲存庫",
|
||||
|
||||
Reference in New Issue
Block a user