plugin-cluster-manager 1.1.15 → 1.1.17

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 (49) hide show
  1. package/dist/client/index.js +1 -1
  2. package/dist/client-v2/376.cd1d86e85a50088e.js +10 -0
  3. package/dist/client-v2/index.js +1 -1
  4. package/dist/externalVersion.js +6 -6
  5. package/dist/locale/en-US.json +16 -6
  6. package/dist/locale/vi-VN.json +16 -6
  7. package/dist/locale/zh-CN.json +16 -6
  8. package/dist/server/actions/cluster-nodes.js +44 -11
  9. package/dist/server/actions/doctor.js +73 -7
  10. package/dist/server/actions/event-queue-monitor.js +33 -3
  11. package/dist/server/actions/orchestrator.js +48 -32
  12. package/dist/server/actions/queue-mappings.js +1 -0
  13. package/dist/server/actions/tasks.js +8 -8
  14. package/dist/server/adapters/redis-event-queue-adapter.js +188 -0
  15. package/dist/server/adapters/redis-node-registry.js +44 -10
  16. package/dist/server/collections/orchestrator-stacks.js +6 -0
  17. package/dist/server/collections/worker-queue-mappings.js +1 -1
  18. package/dist/server/orchestrator/PackageManager.js +47 -12
  19. package/dist/server/plugin.js +37 -6
  20. package/dist/server/queue-scanner.js +54 -34
  21. package/dist/server/utils/node.js +3 -7
  22. package/dist/server/utils/redis.js +37 -9
  23. package/dist/shared/worker-processes.js +233 -0
  24. package/package.json +1 -1
  25. package/src/client/ClusterNodes.tsx +76 -10
  26. package/src/client/ContainerOrchestrator.tsx +146 -8
  27. package/src/client/QueueAssignment.tsx +10 -2
  28. package/src/locale/en-US.json +16 -6
  29. package/src/locale/vi-VN.json +16 -6
  30. package/src/locale/zh-CN.json +16 -6
  31. package/src/server/__tests__/worker-processes.test.ts +42 -0
  32. package/src/server/actions/cluster-nodes.ts +43 -8
  33. package/src/server/actions/doctor.ts +77 -0
  34. package/src/server/actions/event-queue-monitor.ts +34 -3
  35. package/src/server/actions/orchestrator.ts +58 -38
  36. package/src/server/actions/queue-mappings.ts +1 -0
  37. package/src/server/actions/tasks.ts +142 -142
  38. package/src/server/adapters/redis-event-queue-adapter.ts +189 -0
  39. package/src/server/adapters/redis-node-registry.ts +44 -4
  40. package/src/server/collections/orchestrator-stacks.ts +6 -0
  41. package/src/server/collections/worker-queue-mappings.ts +3 -3
  42. package/src/server/orchestrator/PackageManager.ts +48 -11
  43. package/src/server/orchestrator/types.ts +5 -4
  44. package/src/server/plugin.ts +40 -6
  45. package/src/server/queue-scanner.ts +65 -51
  46. package/src/server/utils/node.ts +3 -10
  47. package/src/server/utils/redis.ts +39 -4
  48. package/src/shared/worker-processes.ts +216 -0
  49. package/dist/client-v2/914.c0bce51908fd81d7.js +0 -10
@@ -38,6 +38,12 @@ import {
38
38
  } from '@ant-design/icons';
39
39
  import { useApp } from '@nocobase/client-v2';
40
40
  import { useT } from './utils';
41
+ import {
42
+ getCommonWorkerProcesses,
43
+ normalizeWorkerMode,
44
+ resolveWorkerProcessName,
45
+ workerModeTokens,
46
+ } from '../shared/worker-processes';
41
47
 
42
48
  const { Text, Title } = Typography;
43
49
 
