plugin-cluster-manager 1.1.16 → 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 +43 -10
  9. package/dist/server/actions/doctor.js +69 -4
  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 +42 -3
  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 +36 -5
  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
@@ -27,21 +27,54 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
27
27
  var redis_exports = {};
28
28
  __export(redis_exports, {
29
29
  deleteKeysChunked: () => deleteKeysChunked,
30
+ getClusterRedisUrl: () => getClusterRedisUrl,
30
31
  getRedis: () => getRedis,
31
32
  getRedisClient: () => getRedisClient,
32
33
  getRedisOrThrow: () => getRedisOrThrow,
34
+ isClusterRedisConfigured: () => isClusterRedisConfigured,
33
35
  scanKeys: () => scanKeys
34
36
  });
35
37
  module.exports = __toCommonJS(redis_exports);
36
38
  var import_redis = require("redis");
37
39
  let globalRedisClient = null;
40
+ const CLUSTER_REDIS_CONNECTION = "cluster-manager:registry";
41
+ const CLUSTER_REDIS_ENV_CANDIDATES = [
42
+ "NODE_REGISTRY_REDIS_URL",
43
+ "CLUSTER_MANAGER_REDIS_URL",
44
+ "REDIS_URL",
45
+ "CACHE_REDIS_URL",
46
+ "PUBSUB_ADAPTER_REDIS_URL",
47
+ "QUEUE_ADAPTER_REDIS_URL",
48
+ "LOCK_ADAPTER_REDIS_URL"
49
+ ];
50
+ function getClusterRedisUrl() {
51
+ for (const key of CLUSTER_REDIS_ENV_CANDIDATES) {
52
+ const value = process.env[key];
53
+ if (value == null ? void 0 : value.trim()) {
54
+ return value.trim();
55
+ }
56
+ }
57
+ return "";
58
+ }
59
+ function isClusterRedisConfigured(app) {
60
+ var _a;
61
+ if (getClusterRedisUrl()) {
62
+ return true;
63
+ }
64
+ const manager = app == null ? void 0 : app.redisConnectionManager;
65
+ return Boolean((_a = manager == null ? void 0 : manager.getConnection) == null ? void 0 : _a.call(manager));
66
+ }
38
67
  function getRedisClient(app) {
39
68
  if (globalRedisClient) return globalRedisClient;
69
+ const url = getClusterRedisUrl();
40
70
  if (app == null ? void 0 : app.redisConnectionManager) {
71
+ if (url) {
72
+ const conn2 = app.redisConnectionManager.getConnection(CLUSTER_REDIS_CONNECTION, { connectionString: url });
73
+ if (conn2) return conn2;
74
+ }
41
75
  const conn = app.redisConnectionManager.getConnection();
42
76
  if (conn) return conn;
43
77
  }
44
- const url = process.env.REDIS_URL || process.env.CACHE_REDIS_URL || process.env.PUBSUB_ADAPTER_REDIS_URL;
45
78
  if (!url) return null;
46
79
  globalRedisClient = (0, import_redis.createClient)({ url });
47
80
  globalRedisClient.connect().catch((err) => {
@@ -64,14 +97,7 @@ async function scanKeys(redis, pattern, batchSize = 200) {
64
97
  const keys = [];
65
98
  let cursor = "0";
66
99
  do {
67
- const result = await redis.sendCommand([
68
- "SCAN",
69
- cursor,
70
- "MATCH",
71
- pattern,
72
- "COUNT",
73
- String(batchSize)
74
- ]);
100
+ const result = await redis.sendCommand(["SCAN", cursor, "MATCH", pattern, "COUNT", String(batchSize)]);
75
101
  cursor = String(result[0]);
76
102
  if (Array.isArray(result[1])) {
77
103
  keys.push(...result[1]);
@@ -96,8 +122,10 @@ async function deleteKeysChunked(redis, keys, chunkSize = 500) {
96
122
  // Annotate the CommonJS export names for ESM import in node:
97
123
  0 && (module.exports = {
98
124
  deleteKeysChunked,
125
+ getClusterRedisUrl,
99
126
  getRedis,
100
127
  getRedisClient,
101
128
  getRedisOrThrow,
129
+ isClusterRedisConfigured,
102
130
  scanKeys
103
131
  });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var worker_processes_exports = {};
28
+ __export(worker_processes_exports, {
29
+ WORKER_PROCESS_DEFINITIONS: () => WORKER_PROCESS_DEFINITIONS,
30
+ getCommonWorkerProcesses: () => getCommonWorkerProcesses,
31
+ getWorkerProcessByChannel: () => getWorkerProcessByChannel,
32
+ getWorkerProcessDefinition: () => getWorkerProcessDefinition,
33
+ isAppServingMode: () => isAppServingMode,
34
+ isWorkerOnlyMode: () => isWorkerOnlyMode,
35
+ normalizeWorkerMode: () => normalizeWorkerMode,
36
+ resolveWorkerProcessName: () => resolveWorkerProcessName,
37
+ workerModeServesProcess: () => workerModeServesProcess,
38
+ workerModeTokens: () => workerModeTokens
39
+ });
40
+ module.exports = __toCommonJS(worker_processes_exports);
41
+ const WORKER_PROCESS_DEFINITIONS = [
42
+ {
43
+ name: "workflow:process",
44
+ label: "Workflow",
45
+ description: "Process workflow executions",
46
+ kind: "event-queue",
47
+ aliases: ["workflow.pendingExecution", "@nocobase/plugin-workflow.pendingExecution"],
48
+ common: true
49
+ },
50
+ {
51
+ name: "async-task:process",
52
+ label: "Async Tasks",
53
+ description: "Execute async tasks",
54
+ kind: "event-queue",
55
+ aliases: ["async-task-manager.task", "@nocobase/plugin-async-task-manager.task"],
56
+ common: true
57
+ },
58
+ {
59
+ name: "notification:send",
60
+ label: "Notifications",
61
+ description: "Send notification messages",
62
+ kind: "event-queue",
63
+ aliases: ["notification-manager.send", "@nocobase/plugin-notification-manager.send"],
64
+ common: true
65
+ },
66
+ {
67
+ name: "knowledge-base:document-vectorize",
68
+ label: "Document Vectorization",
69
+ description: "Vectorize knowledge base documents",
70
+ kind: "event-queue",
71
+ aliases: ["knowledge-base:document-vectorize"],
72
+ pluginName: "plugin-knowledge-base",
73
+ common: true
74
+ },
75
+ {
76
+ name: "git-review:process",
77
+ label: "Git Review",
78
+ description: "AI code review jobs",
79
+ kind: "redis-list",
80
+ aliases: ["plugin-git-manager.review"],
81
+ redisKeySuffixes: [":plugin-git-manager:review:queue"],
82
+ pluginName: "plugin-git-manager",
83
+ common: true
84
+ },
85
+ {
86
+ name: "build-guide:process",
87
+ label: "Build Guide",
88
+ description: "Build user guide pages",
89
+ kind: "redis-list",
90
+ aliases: ["plugin-build-guide-block.build"],
91
+ redisKeySuffixes: [":plugin-build-guide-block:build:queue"],
92
+ pluginName: "plugin-build-guide-block",
93
+ common: true
94
+ },
95
+ {
96
+ name: "build-visualization:process",
97
+ label: "Build Visualization",
98
+ description: "Build visualization blocks",
99
+ kind: "redis-list",
100
+ aliases: ["plugin-build-visualization-block.build"],
101
+ redisKeySuffixes: [":plugin-build-visualization-block:build:queue"],
102
+ pluginName: "plugin-build-visualization-block",
103
+ common: true
104
+ },
105
+ {
106
+ name: "build-ui-template:process",
107
+ label: "Build UI Template",
108
+ description: "Build UI template pages",
109
+ kind: "event-queue",
110
+ aliases: ["plugin-build-ui-template.build"],
111
+ pluginName: "plugin-build-ui-template",
112
+ common: true
113
+ },
114
+ {
115
+ name: "file-preview-auth:ocr",
116
+ label: "File Preview OCR",
117
+ description: "Run OCR extraction for authenticated file previews",
118
+ kind: "redis-list",
119
+ aliases: ["file-preview-auth.ocr.queue"],
120
+ redisKeySuffixes: ["file-preview-auth.ocr.queue"],
121
+ pluginName: "plugin-file-preview-auth",
122
+ common: true
123
+ },
124
+ {
125
+ name: "skill-hub:sandbox",
126
+ label: "Skill Sandbox",
127
+ description: "Execute Skill Hub sandbox tasks",
128
+ kind: "pubsub",
129
+ aliases: ["skill-hub.task"],
130
+ pluginName: "plugin-agent-orchestrator",
131
+ sandbox: true
132
+ }
133
+ ];
134
+ const PROCESS_BY_NAME = new Map(WORKER_PROCESS_DEFINITIONS.map((definition) => [definition.name, definition]));
135
+ const ALIAS_TO_PROCESS = /* @__PURE__ */ new Map();
136
+ for (const definition of WORKER_PROCESS_DEFINITIONS) {
137
+ ALIAS_TO_PROCESS.set(definition.name, definition.name);
138
+ for (const alias of definition.aliases || []) {
139
+ ALIAS_TO_PROCESS.set(alias, definition.name);
140
+ }
141
+ }
142
+ function splitWorkerModeTokens(value) {
143
+ const rawTokens = Array.isArray(value) ? value : String(value || "").split(",");
144
+ return rawTokens.map((token) => String(token).trim()).filter(Boolean);
145
+ }
146
+ function stripKnownPrefixes(token) {
147
+ const candidates = [token];
148
+ const dotIndex = token.indexOf(".");
149
+ if (dotIndex > 0) {
150
+ candidates.push(token.slice(dotIndex + 1));
151
+ }
152
+ const colonIndex = token.indexOf(":");
153
+ if (colonIndex > 0) {
154
+ candidates.push(token.slice(colonIndex + 1));
155
+ }
156
+ return Array.from(new Set(candidates));
157
+ }
158
+ function resolveWorkerProcessName(token) {
159
+ const value = String(token || "").trim();
160
+ if (!value || value === "*" || value === "!" || value === "-") {
161
+ return value;
162
+ }
163
+ const directProcessName = ALIAS_TO_PROCESS.get(value);
164
+ if (directProcessName) {
165
+ return directProcessName;
166
+ }
167
+ for (const candidate of stripKnownPrefixes(value)) {
168
+ const processName = ALIAS_TO_PROCESS.get(candidate);
169
+ if (processName) {
170
+ return processName;
171
+ }
172
+ }
173
+ for (const definition of WORKER_PROCESS_DEFINITIONS) {
174
+ if ((definition.redisKeySuffixes || []).some((suffix) => value.endsWith(suffix))) {
175
+ return definition.name;
176
+ }
177
+ }
178
+ return value;
179
+ }
180
+ function normalizeWorkerMode(value) {
181
+ const resolved = splitWorkerModeTokens(value).map(resolveWorkerProcessName);
182
+ if (resolved.includes("*")) {
183
+ return "*";
184
+ }
185
+ const unique = Array.from(new Set(resolved.filter(Boolean)));
186
+ return unique.length ? unique.join(",") : void 0;
187
+ }
188
+ function workerModeTokens(value) {
189
+ const normalized = normalizeWorkerMode(value);
190
+ return normalized ? normalized.split(",") : [];
191
+ }
192
+ function workerModeServesProcess(workerMode, processNameOrAlias) {
193
+ const mode = normalizeWorkerMode(workerMode);
194
+ if (!mode) return true;
195
+ if (mode === "main" || mode === "app") return true;
196
+ if (mode === "-") return false;
197
+ if (mode === "*") return true;
198
+ const processName = resolveWorkerProcessName(processNameOrAlias);
199
+ return mode.split(",").includes(processName);
200
+ }
201
+ function isWorkerOnlyMode(workerMode) {
202
+ const mode = normalizeWorkerMode(workerMode);
203
+ if (!mode || mode === "main" || mode === "app" || mode === "-") return false;
204
+ return !mode.split(",").includes("!");
205
+ }
206
+ function isAppServingMode(workerMode) {
207
+ const mode = normalizeWorkerMode(workerMode);
208
+ if (!mode || mode === "main" || mode === "app") return true;
209
+ return mode.split(",").includes("!");
210
+ }
211
+ function getWorkerProcessDefinition(nameOrAlias) {
212
+ return PROCESS_BY_NAME.get(resolveWorkerProcessName(nameOrAlias));
213
+ }
214
+ function getCommonWorkerProcesses() {
215
+ return WORKER_PROCESS_DEFINITIONS.filter((definition) => definition.common && !definition.sandbox);
216
+ }
217
+ function getWorkerProcessByChannel(channel) {
218
+ const processName = resolveWorkerProcessName(channel);
219
+ return PROCESS_BY_NAME.get(processName);
220
+ }
221
+ // Annotate the CommonJS export names for ESM import in node:
222
+ 0 && (module.exports = {
223
+ WORKER_PROCESS_DEFINITIONS,
224
+ getCommonWorkerProcesses,
225
+ getWorkerProcessByChannel,
226
+ getWorkerProcessDefinition,
227
+ isAppServingMode,
228
+ isWorkerOnlyMode,
229
+ normalizeWorkerMode,
230
+ resolveWorkerProcessName,
231
+ workerModeServesProcess,
232
+ workerModeTokens
233
+ });
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "displayName": "Cluster Manager",
4
4
  "displayName.zh-CN": "Cluster Manager",
5
5
  "description": "Cluster node tracking, task orchestration, worker management, and package distribution",
6
- "version": "1.1.16",
6
+ "version": "1.1.17",
7
7
  "license": "Apache-2.0",
8
8
  "main": "dist/server/index.js",
9
9
  "keywords": [
@@ -31,6 +31,7 @@ import {
31
31
  } from '@ant-design/icons';
32
32
  import { useApp } from '@nocobase/client-v2';
33
33
  import { useT, formatBytes, formatUptime } from './utils';
34
+ import { isWorkerOnlyMode } from '../shared/worker-processes';
34
35
 
35
36
  function LogViewerModal({ open, node, onClose }: { open: boolean; node: any; onClose: () => void }) {
36
37
  const t = useT();
@@ -195,12 +196,46 @@ function countPackages(packages?: { apt?: string[]; npm?: string[]; python?: str
195
196
  return (packages?.apt?.length || 0) + (packages?.npm?.length || 0) + (packages?.python?.length || 0);
196
197
  }
197
198
 
199
+ function getNodeRole(record: any): 'app' | 'worker' | 'sandbox' {
200
+ if (record?.appRole === 'app' || record?.appRole === 'worker' || record?.appRole === 'sandbox') {
201
+ return record.appRole;
202
+ }
203
+ if (record?.isSandbox) {
204
+ return 'sandbox';
205
+ }
206
+ return isWorkerOnlyMode(record?.workerMode) ? 'worker' : 'app';
207
+ }
208
+
209
+ function servesWorkerQueues(record: any) {
210
+ return record?.appRole === 'worker' || isWorkerOnlyMode(record?.workerMode);
211
+ }
212
+
213
+ function readClusterListPayload(response: any) {
214
+ const body = response?.data;
215
+ const wrapped = body?.data;
216
+
217
+ if (Array.isArray(wrapped)) {
218
+ return { rows: wrapped, meta: body?.meta || {} };
219
+ }
220
+
221
+ if (Array.isArray(wrapped?.data)) {
222
+ return { rows: wrapped.data, meta: wrapped.meta || {} };
223
+ }
224
+
225
+ if (Array.isArray(body?.rows)) {
226
+ return { rows: body.rows, meta: body?.meta || {} };
227
+ }
228
+
229
+ return { rows: [], meta: {} };
230
+ }
231
+
198
232
  export function ClusterNodes() {
199
233
  const t = useT();
200
234
  const api = useApp().apiClient;
201
235
  const [loading, setLoading] = useState(false);
202
236
  const [currentNode, setCurrentNode] = useState<any>(null);
203
237
  const [environments, setEnvironments] = useState<any[]>([]);
238
+ const [clusterListMeta, setClusterListMeta] = useState<any>(null);
204
239
  const [health, setHealth] = useState<any>(null);
205
240
  const [drift, setDrift] = useState<any>(null);
206
241
  const [legacyDiagnostics, setLegacyDiagnostics] = useState<any>(null);
@@ -221,8 +256,10 @@ export function ClusterNodes() {
221
256
  api.request({ url: 'clusterManagerCluster:drift' }),
222
257
  api.request({ url: 'clusterManagerCluster:legacyDiagnostics' }),
223
258
  ]);
259
+ const listPayload = readClusterListPayload(listRes);
224
260
  setCurrentNode(currentRes?.data?.data);
225
- setEnvironments(listRes?.data?.data?.data || []);
261
+ setEnvironments(listPayload.rows);
262
+ setClusterListMeta(listPayload.meta);
226
263
  setHealth(healthRes?.data?.data);
227
264
  setDrift(driftRes?.data?.data);
228
265
  setLegacyDiagnostics(legacyRes?.data?.data);
@@ -309,13 +346,21 @@ export function ClusterNodes() {
309
346
  title: t('Type'),
310
347
  dataIndex: 'workerMode',
311
348
  key: 'workerMode',
312
- width: 100,
349
+ width: 150,
313
350
  render: (mode: string, record: any) => {
314
- if (record.isSandbox) {
351
+ const role = getNodeRole(record);
352
+ if (role === 'sandbox') {
315
353
  return <Tag color="purple">SANDBOX</Tag>;
316
354
  }
317
- const isWorker = mode === 'worker' || mode === 'task' || mode === '*';
318
- return <Tag color={isWorker ? 'blue' : 'green'}>{isWorker ? 'WORKER' : 'APP'}</Tag>;
355
+ if (role === 'app' && isWorkerOnlyMode(mode)) {
356
+ return (
357
+ <Space size={4}>
358
+ <Tag color="green">APP</Tag>
359
+ <Tag color="blue">QUEUES</Tag>
360
+ </Space>
361
+ );
362
+ }
363
+ return <Tag color={role === 'worker' ? 'blue' : 'green'}>{role === 'worker' ? 'WORKER' : 'APP'}</Tag>;
319
364
  },
320
365
  },
321
366
  { title: t('PID'), dataIndex: 'pid', key: 'pid', width: 80 },
@@ -346,6 +391,10 @@ export function ClusterNodes() {
346
391
  const versionDrifts = drift?.versionDrifts || [];
347
392
  const runtimeDrifts = drift?.runtimeDrifts || [];
348
393
  const legacyFindings = legacyDiagnostics?.findings || [];
394
+ const registryStatus = clusterListMeta?.registry || {};
395
+ const showRegistryNotice = Boolean(
396
+ clusterListMeta?.fallback || registryStatus.lastHeartbeatError || registryStatus.lastReadError,
397
+ );
349
398
  const renderFindingMessage = (finding: any) => {
350
399
  const template = finding.messageKey ? t(finding.messageKey) : finding.message;
351
400
  if (!finding.messageArgs) {
@@ -416,11 +465,7 @@ export function ClusterNodes() {
416
465
  <Card size="small">
417
466
  <Statistic
418
467
  title={t('Worker Nodes')}
419
- value={
420
- environments.filter(
421
- (e) => e.workerMode === 'worker' || e.workerMode === 'task' || e.workerMode === '*',
422
- ).length
423
- }
468
+ value={environments.filter((e) => servesWorkerQueues(e)).length}
424
469
  prefix={<ClusterOutlined />}
425
470
  />
426
471
  </Card>
@@ -643,6 +688,27 @@ export function ClusterNodes() {
643
688
 
644
689
  {/* Cluster nodes table */}
645
690
  <Card title={t('Cluster Nodes')} size="small">
691
+ {showRegistryNotice && (
692
+ <Alert
693
+ type={registryStatus.configured ? 'warning' : 'info'}
694
+ showIcon
695
+ style={{ marginBottom: 12 }}
696
+ message={
697
+ registryStatus.configured
698
+ ? t('Cluster registry has no worker heartbeats')
699
+ : t('Cluster registry Redis is not configured')
700
+ }
701
+ description={
702
+ registryStatus.configured
703
+ ? t(
704
+ 'Cluster Nodes reads Redis heartbeats, not the container runtime. Check worker boot, plugin-cluster-manager, and shared Redis configuration.',
705
+ )
706
+ : t(
707
+ 'Set REDIS_URL or CLUSTER_MANAGER_REDIS_URL on every app and worker to enable cluster node discovery.',
708
+ )
709
+ }
710
+ />
711
+ )}
646
712
  <Table
647
713
  dataSource={environments}
648
714
  columns={nodeColumns}