plugin-cluster-manager 1.1.10 → 1.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client.js +1 -0
- package/dist/client/Doctor.d.ts +2 -0
- package/dist/client/NginxCacheManager.d.ts +2 -0
- package/dist/client/index.js +1 -1
- package/dist/client/utils/clientSafeCache.d.ts +3 -0
- package/dist/client/utils/requestDedupInterceptor.d.ts +2 -0
- package/dist/externalVersion.js +5 -5
- package/dist/locale/en-US.json +97 -1
- package/dist/locale/vi-VN.json +98 -1
- package/dist/locale/zh-CN.json +98 -1
- package/dist/server/actions/cache-monitor.d.ts +10 -0
- package/dist/server/actions/cache-monitor.js +301 -0
- package/dist/server/actions/cluster-nodes.d.ts +15 -0
- package/dist/server/actions/cluster-nodes.js +394 -10
- package/dist/server/actions/doctor.d.ts +82 -0
- package/dist/server/actions/doctor.js +1250 -0
- package/dist/server/collections/cluster-manager-doctor-runs.d.ts +3 -0
- package/dist/server/collections/cluster-manager-doctor-runs.js +52 -0
- package/dist/server/collections/cluster-manager-doctor.d.ts +18 -0
- package/dist/server/collections/cluster-manager-doctor.js +44 -0
- package/dist/server/hooks/cacheInvalidationHooks.d.ts +1 -0
- package/dist/server/hooks/cacheInvalidationHooks.js +81 -0
- package/dist/server/middlewares/listMetaCacheMiddleware.d.ts +2 -0
- package/dist/server/middlewares/listMetaCacheMiddleware.js +79 -0
- package/dist/server/orchestrator/PackageManager.js +20 -16
- package/dist/server/plugin.js +61 -8
- package/dist/server/utils/versionManager.d.ts +10 -0
- package/dist/server/utils/versionManager.js +91 -0
- package/package.json +41 -41
- package/server.js +1 -0
- package/src/client/CacheMonitor.tsx +166 -179
- package/src/client/ClusterManagerLayout.tsx +48 -42
- package/src/client/ClusterNodes.tsx +691 -418
- package/src/client/Doctor.tsx +559 -0
- package/src/client/NginxCacheManager.tsx +415 -0
- package/src/client/PluginOperations.tsx +234 -234
- package/src/client/index.tsx +22 -14
- package/src/client/utils/clientSafeCache.ts +41 -0
- package/src/client/utils/requestDedupInterceptor.ts +213 -0
- package/src/locale/en-US.json +97 -1
- package/src/locale/vi-VN.json +98 -1
- package/src/locale/zh-CN.json +98 -1
- package/src/server/__tests__/doctor.test.ts +53 -0
- package/src/server/actions/acl-cache.ts +272 -272
- package/src/server/actions/cache-monitor.ts +453 -116
- package/src/server/actions/cluster-nodes.ts +882 -378
- package/src/server/actions/doctor.ts +1540 -0
- package/src/server/collections/cluster-manager-doctor-runs.ts +23 -0
- package/src/server/collections/cluster-manager-doctor.ts +19 -0
- package/src/server/hooks/cacheInvalidationHooks.ts +58 -0
- package/src/server/middlewares/listMetaCacheMiddleware.ts +55 -0
- package/src/server/orchestrator/PackageManager.ts +19 -15
- package/src/server/plugin.ts +338 -263
- package/src/server/utils/versionManager.ts +69 -0
|
@@ -1,234 +1,234 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
-
import { Alert, Button, Input, Popconfirm, Space, Spin, Table, Tag, Typography, message } from 'antd';
|
|
3
|
-
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons';
|
|
4
|
-
import { useAPIClient } from '@nocobase/client';
|
|
5
|
-
import { useT } from './utils';
|
|
6
|
-
|
|
7
|
-
interface PluginRecord {
|
|
8
|
-
name: string;
|
|
9
|
-
packageName?: string;
|
|
10
|
-
displayName?: string;
|
|
11
|
-
description?: string;
|
|
12
|
-
version?: string;
|
|
13
|
-
enabled?: boolean;
|
|
14
|
-
installed?: boolean;
|
|
15
|
-
loaded?: boolean;
|
|
16
|
-
protected?: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function getErrorMessage(err: any, fallback: string) {
|
|
20
|
-
return err?.response?.data?.errors?.[0]?.message || err?.message || fallback;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function PluginOperations() {
|
|
24
|
-
const t = useT();
|
|
25
|
-
const api = useAPIClient();
|
|
26
|
-
const [loading, setLoading] = useState(false);
|
|
27
|
-
const [actionKey, setActionKey] = useState<string | null>(null);
|
|
28
|
-
const [search, setSearch] = useState('');
|
|
29
|
-
const [plugins, setPlugins] = useState<PluginRecord[]>([]);
|
|
30
|
-
|
|
31
|
-
const fetchData = useCallback(async () => {
|
|
32
|
-
setLoading(true);
|
|
33
|
-
try {
|
|
34
|
-
const res = await api.request({ url: 'clusterManagerPlugins:list' });
|
|
35
|
-
const data = Array.isArray(res?.data?.data?.data)
|
|
36
|
-
? res.data.data.data
|
|
37
|
-
: Array.isArray(res?.data?.data)
|
|
38
|
-
? res.data.data
|
|
39
|
-
: Array.isArray(res?.data)
|
|
40
|
-
? res.data
|
|
41
|
-
: [];
|
|
42
|
-
setPlugins(data);
|
|
43
|
-
} catch (err: any) {
|
|
44
|
-
message.error(getErrorMessage(err, t('Failed to load plugins')));
|
|
45
|
-
} finally {
|
|
46
|
-
setLoading(false);
|
|
47
|
-
}
|
|
48
|
-
}, [api, t]);
|
|
49
|
-
|
|
50
|
-
useEffect(() => {
|
|
51
|
-
fetchData();
|
|
52
|
-
}, [fetchData]);
|
|
53
|
-
|
|
54
|
-
const filteredPlugins = useMemo(() => {
|
|
55
|
-
const keyword = search.trim().toLowerCase();
|
|
56
|
-
const list = Array.isArray(plugins) ? plugins : [];
|
|
57
|
-
if (!keyword) return list;
|
|
58
|
-
return list.filter((plugin) =>
|
|
59
|
-
[plugin.name, plugin.packageName, plugin.displayName, plugin.description]
|
|
60
|
-
.filter(Boolean)
|
|
61
|
-
.some((value) => String(value).toLowerCase().includes(keyword)),
|
|
62
|
-
);
|
|
63
|
-
}, [plugins, search]);
|
|
64
|
-
|
|
65
|
-
const handleForceDisable = async (record: PluginRecord) => {
|
|
66
|
-
const key = `${record.name}:disable`;
|
|
67
|
-
setActionKey(key);
|
|
68
|
-
try {
|
|
69
|
-
const res = await api.request({
|
|
70
|
-
url: 'clusterManagerPlugins:forceDisable',
|
|
71
|
-
method: 'post',
|
|
72
|
-
data: { name: record.name },
|
|
73
|
-
});
|
|
74
|
-
message.success(res?.data?.data?.message || res?.data?.message || t('Plugin force disabled'));
|
|
75
|
-
await fetchData();
|
|
76
|
-
} catch (err: any) {
|
|
77
|
-
message.error(getErrorMessage(err, t('Failed to force disable plugin')));
|
|
78
|
-
} finally {
|
|
79
|
-
setActionKey(null);
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
const handleForceRemove = async (record: PluginRecord) => {
|
|
84
|
-
const key = `${record.name}:remove`;
|
|
85
|
-
setActionKey(key);
|
|
86
|
-
try {
|
|
87
|
-
const res = await api.request({
|
|
88
|
-
url: 'clusterManagerPlugins:forceRemove',
|
|
89
|
-
method: 'post',
|
|
90
|
-
data: { name: record.name },
|
|
91
|
-
});
|
|
92
|
-
message.success(res?.data?.data?.message || res?.data?.message || t('Plugin force removed'));
|
|
93
|
-
await fetchData();
|
|
94
|
-
} catch (err: any) {
|
|
95
|
-
message.error(getErrorMessage(err, t('Failed to force remove plugin')));
|
|
96
|
-
} finally {
|
|
97
|
-
setActionKey(null);
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const columns = [
|
|
102
|
-
{
|
|
103
|
-
title: t('Plugin'),
|
|
104
|
-
dataIndex: 'displayName',
|
|
105
|
-
key: 'displayName',
|
|
106
|
-
render: (_: string, record: PluginRecord) => (
|
|
107
|
-
<Space direction="vertical" size={0}>
|
|
108
|
-
<Typography.Text strong>{record.displayName || record.name}</Typography.Text>
|
|
109
|
-
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
110
|
-
{record.packageName || record.name}
|
|
111
|
-
</Typography.Text>
|
|
112
|
-
</Space>
|
|
113
|
-
),
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
title: t('Name'),
|
|
117
|
-
dataIndex: 'name',
|
|
118
|
-
key: 'name',
|
|
119
|
-
width: 220,
|
|
120
|
-
render: (name: string) => <code style={{ fontSize: 12 }}>{name}</code>,
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
title: t('Status'),
|
|
124
|
-
key: 'status',
|
|
125
|
-
width: 260,
|
|
126
|
-
render: (_: any, record: PluginRecord) => (
|
|
127
|
-
<Space wrap size={[4, 4]}>
|
|
128
|
-
<Tag color={record.enabled ? 'green' : 'default'}>
|
|
129
|
-
{record.enabled ? t('Enabled') : t('Disabled')}
|
|
130
|
-
</Tag>
|
|
131
|
-
<Tag color={record.installed ? 'blue' : 'default'}>
|
|
132
|
-
{record.installed ? t('Installed') : t('Not installed')}
|
|
133
|
-
</Tag>
|
|
134
|
-
<Tag color={record.loaded ? 'processing' : 'default'}>
|
|
135
|
-
{record.loaded ? t('Loaded') : t('Not loaded')}
|
|
136
|
-
</Tag>
|
|
137
|
-
{record.protected && <Tag color="red">{t('Protected')}</Tag>}
|
|
138
|
-
</Space>
|
|
139
|
-
),
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
title: t('Version'),
|
|
143
|
-
dataIndex: 'version',
|
|
144
|
-
key: 'version',
|
|
145
|
-
width: 120,
|
|
146
|
-
render: (version: string) => version || '-',
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
title: t('Description'),
|
|
150
|
-
dataIndex: 'description',
|
|
151
|
-
key: 'description',
|
|
152
|
-
ellipsis: true,
|
|
153
|
-
render: (description: string) => description || '-',
|
|
154
|
-
},
|
|
155
|
-
{
|
|
156
|
-
title: t('Actions'),
|
|
157
|
-
key: 'actions',
|
|
158
|
-
width: 250,
|
|
159
|
-
render: (_: any, record: PluginRecord) => (
|
|
160
|
-
<Space>
|
|
161
|
-
<Popconfirm
|
|
162
|
-
title={t('Force disable this plugin?')}
|
|
163
|
-
description={t('This updates the plugin registry directly. Restart or reload is required to fully unload runtime hooks.')}
|
|
164
|
-
disabled={record.protected || !record.enabled}
|
|
165
|
-
onConfirm={() => handleForceDisable(record)}
|
|
166
|
-
okText={t('Force disable')}
|
|
167
|
-
cancelText={t('Cancel')}
|
|
168
|
-
>
|
|
169
|
-
<Button
|
|
170
|
-
size="small"
|
|
171
|
-
icon={<StopOutlined />}
|
|
172
|
-
disabled={record.protected || !record.enabled}
|
|
173
|
-
loading={actionKey === `${record.name}:disable`}
|
|
174
|
-
>
|
|
175
|
-
{t('Force disable')}
|
|
176
|
-
</Button>
|
|
177
|
-
</Popconfirm>
|
|
178
|
-
<Popconfirm
|
|
179
|
-
title={t('Force remove this plugin?')}
|
|
180
|
-
description={t('This removes the plugin registry record. Package files are not deleted. Restart or reload is required.')}
|
|
181
|
-
disabled={record.protected}
|
|
182
|
-
onConfirm={() => handleForceRemove(record)}
|
|
183
|
-
okText={t('Force remove')}
|
|
184
|
-
cancelText={t('Cancel')}
|
|
185
|
-
>
|
|
186
|
-
<Button
|
|
187
|
-
size="small"
|
|
188
|
-
danger
|
|
189
|
-
icon={<DeleteOutlined />}
|
|
190
|
-
disabled={record.protected}
|
|
191
|
-
loading={actionKey === `${record.name}:remove`}
|
|
192
|
-
>
|
|
193
|
-
{t('Force remove')}
|
|
194
|
-
</Button>
|
|
195
|
-
</Popconfirm>
|
|
196
|
-
</Space>
|
|
197
|
-
),
|
|
198
|
-
},
|
|
199
|
-
];
|
|
200
|
-
|
|
201
|
-
return (
|
|
202
|
-
<Spin spinning={loading}>
|
|
203
|
-
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
204
|
-
<Alert
|
|
205
|
-
type="warning"
|
|
206
|
-
showIcon
|
|
207
|
-
icon={<ExclamationCircleOutlined />}
|
|
208
|
-
message={t('Force operations bypass plugin lifecycle hooks')}
|
|
209
|
-
description={t('Use this only when the normal plugin manager cannot disable or remove a broken plugin. Restart or reload the app after a successful operation.')}
|
|
210
|
-
/>
|
|
211
|
-
<Space>
|
|
212
|
-
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
|
213
|
-
{t('Refresh')}
|
|
214
|
-
</Button>
|
|
215
|
-
<Input.Search
|
|
216
|
-
allowClear
|
|
217
|
-
placeholder={t('Search plugins')}
|
|
218
|
-
value={search}
|
|
219
|
-
onChange={(event) => setSearch(event.target.value)}
|
|
220
|
-
style={{ width: 320 }}
|
|
221
|
-
/>
|
|
222
|
-
</Space>
|
|
223
|
-
<Table
|
|
224
|
-
rowKey="name"
|
|
225
|
-
size="small"
|
|
226
|
-
dataSource={filteredPlugins}
|
|
227
|
-
columns={columns}
|
|
228
|
-
pagination={{ pageSize: 20 }}
|
|
229
|
-
scroll={{ x: 1100 }}
|
|
230
|
-
/>
|
|
231
|
-
</Space>
|
|
232
|
-
</Spin>
|
|
233
|
-
);
|
|
234
|
-
}
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Alert, Button, Input, Popconfirm, Space, Spin, Table, Tag, Typography, message } from 'antd';
|
|
3
|
+
import { DeleteOutlined, ExclamationCircleOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons';
|
|
4
|
+
import { useAPIClient } from '@nocobase/client';
|
|
5
|
+
import { useT } from './utils';
|
|
6
|
+
|
|
7
|
+
interface PluginRecord {
|
|
8
|
+
name: string;
|
|
9
|
+
packageName?: string;
|
|
10
|
+
displayName?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
version?: string;
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
installed?: boolean;
|
|
15
|
+
loaded?: boolean;
|
|
16
|
+
protected?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getErrorMessage(err: any, fallback: string) {
|
|
20
|
+
return err?.response?.data?.errors?.[0]?.message || err?.message || fallback;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function PluginOperations() {
|
|
24
|
+
const t = useT();
|
|
25
|
+
const api = useAPIClient();
|
|
26
|
+
const [loading, setLoading] = useState(false);
|
|
27
|
+
const [actionKey, setActionKey] = useState<string | null>(null);
|
|
28
|
+
const [search, setSearch] = useState('');
|
|
29
|
+
const [plugins, setPlugins] = useState<PluginRecord[]>([]);
|
|
30
|
+
|
|
31
|
+
const fetchData = useCallback(async () => {
|
|
32
|
+
setLoading(true);
|
|
33
|
+
try {
|
|
34
|
+
const res = await api.request({ url: 'clusterManagerPlugins:list' });
|
|
35
|
+
const data = Array.isArray(res?.data?.data?.data)
|
|
36
|
+
? res.data.data.data
|
|
37
|
+
: Array.isArray(res?.data?.data)
|
|
38
|
+
? res.data.data
|
|
39
|
+
: Array.isArray(res?.data)
|
|
40
|
+
? res.data
|
|
41
|
+
: [];
|
|
42
|
+
setPlugins(data);
|
|
43
|
+
} catch (err: any) {
|
|
44
|
+
message.error(getErrorMessage(err, t('Failed to load plugins')));
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
}, [api, t]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
fetchData();
|
|
52
|
+
}, [fetchData]);
|
|
53
|
+
|
|
54
|
+
const filteredPlugins = useMemo(() => {
|
|
55
|
+
const keyword = search.trim().toLowerCase();
|
|
56
|
+
const list = Array.isArray(plugins) ? plugins : [];
|
|
57
|
+
if (!keyword) return list;
|
|
58
|
+
return list.filter((plugin) =>
|
|
59
|
+
[plugin.name, plugin.packageName, plugin.displayName, plugin.description]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.some((value) => String(value).toLowerCase().includes(keyword)),
|
|
62
|
+
);
|
|
63
|
+
}, [plugins, search]);
|
|
64
|
+
|
|
65
|
+
const handleForceDisable = async (record: PluginRecord) => {
|
|
66
|
+
const key = `${record.name}:disable`;
|
|
67
|
+
setActionKey(key);
|
|
68
|
+
try {
|
|
69
|
+
const res = await api.request({
|
|
70
|
+
url: 'clusterManagerPlugins:forceDisable',
|
|
71
|
+
method: 'post',
|
|
72
|
+
data: { name: record.name },
|
|
73
|
+
});
|
|
74
|
+
message.success(res?.data?.data?.message || res?.data?.message || t('Plugin force disabled'));
|
|
75
|
+
await fetchData();
|
|
76
|
+
} catch (err: any) {
|
|
77
|
+
message.error(getErrorMessage(err, t('Failed to force disable plugin')));
|
|
78
|
+
} finally {
|
|
79
|
+
setActionKey(null);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handleForceRemove = async (record: PluginRecord) => {
|
|
84
|
+
const key = `${record.name}:remove`;
|
|
85
|
+
setActionKey(key);
|
|
86
|
+
try {
|
|
87
|
+
const res = await api.request({
|
|
88
|
+
url: 'clusterManagerPlugins:forceRemove',
|
|
89
|
+
method: 'post',
|
|
90
|
+
data: { name: record.name },
|
|
91
|
+
});
|
|
92
|
+
message.success(res?.data?.data?.message || res?.data?.message || t('Plugin force removed'));
|
|
93
|
+
await fetchData();
|
|
94
|
+
} catch (err: any) {
|
|
95
|
+
message.error(getErrorMessage(err, t('Failed to force remove plugin')));
|
|
96
|
+
} finally {
|
|
97
|
+
setActionKey(null);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const columns = [
|
|
102
|
+
{
|
|
103
|
+
title: t('Plugin'),
|
|
104
|
+
dataIndex: 'displayName',
|
|
105
|
+
key: 'displayName',
|
|
106
|
+
render: (_: string, record: PluginRecord) => (
|
|
107
|
+
<Space direction="vertical" size={0}>
|
|
108
|
+
<Typography.Text strong>{record.displayName || record.name}</Typography.Text>
|
|
109
|
+
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
|
110
|
+
{record.packageName || record.name}
|
|
111
|
+
</Typography.Text>
|
|
112
|
+
</Space>
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
title: t('Name'),
|
|
117
|
+
dataIndex: 'name',
|
|
118
|
+
key: 'name',
|
|
119
|
+
width: 220,
|
|
120
|
+
render: (name: string) => <code style={{ fontSize: 12 }}>{name}</code>,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
title: t('Status'),
|
|
124
|
+
key: 'status',
|
|
125
|
+
width: 260,
|
|
126
|
+
render: (_: any, record: PluginRecord) => (
|
|
127
|
+
<Space wrap size={[4, 4]}>
|
|
128
|
+
<Tag color={record.enabled ? 'green' : 'default'}>
|
|
129
|
+
{record.enabled ? t('Enabled') : t('Disabled')}
|
|
130
|
+
</Tag>
|
|
131
|
+
<Tag color={record.installed ? 'blue' : 'default'}>
|
|
132
|
+
{record.installed ? t('Installed') : t('Not installed')}
|
|
133
|
+
</Tag>
|
|
134
|
+
<Tag color={record.loaded ? 'processing' : 'default'}>
|
|
135
|
+
{record.loaded ? t('Loaded') : t('Not loaded')}
|
|
136
|
+
</Tag>
|
|
137
|
+
{record.protected && <Tag color="red">{t('Protected')}</Tag>}
|
|
138
|
+
</Space>
|
|
139
|
+
),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
title: t('Version'),
|
|
143
|
+
dataIndex: 'version',
|
|
144
|
+
key: 'version',
|
|
145
|
+
width: 120,
|
|
146
|
+
render: (version: string) => version || '-',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
title: t('Description'),
|
|
150
|
+
dataIndex: 'description',
|
|
151
|
+
key: 'description',
|
|
152
|
+
ellipsis: true,
|
|
153
|
+
render: (description: string) => description || '-',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
title: t('Actions'),
|
|
157
|
+
key: 'actions',
|
|
158
|
+
width: 250,
|
|
159
|
+
render: (_: any, record: PluginRecord) => (
|
|
160
|
+
<Space>
|
|
161
|
+
<Popconfirm
|
|
162
|
+
title={t('Force disable this plugin?')}
|
|
163
|
+
description={t('This updates the plugin registry directly. Restart or reload is required to fully unload runtime hooks.')}
|
|
164
|
+
disabled={record.protected || !record.enabled}
|
|
165
|
+
onConfirm={() => handleForceDisable(record)}
|
|
166
|
+
okText={t('Force disable')}
|
|
167
|
+
cancelText={t('Cancel')}
|
|
168
|
+
>
|
|
169
|
+
<Button
|
|
170
|
+
size="small"
|
|
171
|
+
icon={<StopOutlined />}
|
|
172
|
+
disabled={record.protected || !record.enabled}
|
|
173
|
+
loading={actionKey === `${record.name}:disable`}
|
|
174
|
+
>
|
|
175
|
+
{t('Force disable')}
|
|
176
|
+
</Button>
|
|
177
|
+
</Popconfirm>
|
|
178
|
+
<Popconfirm
|
|
179
|
+
title={t('Force remove this plugin?')}
|
|
180
|
+
description={t('This removes the plugin registry record. Package files are not deleted. Restart or reload is required.')}
|
|
181
|
+
disabled={record.protected}
|
|
182
|
+
onConfirm={() => handleForceRemove(record)}
|
|
183
|
+
okText={t('Force remove')}
|
|
184
|
+
cancelText={t('Cancel')}
|
|
185
|
+
>
|
|
186
|
+
<Button
|
|
187
|
+
size="small"
|
|
188
|
+
danger
|
|
189
|
+
icon={<DeleteOutlined />}
|
|
190
|
+
disabled={record.protected}
|
|
191
|
+
loading={actionKey === `${record.name}:remove`}
|
|
192
|
+
>
|
|
193
|
+
{t('Force remove')}
|
|
194
|
+
</Button>
|
|
195
|
+
</Popconfirm>
|
|
196
|
+
</Space>
|
|
197
|
+
),
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<Spin spinning={loading}>
|
|
203
|
+
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
|
204
|
+
<Alert
|
|
205
|
+
type="warning"
|
|
206
|
+
showIcon
|
|
207
|
+
icon={<ExclamationCircleOutlined />}
|
|
208
|
+
message={t('Force operations bypass plugin lifecycle hooks')}
|
|
209
|
+
description={t('Use this only when the normal plugin manager cannot disable or remove a broken plugin. Restart or reload the app after a successful operation.')}
|
|
210
|
+
/>
|
|
211
|
+
<Space>
|
|
212
|
+
<Button icon={<ReloadOutlined />} onClick={fetchData}>
|
|
213
|
+
{t('Refresh')}
|
|
214
|
+
</Button>
|
|
215
|
+
<Input.Search
|
|
216
|
+
allowClear
|
|
217
|
+
placeholder={t('Search plugins')}
|
|
218
|
+
value={search}
|
|
219
|
+
onChange={(event) => setSearch(event.target.value)}
|
|
220
|
+
style={{ width: 320 }}
|
|
221
|
+
/>
|
|
222
|
+
</Space>
|
|
223
|
+
<Table
|
|
224
|
+
rowKey="name"
|
|
225
|
+
size="small"
|
|
226
|
+
dataSource={filteredPlugins}
|
|
227
|
+
columns={columns}
|
|
228
|
+
pagination={{ pageSize: 20 }}
|
|
229
|
+
scroll={{ x: 1100 }}
|
|
230
|
+
/>
|
|
231
|
+
</Space>
|
|
232
|
+
</Spin>
|
|
233
|
+
);
|
|
234
|
+
}
|
package/src/client/index.tsx
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
-
import { Plugin } from '@nocobase/client';
|
|
2
|
-
import { ClusterManagerLayout } from './ClusterManagerLayout';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
import { Plugin } from '@nocobase/client';
|
|
2
|
+
import { ClusterManagerLayout } from './ClusterManagerLayout';
|
|
3
|
+
import { setupClientSafeCache } from './utils/clientSafeCache';
|
|
4
|
+
import { setupRequestDedupAndCache } from './utils/requestDedupInterceptor';
|
|
5
|
+
|
|
6
|
+
export class PluginClusterManagerClient extends Plugin {
|
|
7
|
+
async load() {
|
|
8
|
+
// Initialize safe sessionStorage clean-ups on role / token change
|
|
9
|
+
setupClientSafeCache(this.app.apiClient);
|
|
10
|
+
|
|
11
|
+
// Initialize in-flight request deduplication and safe storage
|
|
12
|
+
setupRequestDedupAndCache(this.app.apiClient);
|
|
13
|
+
|
|
14
|
+
this.app.pluginSettingsManager.add('plugin-cluster-manager', {
|
|
15
|
+
title: '{{t("Cluster Manager")}}',
|
|
16
|
+
icon: 'DashboardOutlined',
|
|
17
|
+
Component: ClusterManagerLayout,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default PluginClusterManagerClient;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { APIClient } from '@nocobase/client';
|
|
2
|
+
|
|
3
|
+
export function clearClientCache() {
|
|
4
|
+
const keysToRemove: string[] = [];
|
|
5
|
+
for (let i = 0; i < sessionStorage.length; i++) {
|
|
6
|
+
const key = sessionStorage.key(i);
|
|
7
|
+
if (key && key.startsWith('nb_cache:')) {
|
|
8
|
+
keysToRemove.push(key);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
keysToRemove.forEach((key) => {
|
|
12
|
+
try {
|
|
13
|
+
sessionStorage.removeItem(key);
|
|
14
|
+
} catch {
|
|
15
|
+
// Ignore sessionStorage block/quota errors
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setupClientSafeCache(apiClient: APIClient) {
|
|
21
|
+
if (!apiClient?.auth) return;
|
|
22
|
+
|
|
23
|
+
// Clear cache on role changes
|
|
24
|
+
const originalSetRole = apiClient.auth.setRole.bind(apiClient.auth);
|
|
25
|
+
apiClient.auth.setRole = (role: string) => {
|
|
26
|
+
clearClientCache();
|
|
27
|
+
return originalSetRole(role);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Clear cache on token changes
|
|
31
|
+
const originalSetToken = apiClient.auth.setToken.bind(apiClient.auth);
|
|
32
|
+
apiClient.auth.setToken = (token: string) => {
|
|
33
|
+
clearClientCache();
|
|
34
|
+
return originalSetToken(token);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Listen to application auth:tokenChanged event
|
|
38
|
+
apiClient.app?.eventBus?.addEventListener('auth:tokenChanged', () => {
|
|
39
|
+
clearClientCache();
|
|
40
|
+
});
|
|
41
|
+
}
|