@@ -58,6 +64,7 @@ interface StackInfo {
58
64
  image: string;
59
65
  command?: string;
60
66
  envVars?: Record<string, string>;
67
+ workerMode?: string;
61
68
  resourceLimits?: Record<string, string>;
62
69
  replicas: number;
63
70
  desiredReplicas: number;
@@ -73,6 +80,15 @@ interface StackInfo {
73
80
  k8sVolumes?: Record<string, any>[];
74
81
  }
75
82
 
83
+ interface DiscoveredQueue {
84
+ name: string;
85
+ label: string;
86
+ description: string;
87
+ type: 'event-queue' | 'redis-list';
88
+ pending: number | null;
89
+ workerProcessName?: string;
90
+ }
91
+
76
92
  const DEFAULT_WORKER_COMMAND = `export NOCOBASE_RUNNING_IN_DOCKER=true
77
93
  if ! command -v git >/dev/null; then echo "Installing git..."; if command -v apt-get >/dev/null; then apt-get update && apt-get install -y git; elif command -v apk >/dev/null; then apk add git; fi; fi
78
94
  if [ ! -d /app/nocobase ]; then mkdir -p /app/nocobase; fi
@@ -98,9 +114,14 @@ while [ $WAITED -lt $MAX_WAIT ]; do
98
114
  done
99
115
  exec yarn --cwd /app/nocobase start`;
100
116
 
117
+ const DEFAULT_WORKER_MODE = 'workflow:process,async-task:process';
118
+
119
+ const COMMON_WORKER_MODES = getCommonWorkerProcesses();
120
+ const ALL_WORKER_MODE = { name: '*', label: 'All processes', description: 'Consume every worker process' };
121
+
101
122
  const DEFAULT_WORKER_ENV = {
102
123
  APP_ROLE: 'worker',
103
- WORKER_MODE: '*',
124
+ WORKER_MODE: DEFAULT_WORKER_MODE,
104
125
  APP_PORT: '13000',
105
126
  SKILL_HUB_SANDBOX: 'false',
106
127
  };
@@ -161,6 +182,21 @@ function parseJsonText(value: string | undefined, fallback: any, label: string)
161
182
  }
162
183
  }
163
184
 
