plugin-cluster-manager 1.1.10 → 1.1.13

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 (119) hide show
  1. package/client-v2.d.ts +2 -0
  2. package/client-v2.js +1 -0
  3. package/client.js +1 -0
  4. package/dist/client/index.js +1 -1
  5. package/dist/client-v2/914.5dc1105cf3ada6a6.js +10 -0
  6. package/dist/client-v2/index.js +10 -0
  7. package/dist/externalVersion.js +6 -5
  8. package/dist/locale/en-US.json +138 -28
  9. package/dist/locale/vi-VN.json +139 -28
  10. package/dist/locale/zh-CN.json +140 -28
  11. package/dist/server/actions/cache-monitor.js +301 -0
  12. package/dist/server/actions/cluster-nodes.js +391 -11
  13. package/dist/server/actions/doctor.js +1246 -0
  14. package/dist/server/actions/orchestrator.js +37 -0
  15. package/dist/server/actions/queue-mappings.js +107 -0
  16. package/dist/server/collections/cluster-manager-doctor-runs.js +52 -0
  17. package/dist/server/collections/cluster-manager-doctor.js +44 -0
  18. package/dist/server/collections/worker-queue-mappings.js +106 -0
  19. package/dist/server/hooks/cacheInvalidationHooks.js +81 -0
  20. package/dist/server/middlewares/listMetaCacheMiddleware.js +79 -0
  21. package/dist/server/orchestrator/PackageManager.js +21 -24
  22. package/dist/server/orchestrator/docker-adapter.js +49 -27
  23. package/dist/server/plugin.js +71 -16
  24. package/dist/server/queue-scanner.js +141 -0
  25. package/dist/server/utils/node.js +30 -2
  26. package/dist/server/utils/versionManager.js +91 -0
  27. package/package.json +9 -5
  28. package/server.js +1 -0
  29. package/src/client/AclCacheManager.tsx +292 -287
  30. package/src/client/CacheMonitor.tsx +166 -179
  31. package/src/client/ClusterManagerLayout.tsx +54 -42
  32. package/src/client/ClusterNodes.tsx +698 -418
  33. package/src/client/ContainerOrchestrator.tsx +184 -102
  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/QueueAssignment.tsx +355 -0
  38. package/src/client/TaskManager.tsx +194 -187
  39. package/src/client/WorkflowExecutions.tsx +243 -238
  40. package/src/client/index.tsx +22 -14
  41. package/src/client/utils/clientSafeCache.ts +41 -0
  42. package/src/client/utils/requestDedupInterceptor.ts +213 -0
  43. package/src/client-v2/plugin.tsx +24 -0
  44. package/src/locale/en-US.json +138 -28
  45. package/src/locale/vi-VN.json +139 -28
  46. package/src/locale/zh-CN.json +140 -28
  47. package/src/server/__tests__/doctor.test.ts +53 -0
  48. package/src/server/actions/acl-cache.ts +272 -272
  49. package/src/server/actions/cache-monitor.ts +453 -116
  50. package/src/server/actions/cluster-nodes.ts +878 -378
  51. package/src/server/actions/doctor.ts +1536 -0
  52. package/src/server/actions/orchestrator.ts +54 -2
  53. package/src/server/actions/queue-mappings.ts +94 -0
  54. package/src/server/collections/cluster-manager-doctor-runs.ts +23 -0
  55. package/src/server/collections/cluster-manager-doctor.ts +19 -0
  56. package/src/server/collections/worker-queue-mappings.ts +85 -0
  57. package/src/server/hooks/cacheInvalidationHooks.ts +58 -0
  58. package/src/server/middlewares/listMetaCacheMiddleware.ts +55 -0
  59. package/src/server/orchestrator/PackageManager.ts +20 -24
  60. package/src/server/orchestrator/docker-adapter.ts +74 -37
  61. package/src/server/plugin.ts +347 -270
  62. package/src/server/queue-scanner.ts +154 -0
  63. package/src/server/utils/node.ts +48 -0
  64. package/src/server/utils/versionManager.ts +69 -0
  65. package/dist/client/AclCacheManager.d.ts +0 -2
  66. package/dist/client/CacheMonitor.d.ts +0 -2
  67. package/dist/client/ClusterManagerLayout.d.ts +0 -2
  68. package/dist/client/ClusterNodes.d.ts +0 -2
  69. package/dist/client/ContainerOrchestrator.d.ts +0 -2
  70. package/dist/client/EventQueueMonitor.d.ts +0 -2
  71. package/dist/client/LockMonitor.d.ts +0 -2
  72. package/dist/client/PackageInstaller.d.ts +0 -2
  73. package/dist/client/PluginOperations.d.ts +0 -2
  74. package/dist/client/RedisMonitor.d.ts +0 -2
  75. package/dist/client/TaskManager.d.ts +0 -2
  76. package/dist/client/WorkflowExecutions.d.ts +0 -2
  77. package/dist/client/index.d.ts +0 -5
  78. package/dist/client/utils.d.ts +0 -12
  79. package/dist/index.d.ts +0 -2
  80. package/dist/server/actions/acl-cache.d.ts +0 -53
  81. package/dist/server/actions/cache-monitor.d.ts +0 -23
  82. package/dist/server/actions/cluster-nodes.d.ts +0 -49
  83. package/dist/server/actions/event-queue-monitor.d.ts +0 -13
  84. package/dist/server/actions/lock-monitor.d.ts +0 -19
  85. package/dist/server/actions/orchestrator.d.ts +0 -58
  86. package/dist/server/actions/package-manager.d.ts +0 -6
  87. package/dist/server/actions/plugin-operations.d.ts +0 -6
  88. package/dist/server/actions/redis-monitor.d.ts +0 -12
  89. package/dist/server/actions/tasks.d.ts +0 -7
  90. package/dist/server/actions/workflow-executions.d.ts +0 -7
  91. package/dist/server/adapters/redis-lock-adapter.d.ts +0 -15
  92. package/dist/server/adapters/redis-node-registry.d.ts +0 -12
  93. package/dist/server/adapters/redis-pubsub-adapter.d.ts +0 -16
  94. package/dist/server/collections/app.d.ts +0 -8
  95. package/dist/server/collections/cluster-manager-acl-cache.d.ts +0 -22
  96. package/dist/server/collections/cluster-manager-cache-mgr.d.ts +0 -22
  97. package/dist/server/collections/cluster-manager-cluster.d.ts +0 -22
  98. package/dist/server/collections/cluster-manager-lock.d.ts +0 -22
  99. package/dist/server/collections/cluster-manager-plugins.d.ts +0 -18
  100. package/dist/server/collections/cluster-manager-queue.d.ts +0 -22
  101. package/dist/server/collections/cluster-manager-redis.d.ts +0 -22
  102. package/dist/server/collections/cluster-manager-workflow.d.ts +0 -22
  103. package/dist/server/collections/cluster-manager.d.ts +0 -22
  104. package/dist/server/collections/orchestrator-settings.d.ts +0 -59
  105. package/dist/server/collections/orchestrator-stacks.d.ts +0 -102
  106. package/dist/server/collections/worker-orchestrator.d.ts +0 -22
  107. package/dist/server/collections/worker-packages-configs.d.ts +0 -3
  108. package/dist/server/collections/worker-packages.d.ts +0 -22
  109. package/dist/server/orchestrator/PackageManager.d.ts +0 -39
  110. package/dist/server/orchestrator/docker-adapter.d.ts +0 -41
  111. package/dist/server/orchestrator/index.d.ts +0 -4
  112. package/dist/server/orchestrator/k8s-adapter.d.ts +0 -50
  113. package/dist/server/orchestrator/leader-election.d.ts +0 -48
  114. package/dist/server/orchestrator/types.d.ts +0 -84
  115. package/dist/server/plugin.d.ts +0 -26
  116. package/dist/server/utils/node.d.ts +0 -6
  117. package/dist/server/utils/redis.d.ts +0 -29
  118. package/dist/shared/packages.d.ts +0 -23
  119. /package/{dist/server/index.d.ts → src/client-v2/index.tsx} +0 -0
