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.
Files changed (54) hide show
  1. package/client.js +1 -0
  2. package/dist/client/Doctor.d.ts +2 -0
  3. package/dist/client/NginxCacheManager.d.ts +2 -0
  4. package/dist/client/index.js +1 -1
  5. package/dist/client/utils/clientSafeCache.d.ts +3 -0
  6. package/dist/client/utils/requestDedupInterceptor.d.ts +2 -0
  7. package/dist/externalVersion.js +5 -5
  8. package/dist/locale/en-US.json +97 -1
  9. package/dist/locale/vi-VN.json +98 -1
  10. package/dist/locale/zh-CN.json +98 -1
  11. package/dist/server/actions/cache-monitor.d.ts +10 -0
  12. package/dist/server/actions/cache-monitor.js +301 -0
  13. package/dist/server/actions/cluster-nodes.d.ts +15 -0
  14. package/dist/server/actions/cluster-nodes.js +394 -10
  15. package/dist/server/actions/doctor.d.ts +82 -0
  16. package/dist/server/actions/doctor.js +1250 -0
  17. package/dist/server/collections/cluster-manager-doctor-runs.d.ts +3 -0
  18. package/dist/server/collections/cluster-manager-doctor-runs.js +52 -0
  19. package/dist/server/collections/cluster-manager-doctor.d.ts +18 -0
  20. package/dist/server/collections/cluster-manager-doctor.js +44 -0
  21. package/dist/server/hooks/cacheInvalidationHooks.d.ts +1 -0
  22. package/dist/server/hooks/cacheInvalidationHooks.js +81 -0
  23. package/dist/server/middlewares/listMetaCacheMiddleware.d.ts +2 -0
  24. package/dist/server/middlewares/listMetaCacheMiddleware.js +79 -0
  25. package/dist/server/orchestrator/PackageManager.js +20 -16
  26. package/dist/server/plugin.js +61 -8
  27. package/dist/server/utils/versionManager.d.ts +10 -0
  28. package/dist/server/utils/versionManager.js +91 -0
  29. package/package.json +41 -41
  30. package/server.js +1 -0
  31. package/src/client/CacheMonitor.tsx +166 -179
  32. package/src/client/ClusterManagerLayout.tsx +48 -42
  33. package/src/client/ClusterNodes.tsx +691 -418
  34. package/src/client/Doctor.tsx +559 -0
  35. package/src/client/NginxCacheManager.tsx +415 -0
  36. package/src/client/PluginOperations.tsx +234 -234
  37. package/src/client/index.tsx +22 -14
  38. package/src/client/utils/clientSafeCache.ts +41 -0
  39. package/src/client/utils/requestDedupInterceptor.ts +213 -0
  40. package/src/locale/en-US.json +97 -1
  41. package/src/locale/vi-VN.json +98 -1
  42. package/src/locale/zh-CN.json +98 -1
  43. package/src/server/__tests__/doctor.test.ts +53 -0
  44. package/src/server/actions/acl-cache.ts +272 -272
  45. package/src/server/actions/cache-monitor.ts +453 -116
  46. package/src/server/actions/cluster-nodes.ts +882 -378
  47. package/src/server/actions/doctor.ts +1540 -0
  48. package/src/server/collections/cluster-manager-doctor-runs.ts +23 -0
  49. package/src/server/collections/cluster-manager-doctor.ts +19 -0
  50. package/src/server/hooks/cacheInvalidationHooks.ts +58 -0
  51. package/src/server/middlewares/listMetaCacheMiddleware.ts +55 -0
  52. package/src/server/orchestrator/PackageManager.ts +19 -15
  53. package/src/server/plugin.ts +338 -263
  54. 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
+ }
@@ -1,14 +1,22 @@
1
- import { Plugin } from '@nocobase/client';
2
- import { ClusterManagerLayout } from './ClusterManagerLayout';
3
-
4
- export class PluginClusterManagerClient extends Plugin {
5
- async load() {
6
- this.app.pluginSettingsManager.add('plugin-cluster-manager', {
7
- title: '{{t("Cluster Manager")}}',
8
- icon: 'DashboardOutlined',
9
- Component: ClusterManagerLayout,
10
- });
11
- }
12
- }
13
-
14
- export default PluginClusterManagerClient;
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
+ }