185
+ function splitWorkerMode(value?: string): string[] {
186
+ return workerModeTokens(value);
187
+ }
188
+
189
+ function getStackWorkerMode(stack?: StackInfo | null, fallback = '*'): string {
190
+ return normalizeWorkerMode(stack?.workerMode || stack?.envVars?.WORKER_MODE || fallback) || '';
191
+ }
192
+
193
+ function shortWorkerMode(workerMode: string): string {
194
+ if (workerMode.length <= 46) {
195
+ return workerMode;
196
+ }
197
+ return `${workerMode.slice(0, 43)}...`;
198
+ }
199
+
164
200
  function stackToFormValues(stack?: StackInfo | null) {
165
201
  if (!stack) {
166
202
  return {
@@ -168,6 +204,7 @@ function stackToFormValues(stack?: StackInfo | null) {
168
204
  adapter: 'kubernetes',
169
205
  image: 'nocobase/nocobase:2.1.6-full',
170
206
  command: DEFAULT_WORKER_COMMAND,
207
+ workerMode: splitWorkerMode(DEFAULT_WORKER_MODE),
171
208
  envVars: jsonText(DEFAULT_WORKER_ENV, {}),
172
209
  resourceLimits: jsonText({ memory: '1536Mi' }, {}),
173
210
  desiredReplicas: 1,
@@ -187,6 +224,7 @@ function stackToFormValues(stack?: StackInfo | null) {
187
224
 
188
225
  return {
189
226
  ...stack,
227
+ workerMode: splitWorkerMode(getStackWorkerMode(stack)),
190
228
  envVars: jsonText(stack.envVars, {}),
191
229
  resourceLimits: jsonText(stack.resourceLimits, {}),
192
230
  k8sEnv: jsonText(stack.k8sEnv, []),
@@ -197,9 +235,25 @@ function stackToFormValues(stack?: StackInfo | null) {
197
235
  }
198
236
 
199
237
  function formValuesToStack(values: any) {
238
+ const workerMode = normalizeWorkerMode(values.workerMode);
239
+ if (!workerMode) {
240
+ throw new Error('Select at least one process or queue');
241
+ }
242
+ if (workerMode.split(',').includes('!')) {
243
+ throw new Error('Container worker stacks cannot include app process "!"');
244
+ }
245
+
246
+ const envVars = parseJsonText(values.envVars, {}, 'Environment variables');
247
+ envVars.APP_ROLE = envVars.APP_ROLE || 'worker';
248
+ envVars.WORKER_MODE = workerMode;
249
+ if (envVars.SKILL_HUB_SANDBOX === undefined) {
250
+ envVars.SKILL_HUB_SANDBOX = 'false';
251
+ }
252
+
200
253
  return {
201
254
  ...values,
202
- envVars: parseJsonText(values.envVars, {}, 'Environment variables'),
255
+ workerMode,
256
+ envVars,
203
257
  resourceLimits: parseJsonText(values.resourceLimits, {}, 'Resource limits'),
204
258
  k8sEnv: parseJsonText(values.k8sEnv, [], 'Kubernetes env'),
205
259
  k8sEnvFrom: parseJsonText(values.k8sEnvFrom, [], 'Kubernetes envFrom'),
@@ -237,6 +291,21 @@ export function ContainerOrchestrator() {
237
291
  const [settingsForm] = Form.useForm();
238
292
  const [stackForm] = Form.useForm();
239
293
  const [dockerNetworks, setDockerNetworks] = useState<{ id: string; name: string }[]>([]);
294
+ const [queueOptions, setQueueOptions] = useState<DiscoveredQueue[]>([]);
295
+ const [queuesLoading, setQueuesLoading] = useState(false);
296
+
297
+ const fetchQueues = useCallback(async () => {
298
+ setQueuesLoading(true);
299
+ try {
300
+ const res = await api.request({ url: '/workerQueueMappings:scanQueues' });
301
+ const data = res.data?.data || res.data;
302
+ setQueueOptions(Array.isArray(data?.discovered) ? data.discovered : []);
303
+ } catch {
304
+ setQueueOptions([]);
305
+ } finally {
306
+ setQueuesLoading(false);
307
+ }
308
+ }, [api]);
240
309
 
241
310
  // Fetch Docker networks
242
311
  const fetchNetworks = useCallback(async () => {
@@ -329,6 +398,7 @@ export function ContainerOrchestrator() {
329
398
  setStackModal({ visible: true, stack: stack || null });
330
399
  stackForm.setFieldsValue(stackToFormValues(stack));
331
400
  fetchNetworks();
401
+ fetchQueues();
332
402
  };
333
403
 
334
404
  const closeStackModal = () => {
@@ -370,7 +440,8 @@ export function ContainerOrchestrator() {
370
440
  fetchPing();
371
441
  fetchStacks();
372
442
  fetchSettings();
373
- }, [fetchPing, fetchStacks, fetchSettings]);
443
+ fetchQueues();
444
+ }, [fetchPing, fetchStacks, fetchSettings, fetchQueues]);
374
445
 
375
446
  // Load containers for each stack and poll every 10s
376
447
  useEffect(() => {
@@ -517,6 +588,8 @@ export function ContainerOrchestrator() {
517
588
  width: 160,
518
589
  render: (_: any, record: ContainerInfo & { _stackId?: number }) => {
519
590
  const isK8s = pingResult?.adapter === 'kubernetes';
591
+ const stackId = record._stackId;
592
+ if (!stackId) return null;
520
593
  return (
521
594
  <Space size="small">
522
595
  {!isK8s &&
@@ -526,7 +599,7 @@ export function ContainerOrchestrator() {
526
599
  size="small"
527
600
  type="text"
528
601
  icon={<PauseCircleOutlined />}
529
- onClick={() => handleAction('stop', record.id, record._stackId!)}
602
+ onClick={() => handleAction('stop', record.id, stackId)}
530
603
  />
531
604
  </Tooltip>
532
605
  ) : (
@@ -535,7 +608,7 @@ export function ContainerOrchestrator() {
535
608
  size="small"
536
609
  type="text"
537
610
  icon={<PlayCircleOutlined style={{ color: '#52c41a' }} />}
538
- onClick={() => handleAction('start', record.id, record._stackId!)}
611
+ onClick={() => handleAction('start', record.id, stackId)}
539
612
  />
540
613
  </Tooltip>
541
614
  ))}
@@ -544,12 +617,12 @@ export function ContainerOrchestrator() {
544
617
  size="small"
545
618
  type="text"
546
619
  icon={<FileTextOutlined />}
547
- onClick={() => handleViewLogs(record.id, record._stackId!)}
620
+ onClick={() => handleViewLogs(record.id, stackId)}
548
621
  />
549
622
  </Tooltip>
550
623
  <Popconfirm
551
624
  title={t('Remove this container?')}
552
- onConfirm={() => handleAction('remove', record.id, record._stackId!)}
625
+ onConfirm={() => handleAction('remove', record.id, stackId)}
553
626
  >
554
627
  <Tooltip title={t('Remove')}>
555
628
  <Button size="small" type="text" danger icon={<DeleteOutlined />} />
@@ -561,6 +634,23 @@ export function ContainerOrchestrator() {
561
634
  },
562
635
  ];
563
636
 
637
+ const workerModeSelectOptions = Array.from(
638
+ new Map(
639
+ [ALL_WORKER_MODE, ...COMMON_WORKER_MODES, ...queueOptions].map((queue) => {
640
+ const workerProcessName = 'workerProcessName' in queue ? queue.workerProcessName : undefined;
641
+ const processName = workerProcessName || resolveWorkerProcessName(queue.name);
642
+ return [
643
+ processName,
644
+ {
645
+ value: processName,
646
+ label: `${queue.label} (${processName})`,
647
+ title: queue.name === processName ? queue.description : `${queue.description} - alias: ${queue.name}`,
648
+ },
649
+ ];
650
+ }),
651
+ ).values(),
652
+ );
653
+
564
654
  if (!pingResult) {
565
655
  return (
566
656
  <Spin
@@ -646,6 +736,7 @@ export function ContainerOrchestrator() {
646
736
  {stacks.map((stack) => {
647
737
  const stackContainers = (containers[stack.id] || []).map((c) => ({ ...c, _stackId: stack.id }));
648
738
  const meta = containerMeta[stack.id] || {};
739
+ const workerMode = getStackWorkerMode(stack);
649
740
 
650
741
  return (
651
742
  <Card
@@ -655,6 +746,9 @@ export function ContainerOrchestrator() {
655
746
  <CloudServerOutlined />
656
747
  <span>{stack.name}</span>
657
748
  <Tag>{stack.adapter}</Tag>
749
+ <Tooltip title={workerMode}>
750
+ <Tag color={workerMode === '*' ? 'orange' : 'geekblue'}>{shortWorkerMode(workerMode)}</Tag>
751
+ </Tooltip>
658
752
  </Space>
659
753
  }
660
754
  extra={
@@ -830,6 +924,46 @@ export function ContainerOrchestrator() {
830
924
  </Col>
831
925
  </Row>
832
926
 
927
+ <Form.Item
928
+ name="workerMode"
929
+ label={t('Processes / queues')}
930
+ extra={t('Saved as WORKER_MODE. Use tags for custom process keys that are not discovered yet.')}
931
+ rules={[
932
+ {
933
+ validator: (_: unknown, value: string[] | string | undefined) =>
934
+ normalizeWorkerMode(value)
935
+ ? Promise.resolve()
936
+ : Promise.reject(new Error(t('Select at least one process or queue'))),
937
+ },
938
+ ]}
939
+ >
940
+ <Select
941
+ mode="tags"
942
+ loading={queuesLoading}
943
+ options={workerModeSelectOptions}
944
+ placeholder={DEFAULT_WORKER_MODE}
945
+ tokenSeparators={[',']}
946
+ showSearch
947
+ />
948
+ </Form.Item>
949
+
950
+ <Form.Item noStyle shouldUpdate={(prev, curr) => prev.workerMode !== curr.workerMode}>
951
+ {() => {
952
+ const workerMode = normalizeWorkerMode(stackForm.getFieldValue('workerMode')) || '';
953
+ if (!workerMode.includes('*')) {
954
+ return null;
955
+ }
956
+ return (
957
+ <Alert
958
+ type="warning"
959
+ showIcon
960
+ style={{ marginBottom: 16 }}
961
+ message={t('WORKER_MODE=* makes this stack consume every queue. Prefer explicit queues in HA.')}
962
+ />
963
+ );
964
+ }}
965
+ </Form.Item>
966
+
833
967
  {/* Row 3: Deployment name | Service account | Image pull policy */}
834
968
  <Row gutter={12}>
835
969
  <Col span={8}>
@@ -899,7 +1033,11 @@ export function ContainerOrchestrator() {
899
1033
 
900
1034
  <Row gutter={12}>
901
1035
  <Col span={12}>
902
- <Form.Item name="envVars" label={t('Environment variables JSON')}>
1036
+ <Form.Item
1037
+ name="envVars"
1038
+ label={t('Environment variables JSON')}
1039
+ extra={t('WORKER_MODE is managed by Processes / queues above.')}
1040
+ >
903
1041
  <Input.TextArea rows={8} />
904
1042
  </Form.Item>
905
1043
  </Col>
@@ -35,6 +35,7 @@ interface DiscoveredQueue {
35
35
  description: string;
36
36
  type: 'event-queue' | 'redis-list';
37
37
  pending: number | null;
38
+ workerProcessName?: string;
38
39
  }
39
40
 
40
41
  interface RegisteredMapping {
@@ -208,7 +209,12 @@ export function QueueAssignment() {
208
209
  ) : (
209
210
  <MinusCircleOutlined style={{ color: '#52c41a' }} />
210
211
  )}
211
- <Text code>{name}</Text>
212
+ <Space direction="vertical" size={0}>
213
+ <Text code>{name}</Text>
214
+ {record.workerProcessName && record.workerProcessName !== name ? (
215
+ <Text type="secondary">{record.workerProcessName}</Text>
216
+ ) : null}
217
+ </Space>
212
218
  </Space>
213
219
  ),
214
220
  },
@@ -306,7 +312,9 @@ export function QueueAssignment() {
306
312
  <Col>
307
313
  <Space>
308
314
  <Text strong>{t('Queue Assignment')}</Text>
309
- <Text type="secondary">{t('Map queues to worker stacks. Unassigned queues run on all workers.')}</Text>
315
+ <Text type="secondary">
316
+ {t('Fallback mapping for legacy stacks without explicit Processes / queues.')}
317
+ </Text>
310
318
  </Space>
311
319
  </Col>
312
320
  <Col>
@@ -188,10 +188,16 @@
188
188
  "Package": "Package",
189
189
  "DB Version": "DB Version",
190
190
  "Runtime Versions": "Runtime Versions",
191
- "Queue Assignment": "Queue Assignment",
192
- "Queue Name": "Queue Name",
193
- "Map queues to worker stacks. Unassigned queues run on all workers.": "Map queues to worker stacks. Unassigned queues run on all workers.",
194
- "Scan Queues": "Scan Queues",
191
+ "Queue Assignment": "Queue Assignment",
192
+ "Queue Name": "Queue Name",
193
+ "Map queues to worker stacks. Unassigned queues run on all workers.": "Map queues to worker stacks. Unassigned queues run on all workers.",
194
+ "Fallback mapping for legacy stacks without explicit Processes / queues.": "Fallback mapping for legacy stacks without explicit Processes / queues.",
195
+ "Processes / queues": "Processes / queues",
196
+ "Saved as WORKER_MODE. Use tags for custom process keys that are not discovered yet.": "Saved as WORKER_MODE. Use tags for custom process keys that are not discovered yet.",
197
+ "Select at least one process or queue": "Select at least one process or queue",
198
+ "WORKER_MODE=* makes this stack consume every queue. Prefer explicit queues in HA.": "WORKER_MODE=* makes this stack consume every queue. Prefer explicit queues in HA.",
199
+ "WORKER_MODE is managed by Processes / queues above.": "WORKER_MODE is managed by Processes / queues above.",
200
+ "Scan Queues": "Scan Queues",
195
201
  "Auto-map ({count})": "Auto-map ({count})",
196
202
  "Register": "Register",
197
203
  "Unregister": "Unregister",
@@ -201,5 +207,9 @@
201
207
  "No queues discovered. Click \"Scan Queues\" to detect registered queues.": "No queues discovered. Click \"Scan Queues\" to detect registered queues.",
202
208
  "Auto-mapped {count} queue(s)": "Auto-mapped {count} queue(s)",
203
209
  "Queue Assignment updated": "Queue Assignment updated",
204
- "Unassigned (worker runs all queues)": "Unassigned (worker runs all queues)"
205
- }
210
+ "Unassigned (worker runs all queues)": "Unassigned (worker runs all queues)",
211
+ "Cluster registry has no worker heartbeats": "Cluster registry has no worker heartbeats",
212
+ "Cluster registry Redis is not configured": "Cluster registry Redis is not configured",
213
+ "Cluster Nodes reads Redis heartbeats, not the container runtime. Check worker boot, plugin-cluster-manager, and shared Redis configuration.": "Cluster Nodes reads Redis heartbeats, not the container runtime. Check worker boot, plugin-cluster-manager, and shared Redis configuration.",
214
+ "Set REDIS_URL or CLUSTER_MANAGER_REDIS_URL on every app and worker to enable cluster node discovery.": "Set REDIS_URL or CLUSTER_MANAGER_REDIS_URL on every app and worker to enable cluster node discovery."
215
+ }
@@ -157,10 +157,16 @@
157
157
  "Deprecated multi-app share collection is active. Avoid schema/table sharing for new cluster deployments.": "Deprecated multi-app share collection is active. Avoid schema/table sharing for new cluster deployments.",
158
158
  "{count} legacy application record(s) were found in the applications collection.": "{count} legacy application record(s) were found in the applications collection.",
159
159
  "App Supervisor is not enabled. Use it for new multi-application management instead of deprecated multi-app plugins.": "App Supervisor is not enabled. Use it for new multi-application management instead of deprecated multi-app plugins.",
160
- "Queue Assignment": "Gán hàng đợi",
161
- "Queue Name": "Tên hàng đợi",
162
- "Map queues to worker stacks. Unassigned queues run on all workers.": "Gán hàng đợi vào worker stack. Hàng đợi chưa gán sẽ chạy trên tất cả worker.",
163
- "Scan Queues": "Quét hàng đợi",
160
+ "Queue Assignment": "Gán hàng đợi",
161
+ "Queue Name": "Tên hàng đợi",
162
+ "Map queues to worker stacks. Unassigned queues run on all workers.": "Gán hàng đợi vào worker stack. Hàng đợi chưa gán sẽ chạy trên tất cả worker.",
163
+ "Fallback mapping for legacy stacks without explicit Processes / queues.": "Mapping dự phòng cho các stack cũ chưa cấu hình Processes / queues rõ ràng.",
164
+ "Processes / queues": "Processes / queues",
165
+ "Saved as WORKER_MODE. Use tags for custom process keys that are not discovered yet.": "Được lưu thành WORKER_MODE. Có thể nhập tag cho process key tùy chỉnh chưa được phát hiện.",
166
+ "Select at least one process or queue": "Chọn ít nhất một process hoặc queue",
167
+ "WORKER_MODE=* makes this stack consume every queue. Prefer explicit queues in HA.": "WORKER_MODE=* khiến stack này tiêu thụ mọi queue. Nên chọn queue rõ ràng trong HA.",
168
+ "WORKER_MODE is managed by Processes / queues above.": "WORKER_MODE được quản lý bởi Processes / queues ở trên.",
169
+ "Scan Queues": "Quét hàng đợi",
164
170
  "Auto-map ({count})": "Tự động gán ({count})",
165
171
  "Register": "Đăng ký",
166
172
  "Unregister": "Hủy đăng ký",
@@ -202,5 +208,9 @@
202
208
  "Log Files": "Log Files",
203
209
  "Package": "Package",
204
210
  "DB Version": "DB Version",
205
- "Runtime Versions": "Runtime Versions"
206
- }
211
+ "Runtime Versions": "Runtime Versions",
212
+ "Cluster registry has no worker heartbeats": "Registry cụm chưa có heartbeat từ worker",
213
+ "Cluster registry Redis is not configured": "Chưa cấu hình Redis cho registry cụm",
214
+ "Cluster Nodes reads Redis heartbeats, not the container runtime. Check worker boot, plugin-cluster-manager, and shared Redis configuration.": "Cluster Nodes đọc heartbeat trong Redis, không đọc trực tiếp container runtime. Hãy kiểm tra worker đã boot, plugin-cluster-manager đã load, và cấu hình Redis dùng chung.",
215
+ "Set REDIS_URL or CLUSTER_MANAGER_REDIS_URL on every app and worker to enable cluster node discovery.": "Thiết lập REDIS_URL hoặc CLUSTER_MANAGER_REDIS_URL trên mọi app và worker để bật khám phá node trong cụm."
216
+ }
@@ -190,10 +190,16 @@
190
190
  "Package": "Package",
191
191
  "DB Version": "DB Version",
192
192
  "Runtime Versions": "Runtime Versions",
193
- "Queue Assignment": "队列分配",
194
- "Queue Name": "队列名称",
195
- "Map queues to worker stacks. Unassigned queues run on all workers.": "将队列映射到工作节点栈。未分配的队列将在所有工作节点上运行。",
196
- "Scan Queues": "扫描队列",
193
+ "Queue Assignment": "队列分配",
194
+ "Queue Name": "队列名称",
195
+ "Map queues to worker stacks. Unassigned queues run on all workers.": "将队列映射到工作节点栈。未分配的队列将在所有工作节点上运行。",
196
+ "Fallback mapping for legacy stacks without explicit Processes / queues.": "用于没有显式进程/队列配置的旧工作栈的兜底映射。",
197
+ "Processes / queues": "进程 / 队列",
198
+ "Saved as WORKER_MODE. Use tags for custom process keys that are not discovered yet.": "保存为 WORKER_MODE。可用标签输入尚未发现的自定义进程键。",
199
+ "Select at least one process or queue": "请至少选择一个进程或队列",
200
+ "WORKER_MODE=* makes this stack consume every queue. Prefer explicit queues in HA.": "WORKER_MODE=* 会让此工作栈消费所有队列。HA 环境建议显式选择队列。",
201
+ "WORKER_MODE is managed by Processes / queues above.": "WORKER_MODE 由上方的进程 / 队列配置管理。",
202
+ "Scan Queues": "扫描队列",
197
203
  "Auto-map ({count})": "自动映射 ({count})",
198
204
  "Register": "注册",
199
205
  "Unregister": "注销",
@@ -203,5 +209,9 @@
203
209
  "No queues discovered. Click \"Scan Queues\" to detect registered queues.": "未发现队列。点击\"扫描队列\"以检测已注册的队列。",
204
210
  "Auto-mapped {count} queue(s)": "已自动映射 {count} 个队列",
205
211
  "Queue Assignment updated": "队列分配已更新",
206
- "Unassigned (worker runs all queues)": "未分配(工作节点运行所有队列)"
207
- }
212
+ "Unassigned (worker runs all queues)": "未分配(工作节点运行所有队列)",
213
+ "Cluster registry has no worker heartbeats": "集群注册表没有 worker 心跳",
214
+ "Cluster registry Redis is not configured": "未配置集群注册表 Redis",
215
+ "Cluster Nodes reads Redis heartbeats, not the container runtime. Check worker boot, plugin-cluster-manager, and shared Redis configuration.": "Cluster Nodes 读取 Redis 心跳,而不是直接读取容器运行时。请检查 worker 启动、plugin-cluster-manager 加载以及共享 Redis 配置。",
216
+ "Set REDIS_URL or CLUSTER_MANAGER_REDIS_URL on every app and worker to enable cluster node discovery.": "请在每个 app 和 worker 上设置 REDIS_URL 或 CLUSTER_MANAGER_REDIS_URL,以启用集群节点发现。"
217
+ }
@@ -0,0 +1,42 @@
1
+ import {
2
+ isWorkerOnlyMode,
3
+ normalizeWorkerMode,
4
+ resolveWorkerProcessName,
5
+ workerModeServesProcess,
6
+ } from '../../shared/worker-processes';
7
+ import { getNodeRoleFrom } from '../utils/node';
8
+
9
+ describe('worker process resolver', () => {
10
+ it('normalizes process keys and queue aliases', () => {
11
+ expect(normalizeWorkerMode('workflow.pendingExecution, plugin-git-manager.review')).toBe(
12
+ 'workflow:process,git-review:process',
13
+ );
14
+ expect(normalizeWorkerMode('main:plugin-build-guide-block:build:queue')).toBe('build-guide:process');
15
+ expect(normalizeWorkerMode('file-preview-auth.ocr.queue')).toBe('file-preview-auth:ocr');
16
+ });
17
+
18
+ it('lets wildcard win when mixed with explicit processes', () => {
19
+ expect(normalizeWorkerMode('workflow:process,*,git-review:process')).toBe('*');
20
+ expect(workerModeServesProcess('*,workflow:process', 'notification-manager.send')).toBe(true);
21
+ });
22
+
23
+ it('routes app and worker modes using NocoBase worker semantics', () => {
24
+ expect(isWorkerOnlyMode('!')).toBe(false);
25
+ expect(isWorkerOnlyMode('workflow:process')).toBe(true);
26
+ expect(isWorkerOnlyMode('workflow.pendingExecution')).toBe(true);
27
+ expect(workerModeServesProcess('workflow.pendingExecution', 'workflow:process')).toBe(true);
28
+ expect(workerModeServesProcess('workflow:process', 'plugin-git-manager.review')).toBe(false);
29
+ expect(workerModeServesProcess('file-preview-auth.ocr.queue', 'file-preview-auth:ocr')).toBe(true);
30
+ });
31
+
32
+ it('keeps unknown custom process keys stable', () => {
33
+ expect(resolveWorkerProcessName('custom-plugin:process')).toBe('custom-plugin:process');
34
+ expect(workerModeServesProcess('custom-plugin:process', 'custom-plugin:process')).toBe(true);
35
+ });
36
+
37
+ it('lets explicit APP_ROLE override worker mode for local node classification', () => {
38
+ expect(getNodeRoleFrom({ appRole: 'app', workerMode: '*' })).toBe('app');
39
+ expect(getNodeRoleFrom({ appRole: 'worker', workerMode: '!' })).toBe('worker');
40
+ expect(getNodeRoleFrom({ appRole: 'sandbox', workerMode: '!' })).toBe('sandbox');
41
+ });
42
+ });
@@ -181,10 +181,13 @@ function getReferenceVersion(nodes: ClusterNodeRecord[]) {
181
181
  return [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || null;
182
182
  }
183
183
 
184
- async function getClusterNodes(ctx: Context): Promise<ClusterNodeRecord[]> {
184
+ function getClusterRegistry(ctx: Context): RedisNodeRegistry {
185
185
  const plugin = (ctx.app as any).pm?.get?.('plugin-cluster-manager') as any;
186
- const registry = plugin?.nodeRegistry ?? new RedisNodeRegistry(ctx.app);
187
- return registry.getNodes();
186
+ return plugin?.nodeRegistry ?? new RedisNodeRegistry(ctx.app);
187
+ }
188
+
189
+ async function getClusterNodes(ctx: Context): Promise<ClusterNodeRecord[]> {
190
+ return getClusterRegistry(ctx).getNodes();
188
191
  }
189
192
 
190
193
  async function getExpectedPackages(ctx: Context): Promise<NormalizedPackages> {
@@ -267,6 +270,8 @@ export async function readLocalLogs(app: any, maxLines: number) {
267
270
  hostname: os.hostname(),
268
271
  pid: process.pid,
269
272
  workerMode: process.env.WORKER_MODE || 'main',
273
+ appRole: process.env.APP_ROLE || '',
274
+ isSandbox: process.env.SKILL_HUB_SANDBOX === 'true',
270
275
  };
271
276
 
272
277
  if (logFiles.length === 0) {
@@ -301,7 +306,12 @@ export const clusterActions = {
301
306
  */
302
307
  async current(ctx: Context, next: () => Promise<void>) {
303
308
  const currentMode = process.env.WORKER_MODE || 'main';
304
- const isApp = !isWorkerMode(process.env.WORKER_MODE);
309
+ const isApp =
310
+ getNodeRoleFrom({
311
+ workerMode: process.env.WORKER_MODE,
312
+ appRole: process.env.APP_ROLE,
313
+ isSandbox: process.env.SKILL_HUB_SANDBOX === 'true',
314
+ }) === 'app';
305
315
 
306
316
  if (isApp) {
307
317
  // This process IS the APP node — return local data directly
@@ -315,6 +325,8 @@ export const clusterActions = {
315
325
  arch: process.arch,
316
326
  uptime: process.uptime(),
317
327
  workerMode: currentMode,
328
+ appRole: process.env.APP_ROLE || '',
329
+ isSandbox: process.env.SKILL_HUB_SANDBOX === 'true',
318
330
  appPort: process.env.APP_PORT || '',
319
331
  clusterMode: process.env.CLUSTER_MODE || '',
320
332
  },
@@ -337,7 +349,9 @@ export const clusterActions = {
337
349
  const plugin = (ctx.app as any).pm?.get?.('plugin-cluster-manager') as any;
338
350
  const registry = plugin?.nodeRegistry ?? new RedisNodeRegistry(ctx.app);
339
351
  const nodes = await registry.getNodes();
340
- const appNode = nodes.find((n: any) => n.workerMode === 'main' || n.workerMode === '' || n.workerMode === 'app');
352
+ const appNode = nodes.find(
353
+ (n: any) => getNodeRoleFrom({ workerMode: n.workerMode, appRole: n.appRole, isSandbox: n.isSandbox }) === 'app',
354
+ );
341
355
 
342
356
  if (appNode?.nodeDetails) {
343
357
  ctx.body = appNode.nodeDetails;
@@ -353,6 +367,8 @@ export const clusterActions = {
353
367
  arch: process.arch,
354
368
  uptime: process.uptime(),
355
369
  workerMode: currentMode,
370
+ appRole: process.env.APP_ROLE || '',
371
+ isSandbox: process.env.SKILL_HUB_SANDBOX === 'true',
356
372
  appPort: process.env.APP_PORT || '',
357
373
  clusterMode: process.env.CLUSTER_MODE || '',
358
374
  },
@@ -385,7 +401,10 @@ export const clusterActions = {
385
401
  async list(ctx: Context, next: () => Promise<void>) {
386
402
  const environments: any[] = [];
387
403
 
388
- const nodes = await getClusterNodes(ctx);
404
+ const registry = getClusterRegistry(ctx);
405
+ const nodes = await registry.getNodes();
406
+ const registryStatus = registry.getStatus();
407
+ let fallback = false;
389
408
 
390
409
  if (nodes && nodes.length > 0) {
391
410
  for (const env of nodes) {
@@ -399,6 +418,7 @@ export const clusterActions = {
399
418
  lastHeartbeatAt: env.lastHeartbeatAt ? new Date(env.lastHeartbeatAt).toISOString() : null,
400
419
  status: env.status || 'online',
401
420
  workerMode: env.workerMode,
421
+ appRole: env.appRole,
402
422
  isSandbox: env.isSandbox,
403
423
  pid: env.pid,
404
424
  });
@@ -407,18 +427,33 @@ export const clusterActions = {
407
427
 
408
428
  // If no discovery adapter or empty, at least return current node
409
429
  if (environments.length === 0) {
430
+ fallback = true;
431
+ const currentMode = process.env.WORKER_MODE || 'main';
432
+ const appName = process.env.APP_NAME || (ctx.app as any).name || 'main';
410
433
  environments.push({
411
- name: os.hostname(),
434
+ id: getLocalNodeId(ctx.app),
435
+ name: `${appName} (${os.hostname()})`,
412
436
  hostname: os.hostname(),
413
437
  url: null,
414
438
  available: true,
415
439
  appVersion: null,
416
440
  lastHeartbeatAt: new Date().toISOString(),
417
441
  status: 'online',
442
+ workerMode: currentMode,
443
+ appRole: process.env.APP_ROLE || '',
444
+ isSandbox: process.env.SKILL_HUB_SANDBOX === 'true',
445
+ pid: process.pid,
418
446
  });
419
447
  }
420
448
 
421
- ctx.body = { data: environments, meta: { count: environments.length } };
449
+ ctx.body = {
450
+ data: environments,
451
+ meta: {
452
+ count: environments.length,
453
+ fallback,
454
+ registry: registryStatus,
455
+ },
456
+ };
422
457
  await next();
423
458
  },
424
459