@@ -1,238 +1,243 @@
1
- import React, { useEffect, useState, useCallback } from 'react';
2
- import { Table, Tag, Button, Space, Popconfirm, message, Select, Dropdown } from 'antd';
3
- import { ReloadOutlined, StopOutlined, UnorderedListOutlined } from '@ant-design/icons';
4
- import { useAPIClient } from '@nocobase/client';
5
- import dayjs from 'dayjs';
6
-
7
- const EXEC_STATUS: Record<string, { label: string; color: string }> = {
8
- null: { label: 'Queueing', color: 'default' },
9
- '0': { label: 'Started', color: 'processing' },
10
- '1': { label: 'Resolved', color: 'success' },
11
- '-1': { label: 'Failed', color: 'error' },
12
- '-2': { label: 'Error', color: 'error' },
13
- '-3': { label: 'Aborted', color: 'warning' },
14
- '-4': { label: 'Canceled', color: 'warning' },
15
- '-5': { label: 'Rejected', color: 'warning' },
16
- '-6': { label: 'Retry Needed', color: 'orange' },
17
- };
18
-
19
- const JOB_STATUS: Record<string, { label: string; color: string }> = {
20
- '0': { label: 'Pending', color: 'default' },
21
- '1': { label: 'Resolved', color: 'success' },
22
- '-1': { label: 'Failed', color: 'error' },
23
- '-2': { label: 'Error', color: 'error' },
24
- '-3': { label: 'Aborted', color: 'warning' },
25
- '-4': { label: 'Canceled', color: 'warning' },
26
- };
27
-
28
- export function WorkflowExecutions() {
29
- const api = useAPIClient();
30
- const [data, setData] = useState<any[]>([]);
31
- const [loading, setLoading] = useState(false);
32
- const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });
33
- const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
34
- const [expandedJobs, setExpandedJobs] = useState<Record<string, any[]>>({});
35
- const [loadingJobs, setLoadingJobs] = useState<Record<string, boolean>>({});
36
-
37
- const fetchData = useCallback(
38
- async (page = pagination.current, pageSize = pagination.pageSize) => {
39
- setLoading(true);
40
- try {
41
- const res = await api.request({
42
- url: 'clusterManagerWorkflow:list',
43
- params: { page, pageSize, statusFilter: statusFilter === undefined ? '' : statusFilter },
44
- });
45
- const body = res.data;
46
- const rows = Array.isArray(body?.data?.data) ? body.data.data : Array.isArray(body?.data) ? body.data : Array.isArray(body) ? body : [];
47
- setData(rows);
48
- setPagination((prev) => ({
49
- ...prev,
50
- current: body.meta?.page || page,
51
- total: body.meta?.count || 0,
52
- }));
53
- } catch {
54
- message.error('Failed to load executions');
55
- } finally {
56
- setLoading(false);
57
- }
58
- },
59
- [api, pagination.current, pagination.pageSize, statusFilter],
60
- );
61
-
62
- useEffect(() => {
63
- fetchData();
64
- }, [statusFilter]);
65
-
66
- const fetchJobs = async (executionId: string) => {
67
- if (expandedJobs[executionId]) return;
68
- setLoadingJobs((prev) => ({ ...prev, [executionId]: true }));
69
- try {
70
- const res = await api.request({
71
- url: 'clusterManagerWorkflow:getJobs',
72
- params: { filterByTk: executionId },
73
- });
74
- const jobs = Array.isArray(res?.data?.data?.data) ? res.data.data.data : Array.isArray(res?.data?.data) ? res.data.data : Array.isArray(res?.data) ? res.data : [];
75
- setExpandedJobs((prev) => ({ ...prev, [executionId]: jobs }));
76
- } catch {
77
- message.error('Failed to load jobs');
78
- } finally {
79
- setLoadingJobs((prev) => ({ ...prev, [executionId]: false }));
80
- }
81
- };
82
-
83
- const handleCancel = async (id: string) => {
84
- await api.request({ url: 'clusterManagerWorkflow:cancel', params: { filterByTk: id } });
85
- message.success('Execution canceled');
86
- fetchData();
87
- };
88
-
89
- const handlePurge = async (days: number) => {
90
- try {
91
- const res = await api.request({ url: `clusterManagerWorkflow:purge`, method: 'post', data: { days } });
92
- message.success(`Purged ${res?.data?.deletedCount || 0} executions`);
93
- fetchData();
94
- } catch {
95
- message.error('Failed to purge executions');
96
- }
97
- };
98
-
99
- const purgeItems = [
100
- { key: '7', label: 'Older than 7 days', onClick: () => handlePurge(7) },
101
- { key: '30', label: 'Older than 30 days', onClick: () => handlePurge(30) },
102
- { key: '0', label: 'All completed/failed', danger: true, onClick: () => handlePurge(0) },
103
- ];
104
-
105
- const jobColumns = [
106
- { title: 'Job ID', dataIndex: 'id', width: 100 },
107
- {
108
- title: 'Node',
109
- key: 'node',
110
- width: 200,
111
- render: (_: any, r: any) => r.node?.title || r.nodeKey || '-',
112
- },
113
- {
114
- title: 'Status',
115
- dataIndex: 'status',
116
- width: 100,
117
- render: (val: number) => {
118
- const s = JOB_STATUS[String(val)] || { label: String(val), color: 'default' };
119
- return <Tag color={s.color}>{s.label}</Tag>;
120
- },
121
- },
122
- {
123
- title: 'Result',
124
- dataIndex: 'result',
125
- ellipsis: true,
126
- render: (val: any) => (val ? JSON.stringify(val).slice(0, 120) : '-'),
127
- },
128
- ];
129
-
130
- const columns = [
131
- { title: 'ID', dataIndex: 'id', width: 100 },
132
- {
133
- title: 'Workflow',
134
- key: 'workflow',
135
- width: 200,
136
- render: (_: any, r: any) => r.workflow?.title || '-',
137
- },
138
- {
139
- title: 'Status',
140
- dataIndex: 'status',
141
- width: 120,
142
- render: (val: number | null) => {
143
- const s = EXEC_STATUS[String(val)] || { label: String(val), color: 'default' };
144
- return <Tag color={s.color}>{s.label}</Tag>;
145
- },
146
- },
147
- {
148
- title: 'Executing Node',
149
- dataIndex: 'workerNode',
150
- width: 150,
151
- render: (val: string) => (val && val !== '-' ? <Tag color="blue">{val}</Tag> : <Tag>{val || '-'}</Tag>),
152
- },
153
- {
154
- title: 'Manual',
155
- dataIndex: 'manually',
156
- width: 80,
157
- render: (val: boolean) => (val ? <Tag color="blue">Yes</Tag> : '-'),
158
- },
159
- {
160
- title: 'Triggered At',
161
- dataIndex: 'createdAt',
162
- width: 160,
163
- render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm:ss') : '-'),
164
- },
165
- {
166
- title: 'Actions',
167
- key: 'actions',
168
- width: 120,
169
- render: (_: any, record: any) => (
170
- <Space>
171
- <Button
172
- type="link"
173
- size="small"
174
- icon={<UnorderedListOutlined />}
175
- onClick={() => fetchJobs(record.id)}
176
- loading={loadingJobs[record.id]}
177
- >
178
- Jobs
179
- </Button>
180
- {(record.status === 0 || record.status === null) && (
181
- <Popconfirm title="Cancel execution?" onConfirm={() => handleCancel(record.id)}>
182
- <Button type="link" size="small" icon={<StopOutlined />} danger />
183
- </Popconfirm>
184
- )}
185
- </Space>
186
- ),
187
- },
188
- ];
189
-
190
- return (
191
- <div>
192
- <Space style={{ marginBottom: 16 }}>
193
- <Select
194
- placeholder="Filter by status"
195
- allowClear
196
- style={{ width: 160 }}
197
- value={statusFilter}
198
- onChange={setStatusFilter}
199
- options={Object.entries(EXEC_STATUS).map(([k, v]) => ({ value: k, label: v.label }))}
200
- />
201
- <Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
202
- Refresh
203
- </Button>
204
- <Dropdown menu={{ items: purgeItems }} trigger={['click']}>
205
- <Button danger>Clear History</Button>
206
- </Dropdown>
207
- </Space>
208
- <Table
209
- rowKey="id"
210
- columns={columns}
211
- dataSource={data}
212
- loading={loading}
213
- size="small"
214
- expandable={{
215
- expandedRowRender: (record) => {
216
- const jobs = expandedJobs[record.id];
217
- if (!jobs) return <div style={{ padding: 8 }}>Click "Jobs" to load</div>;
218
- return (
219
- <Table
220
- rowKey="id"
221
- columns={jobColumns}
222
- dataSource={jobs}
223
- size="small"
224
- pagination={false}
225
- />
226
- );
227
- },
228
- }}
229
- pagination={{
230
- ...pagination,
231
- showSizeChanger: true,
232
- showTotal: (total) => `Total ${total}`,
233
- onChange: (page, pageSize) => fetchData(page, pageSize),
234
- }}
235
- />
236
- </div>
237
- );
238
- }
1
+ import React, { useEffect, useState, useCallback } from 'react';
2
+ import { Table, Tag, Button, Space, Popconfirm, message, Select, Dropdown } from 'antd';
3
+ import { ReloadOutlined, StopOutlined, UnorderedListOutlined } from '@ant-design/icons';
4
+ import { useAPIClient } from '@nocobase/client';
5
+ import dayjs from 'dayjs';
6
+
7
+ const EXEC_STATUS: Record<string, { label: string; color: string }> = {
8
+ null: { label: 'Queueing', color: 'default' },
9
+ '0': { label: 'Started', color: 'processing' },
10
+ '1': { label: 'Resolved', color: 'success' },
11
+ '-1': { label: 'Failed', color: 'error' },
12
+ '-2': { label: 'Error', color: 'error' },
13
+ '-3': { label: 'Aborted', color: 'warning' },
14
+ '-4': { label: 'Canceled', color: 'warning' },
15
+ '-5': { label: 'Rejected', color: 'warning' },
16
+ '-6': { label: 'Retry Needed', color: 'orange' },
17
+ };
18
+
19
+ const JOB_STATUS: Record<string, { label: string; color: string }> = {
20
+ '0': { label: 'Pending', color: 'default' },
21
+ '1': { label: 'Resolved', color: 'success' },
22
+ '-1': { label: 'Failed', color: 'error' },
23
+ '-2': { label: 'Error', color: 'error' },
24
+ '-3': { label: 'Aborted', color: 'warning' },
25
+ '-4': { label: 'Canceled', color: 'warning' },
26
+ };
27
+
28
+ export function WorkflowExecutions() {
29
+ const api = useAPIClient();
30
+ const [data, setData] = useState<any[]>([]);
31
+ const [loading, setLoading] = useState(false);
32
+ const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 });
33
+ const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined);
34
+ const [expandedJobs, setExpandedJobs] = useState<Record<string, any[]>>({});
35
+ const [loadingJobs, setLoadingJobs] = useState<Record<string, boolean>>({});
36
+
37
+ const fetchData = useCallback(
38
+ async (page = pagination.current, pageSize = pagination.pageSize) => {
39
+ setLoading(true);
40
+ try {
41
+ const res = await api.request({
42
+ url: 'clusterManagerWorkflow:list',
43
+ params: { page, pageSize, statusFilter: statusFilter === undefined ? '' : statusFilter },
44
+ });
45
+ const body = res.data;
46
+ const rows = Array.isArray(body?.data?.data)
47
+ ? body.data.data
48
+ : Array.isArray(body?.data)
49
+ ? body.data
50
+ : Array.isArray(body)
51
+ ? body
52
+ : [];
53
+ setData(rows);
54
+ setPagination((prev) => ({
55
+ ...prev,
56
+ current: body.meta?.page || page,
57
+ total: body.meta?.count || 0,
58
+ }));
59
+ } catch {
60
+ message.error('Failed to load executions');
61
+ } finally {
62
+ setLoading(false);
63
+ }
64
+ },
65
+ [api, pagination.current, pagination.pageSize, statusFilter],
66
+ );
67
+
68
+ useEffect(() => {
69
+ fetchData();
70
+ }, [statusFilter]);
71
+
72
+ const fetchJobs = async (executionId: string) => {
73
+ if (expandedJobs[executionId]) return;
74
+ setLoadingJobs((prev) => ({ ...prev, [executionId]: true }));
75
+ try {
76
+ const res = await api.request({
77
+ url: 'clusterManagerWorkflow:getJobs',
78
+ params: { filterByTk: executionId },
79
+ });
80
+ const jobs = Array.isArray(res?.data?.data?.data)
81
+ ? res.data.data.data
82
+ : Array.isArray(res?.data?.data)
83
+ ? res.data.data
84
+ : Array.isArray(res?.data)
85
+ ? res.data
86
+ : [];
87
+ setExpandedJobs((prev) => ({ ...prev, [executionId]: jobs }));
88
+ } catch {
89
+ message.error('Failed to load jobs');
90
+ } finally {
91
+ setLoadingJobs((prev) => ({ ...prev, [executionId]: false }));
92
+ }
93
+ };
94
+
95
+ const handleCancel = async (id: string) => {
96
+ await api.request({ url: 'clusterManagerWorkflow:cancel', params: { filterByTk: id } });
97
+ message.success('Execution canceled');
98
+ fetchData();
99
+ };
100
+
101
+ const handlePurge = async (days: number) => {
102
+ try {
103
+ const res = await api.request({ url: `clusterManagerWorkflow:purge`, method: 'post', data: { days } });
104
+ message.success(`Purged ${res?.data?.deletedCount || 0} executions`);
105
+ fetchData();
106
+ } catch {
107
+ message.error('Failed to purge executions');
108
+ }
109
+ };
110
+
111
+ const purgeItems = [
112
+ { key: '7', label: 'Older than 7 days', onClick: () => handlePurge(7) },
113
+ { key: '30', label: 'Older than 30 days', onClick: () => handlePurge(30) },
114
+ { key: '0', label: 'All completed/failed', danger: true, onClick: () => handlePurge(0) },
115
+ ];
116
+
117
+ const jobColumns = [
118
+ { title: 'Job ID', dataIndex: 'id', width: 100 },
119
+ {
120
+ title: 'Node',
121
+ key: 'node',
122
+ width: 200,
123
+ render: (_: any, r: any) => r.node?.title || r.nodeKey || '-',
124
+ },
125
+ {
126
+ title: 'Status',
127
+ dataIndex: 'status',
128
+ width: 100,
129
+ render: (val: number) => {
130
+ const s = JOB_STATUS[String(val)] || { label: String(val), color: 'default' };
131
+ return <Tag color={s.color}>{s.label}</Tag>;
132
+ },
133
+ },
134
+ {
135
+ title: 'Result',
136
+ dataIndex: 'result',
137
+ ellipsis: true,
138
+ render: (val: any) => (val ? JSON.stringify(val).slice(0, 120) : '-'),
139
+ },
140
+ ];
141
+
142
+ const columns = [
143
+ { title: 'ID', dataIndex: 'id', width: 100 },
144
+ {
145
+ title: 'Workflow',
146
+ key: 'workflow',
147
+ width: 200,
148
+ render: (_: any, r: any) => r.workflow?.title || '-',
149
+ },
150
+ {
151
+ title: 'Status',
152
+ dataIndex: 'status',
153
+ width: 120,
154
+ render: (val: number | null) => {
155
+ const s = EXEC_STATUS[String(val)] || { label: String(val), color: 'default' };
156
+ return <Tag color={s.color}>{s.label}</Tag>;
157
+ },
158
+ },
159
+ {
160
+ title: 'Executing Node',
161
+ dataIndex: 'workerNode',
162
+ width: 150,
163
+ render: (val: string) => (val && val !== '-' ? <Tag color="blue">{val}</Tag> : <Tag>{val || '-'}</Tag>),
164
+ },
165
+ {
166
+ title: 'Manual',
167
+ dataIndex: 'manually',
168
+ width: 80,
169
+ render: (val: boolean) => (val ? <Tag color="blue">Yes</Tag> : '-'),
170
+ },
171
+ {
172
+ title: 'Triggered At',
173
+ dataIndex: 'createdAt',
174
+ width: 160,
175
+ render: (val: string) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm:ss') : '-'),
176
+ },
177
+ {
178
+ title: 'Actions',
179
+ key: 'actions',
180
+ width: 120,
181
+ render: (_: any, record: any) => (
182
+ <Space>
183
+ <Button
184
+ type="link"
185
+ size="small"
186
+ icon={<UnorderedListOutlined />}
187
+ onClick={() => fetchJobs(record.id)}
188
+ loading={loadingJobs[record.id]}
189
+ >
190
+ Jobs
191
+ </Button>
192
+ {(record.status === 0 || record.status === null) && (
193
+ <Popconfirm title="Cancel execution?" onConfirm={() => handleCancel(record.id)}>
194
+ <Button type="link" size="small" icon={<StopOutlined />} danger />
195
+ </Popconfirm>
196
+ )}
197
+ </Space>
198
+ ),
199
+ },
200
+ ];
201
+
202
+ return (
203
+ <div>
204
+ <Space style={{ marginBottom: 16 }}>
205
+ <Select
206
+ placeholder="Filter by status"
207
+ allowClear
208
+ style={{ width: 160 }}
209
+ value={statusFilter}
210
+ onChange={setStatusFilter}
211
+ options={Object.entries(EXEC_STATUS).map(([k, v]) => ({ value: k, label: v.label }))}
212
+ />
213
+ <Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
214
+ Refresh
215
+ </Button>
216
+ <Dropdown menu={{ items: purgeItems }} trigger={['click']}>
217
+ <Button danger>Clear History</Button>
218
+ </Dropdown>
219
+ </Space>
220
+ <Table
221
+ rowKey="id"
222
+ columns={columns}
223
+ dataSource={data}
224
+ loading={loading}
225
+ size="small"
226
+ scroll={{ x: 'max-content' }}
227
+ expandable={{
228
+ expandedRowRender: (record) => {
229
+ const jobs = expandedJobs[record.id];
230
+ if (!jobs) return <div style={{ padding: 8 }}>Click &quot;Jobs&quot; to load</div>;
231
+ return <Table rowKey="id" columns={jobColumns} dataSource={jobs} size="small" pagination={false} />;
232
+ },
233
+ }}
234
+ pagination={{
235
+ ...pagination,
236
+ showSizeChanger: true,
237
+ showTotal: (total) => `Total ${total}`,
238
+ onChange: (page, pageSize) => fetchData(page, pageSize),
239
+ }}
240
+ />
241
+ </div>
242
+ );
243
+ }
@@ -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
+ }