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,418 +1,698 @@
1
- import React, { useState, useEffect, useCallback, useRef } from 'react';
2
- import { Card, Table, Tag, Button, Space, Row, Col, Statistic, Descriptions, Select, Spin, Alert, Popconfirm, message, Modal, Input, Switch } from 'antd';
3
- import {
4
- ReloadOutlined,
5
- CheckCircleOutlined,
6
- WarningOutlined,
7
- CloseCircleOutlined,
8
- ClusterOutlined,
9
- CloudServerOutlined,
10
- FileTextOutlined,
11
- SearchOutlined,
12
- } from '@ant-design/icons';
13
- import { useAPIClient } from '@nocobase/client';
14
- import { useT, formatBytes, formatUptime } from './utils';
15
-
16
-
17
-
18
- function LogViewerModal({ open, node, onClose }: { open: boolean; node: any; onClose: () => void }) {
19
- const t = useT();
20
- const api = useAPIClient();
21
- const [lines, setLines] = useState<string[]>([]);
22
- const [logMeta, setLogMeta] = useState<any>(null);
23
- const [loading, setLoading] = useState(false);
24
- const [autoRefresh, setAutoRefresh] = useState(true);
25
- const [searchText, setSearchText] = useState('');
26
- const logEndRef = useRef<HTMLDivElement>(null);
27
-
28
- const fetchLogs = useCallback(async () => {
29
- if (!open) return;
30
- setLoading(true);
31
- try {
32
- const res = await api.request({
33
- url: 'clusterManagerCluster:logs',
34
- params: { lines: 200, targetNodeId: node?.id },
35
- });
36
- const data = res?.data?.data;
37
- if (data) {
38
- if (data._error) {
39
- message.warning(data._error);
40
- }
41
- setLines(data.lines || []);
42
- setLogMeta(data.node);
43
- }
44
- } catch {
45
- message.error('Failed to load logs');
46
- } finally {
47
- setLoading(false);
48
- }
49
- }, [api, open, node]);
50
-
51
- useEffect(() => {
52
- if (open) {
53
- setLines([]);
54
- setSearchText('');
55
- setAutoRefresh(true);
56
- fetchLogs();
57
- }
58
- }, [open, fetchLogs]);
59
-
60
- useEffect(() => {
61
- if (!open || !autoRefresh) return;
62
- const timer = setInterval(fetchLogs, 5000);
63
- return () => clearInterval(timer);
64
- }, [open, autoRefresh, fetchLogs]);
65
-
66
- useEffect(() => {
67
- if (logEndRef.current && !searchText) {
68
- logEndRef.current.scrollIntoView({ behavior: 'smooth' });
69
- }
70
- }, [lines, searchText]);
71
-
72
- const filteredLines = searchText
73
- ? lines.filter((l) => l.toLowerCase().includes(searchText.toLowerCase()))
74
- : lines;
75
-
76
- return (
77
- <Modal
78
- title={
79
- <Space>
80
- <FileTextOutlined />
81
- {t('Instance Logs')} {node?.name || ''}
82
- {logMeta && (
83
- <Tag>{logMeta.hostname}:{logMeta.pid} ({logMeta.workerMode})</Tag>
84
- )}
85
- </Space>
86
- }
87
- open={open}
88
- onCancel={onClose}
89
- footer={null}
90
- width="80vw"
91
- styles={{ body: { height: '70vh', display: 'flex', flexDirection: 'column', padding: '12px 24px' } }}
92
- destroyOnClose
93
- >
94
- <Space style={{ marginBottom: 8, flexShrink: 0 }}>
95
- <Input
96
- placeholder={t('Search logs...')}
97
- prefix={<SearchOutlined />}
98
- value={searchText}
99
- onChange={(e) => setSearchText(e.target.value)}
100
- allowClear
101
- style={{ width: 300 }}
102
- />
103
- <Switch
104
- checked={autoRefresh}
105
- onChange={setAutoRefresh}
106
- checkedChildren={t('Auto 5s')}
107
- unCheckedChildren={t('Paused')}
108
- />
109
- <Button icon={<ReloadOutlined />} onClick={fetchLogs} loading={loading} size="small">
110
- {t('Refresh')}
111
- </Button>
112
- <span style={{ fontSize: 12, color: '#888' }}>
113
- {filteredLines.length} / {lines.length} {t('lines')}
114
- </span>
115
- </Space>
116
- <div
117
- style={{
118
- flex: 1,
119
- overflow: 'auto',
120
- background: '#1e1e1e',
121
- color: '#d4d4d4',
122
- fontFamily: 'Consolas, Monaco, "Courier New", monospace',
123
- fontSize: 12,
124
- lineHeight: 1.5,
125
- padding: 12,
126
- borderRadius: 6,
127
- whiteSpace: 'pre-wrap',
128
- wordBreak: 'break-all',
129
- }}
130
- >
131
- {filteredLines.length === 0 && !loading && (
132
- <div style={{ color: '#888', textAlign: 'center', paddingTop: 40 }}>
133
- {lines.length === 0 ? t('No logs available') : t('No matching logs')}
134
- </div>
135
- )}
136
- {filteredLines.map((line, i) => {
137
- let color = '#d4d4d4';
138
- if (/\berror\b/i.test(line)) color = '#f5222d';
139
- else if (/\bwarn(ing)?\b/i.test(line)) color = '#faad14';
140
- else if (/\bdebug\b/i.test(line)) color = '#8c8c8c';
141
- return (
142
- <div key={i} style={{ padding: '1px 0', color }}>{line}</div>
143
- );
144
- })}
145
- <div ref={logEndRef} />
146
- </div>
147
- </Modal>
148
- );
149
- }
150
-
151
- const statusColors: Record<string, string> = {
152
- ok: 'green',
153
- connected: 'green',
154
- online: 'green',
155
- warning: 'orange',
156
- disconnected: 'red',
157
- offline: 'red',
158
- error: 'red',
159
- not_configured: 'default',
160
- };
161
-
162
- export function ClusterNodes() {
163
- const t = useT();
164
- const api = useAPIClient();
165
- const [loading, setLoading] = useState(false);
166
- const [currentNode, setCurrentNode] = useState<any>(null);
167
- const [environments, setEnvironments] = useState<any[]>([]);
168
- const [health, setHealth] = useState<any>(null);
169
- const [autoRefresh, setAutoRefresh] = useState<number | null>(null);
170
- const [logNode, setLogNode] = useState<any>(null);
171
-
172
- const fetchData = useCallback(async () => {
173
- setLoading(true);
174
- try {
175
- const [currentRes, listRes, healthRes] = await Promise.all([
176
- api.request({ url: 'clusterManagerCluster:current' }),
177
- api.request({ url: 'clusterManagerCluster:list' }),
178
- api.request({ url: 'clusterManagerCluster:health' }),
179
- ]);
180
- setCurrentNode(currentRes?.data?.data);
181
- setEnvironments(listRes?.data?.data?.data || []);
182
- setHealth(healthRes?.data?.data);
183
- } catch {
184
- // Ignore
185
- } finally {
186
- setLoading(false);
187
- }
188
- }, [api]);
189
-
190
- const handleRestartNode = async (hostname: string, mode: 'soft' | 'hard') => {
191
- try {
192
- await api.request({
193
- url: 'clusterManagerCluster:restart',
194
- method: 'POST',
195
- data: { hostname, mode }
196
- });
197
- message.success(`[${mode}] Restart signal sent to ${hostname === '*' ? 'all nodes' : hostname}`);
198
- setTimeout(fetchData, 3000);
199
- } catch {
200
- message.error(`Failed to send restart signal to ${hostname}`);
201
- }
202
- };
203
-
204
- useEffect(() => {
205
- fetchData();
206
- }, [fetchData]);
207
-
208
- useEffect(() => {
209
- if (!autoRefresh) return;
210
- const timer = setInterval(fetchData, autoRefresh * 1000);
211
- return () => clearInterval(timer);
212
- }, [autoRefresh, fetchData]);
213
-
214
- const onlineCount = environments.filter((e) => e.status === 'online').length;
215
-
216
- const nodeColumns = [
217
- { title: t('Name'), dataIndex: 'name', key: 'name', width: 200 },
218
- {
219
- title: t('Status'),
220
- dataIndex: 'status',
221
- key: 'status',
222
- width: 100,
223
- render: (status: string) => (
224
- <Tag color={statusColors[status] || 'default'} icon={
225
- status === 'online' ? <CheckCircleOutlined /> :
226
- status === 'warning' ? <WarningOutlined /> :
227
- <CloseCircleOutlined />
228
- }>
229
- {status.toUpperCase()}
230
- </Tag>
231
- ),
232
- },
233
- {
234
- title: t('Type'),
235
- dataIndex: 'workerMode',
236
- key: 'workerMode',
237
- width: 100,
238
- render: (mode: string, record: any) => {
239
- if (record.isSandbox) {
240
- return <Tag color="purple">SANDBOX</Tag>;
241
- }
242
- const isWorker = mode === 'worker' || mode === 'task' || mode === '*';
243
- return (
244
- <Tag color={isWorker ? 'blue' : 'green'}>
245
- {isWorker ? 'WORKER' : 'APP'}
246
- </Tag>
247
- );
248
- },
249
- },
250
- { title: t('PID'), dataIndex: 'pid', key: 'pid', width: 80 },
251
- { title: t('Version'), dataIndex: 'appVersion', key: 'appVersion', width: 120 },
252
- { title: t('Last Heartbeat'), dataIndex: 'lastHeartbeatAt', key: 'lastHeartbeatAt', width: 200 },
253
- {
254
- title: 'Action',
255
- key: 'action',
256
- width: 100,
257
- render: (_: any, r: any) => (
258
- <Space size="small">
259
- <Button type="link" icon={<FileTextOutlined />} onClick={() => setLogNode(r)} disabled={r.status === 'offline'}>
260
- {t('Logs')}
261
- </Button>
262
- </Space>
263
- ),
264
- },
265
- ];
266
-
267
- const healthChecks = health?.checks || {};
268
-
269
- return (
270
- <Spin spinning={loading}>
271
- <Space direction="vertical" style={{ width: '100%' }} size="middle">
272
- {/* Toolbar */}
273
- <Space>
274
- <Button icon={<ReloadOutlined />} onClick={fetchData}>{t('Refresh')}</Button>
275
- <Select
276
- placeholder={t('Auto Refresh')}
277
- allowClear
278
- value={autoRefresh}
279
- onChange={setAutoRefresh}
280
- style={{ width: 140 }}
281
- options={[
282
- { value: 5, label: '5s' },
283
- { value: 10, label: '10s' },
284
- { value: 30, label: '30s' },
285
- ]}
286
- />
287
- <Space>
288
- <Popconfirm
289
- title="Soft Restart ALL Nodes?"
290
- description="Sends a soft reload signal to all active Nodes simultaneously."
291
- onConfirm={() => handleRestartNode('*', 'soft')}
292
- >
293
- <Button style={{ color: '#faad14', borderColor: '#ffe58f' }} icon={<ReloadOutlined />}>{t('Soft Restart Cluster')}</Button>
294
- </Popconfirm>
295
- <Popconfirm
296
- title="Hard Restart ALL Nodes?"
297
- description="Sends a lethal signal. Docker will reboot ALL container infrastructure."
298
- onConfirm={() => handleRestartNode('*', 'hard')}
299
- >
300
- <Button danger icon={<ReloadOutlined />}>{t('Hard Restart Cluster')}</Button>
301
- </Popconfirm>
302
- </Space>
303
- </Space>
304
-
305
- {/* Stats cards */}
306
- <Row gutter={16}>
307
- <Col span={6}>
308
- <Card size="small">
309
- <Statistic
310
- title={t('Nodes Online')}
311
- value={onlineCount}
312
- suffix={`/ ${environments.length}`}
313
- prefix={<ClusterOutlined />}
314
- valueStyle={{ color: onlineCount === environments.length ? '#3f8600' : '#cf1322' }}
315
- />
316
- </Card>
317
- </Col>
318
- <Col span={6}>
319
- <Card size="small">
320
- <Statistic
321
- title={t('Worker Nodes')}
322
- value={environments.filter(e => e.workerMode === 'worker' || e.workerMode === 'task' || e.workerMode === '*').length}
323
- prefix={<ClusterOutlined />}
324
- />
325
- </Card>
326
- </Col>
327
- <Col span={6}>
328
- <Card size="small">
329
- <Statistic
330
- title={t('Process Uptime')}
331
- value={currentNode ? formatUptime(currentNode.node.uptime) : '-'}
332
- />
333
- </Card>
334
- </Col>
335
- <Col span={6}>
336
- <Card size="small">
337
- <Statistic
338
- title={t('Memory (Heap)')}
339
- value={currentNode ? formatBytes(currentNode.memory.heapUsed) : '-'}
340
- suffix={currentNode ? `/ ${formatBytes(currentNode.memory.heapTotal)}` : ''}
341
- />
342
- </Card>
343
- </Col>
344
- </Row>
345
-
346
- {/* Health checks */}
347
- <Card title={t('Health Checks')} size="small">
348
- {health && !health.healthy && (
349
- <Alert type="warning" message={t('Some subsystems are unhealthy')} showIcon style={{ marginBottom: 12 }} />
350
- )}
351
- <Row gutter={[16, 8]}>
352
- {Object.entries(healthChecks).map(([name, check]: [string, any]) => (
353
- <Col span={4} key={name}>
354
- <Card size="small" style={{ textAlign: 'center' }}>
355
- <Tag color={statusColors[check.status] || 'default'}>{check.status}</Tag>
356
- <div style={{ marginTop: 4, fontWeight: 500 }}>{name}</div>
357
- {check.latency !== undefined && (
358
- <div style={{ fontSize: 11, color: '#888' }}>{check.latency}ms</div>
359
- )}
360
- {check.detail && (
361
- <div style={{ fontSize: 11, color: '#888' }}>{check.detail}</div>
362
- )}
363
- </Card>
364
- </Col>
365
- ))}
366
- </Row>
367
- </Card>
368
-
369
- {/* Cluster nodes table */}
370
- <Card title={t('Cluster Nodes')} size="small">
371
- <Table
372
- dataSource={environments}
373
- columns={nodeColumns}
374
- rowKey="id"
375
- size="small"
376
- pagination={false}
377
- />
378
- </Card>
379
-
380
- {/* Current node details (always APP node) */}
381
- {currentNode && (
382
- <Card
383
- title={
384
- <Space>
385
- {t('Current Node Details')}
386
- <Tag color="green">APP</Tag>
387
- </Space>
388
- }
389
- size="small"
390
- >
391
- {currentNode._fallback && (
392
- <Alert
393
- type="warning"
394
- message={t('APP node not found in cluster registry. Showing data from the responding worker node.')}
395
- showIcon
396
- style={{ marginBottom: 12 }}
397
- />
398
- )}
399
- <Descriptions size="small" column={3}>
400
- <Descriptions.Item label={t('Hostname')}>{currentNode.node.hostname}</Descriptions.Item>
401
- <Descriptions.Item label="PID">{currentNode.node.pid}</Descriptions.Item>
402
- <Descriptions.Item label="Node.js">{currentNode.node.nodeVersion}</Descriptions.Item>
403
- <Descriptions.Item label={t('Worker Mode')}>{currentNode.node.workerMode || '(default)'}</Descriptions.Item>
404
- <Descriptions.Item label={t('App Port')}>{currentNode.node.appPort}</Descriptions.Item>
405
- <Descriptions.Item label={t('Cluster Mode')}>{currentNode.node.clusterMode || '(disabled)'}</Descriptions.Item>
406
- <Descriptions.Item label={t('RSS Memory')}>{formatBytes(currentNode.memory.rss)}</Descriptions.Item>
407
- <Descriptions.Item label={t('OS Memory')}>
408
- {formatBytes(currentNode.os.freeMemory)} free / {formatBytes(currentNode.os.totalMemory)}
409
- </Descriptions.Item>
410
- <Descriptions.Item label={t('CPU Cores')}>{currentNode.os.cpuCount}</Descriptions.Item>
411
- </Descriptions>
412
- </Card>
413
- )}
414
- <LogViewerModal open={!!logNode} node={logNode} onClose={() => setLogNode(null)} />
415
- </Space>
416
- </Spin>
417
- );
418
- }
1
+ import React, { useState, useEffect, useCallback, useRef } from 'react';
2
+ import {
3
+ Alert,
4
+ Button,
5
+ Card,
6
+ Col,
7
+ Descriptions,
8
+ Input,
9
+ InputNumber,
10
+ message,
11
+ Modal,
12
+ Popconfirm,
13
+ Row,
14
+ Select,
15
+ Space,
16
+ Spin,
17
+ Statistic,
18
+ Switch,
19
+ Table,
20
+ Tag,
21
+ Typography,
22
+ } from 'antd';
23
+ import {
24
+ ReloadOutlined,
25
+ CheckCircleOutlined,
26
+ WarningOutlined,
27
+ CloseCircleOutlined,
28
+ ClusterOutlined,
29
+ FileTextOutlined,
30
+ SearchOutlined,
31
+ } from '@ant-design/icons';
32
+ import { useAPIClient } from '@nocobase/client';
33
+ import { useT, formatBytes, formatUptime } from './utils';
34
+
35
+ function LogViewerModal({ open, node, onClose }: { open: boolean; node: any; onClose: () => void }) {
36
+ const t = useT();
37
+ const api = useAPIClient();
38
+ const [lines, setLines] = useState<string[]>([]);
39
+ const [logMeta, setLogMeta] = useState<any>(null);
40
+ const [loading, setLoading] = useState(false);
41
+ const [autoRefresh, setAutoRefresh] = useState(true);
42
+ const [searchText, setSearchText] = useState('');
43
+ const logEndRef = useRef<HTMLDivElement>(null);
44
+
45
+ const fetchLogs = useCallback(async () => {
46
+ if (!open) return;
47
+ setLoading(true);
48
+ try {
49
+ const res = await api.request({
50
+ url: 'clusterManagerCluster:logs',
51
+ params: { lines: 200, targetNodeId: node?.id },
52
+ });
53
+ const data = res?.data?.data;
54
+ if (data) {
55
+ if (data._error) {
56
+ message.warning(data._error);
57
+ }
58
+ setLines(data.lines || []);
59
+ setLogMeta(data.node);
60
+ }
61
+ } catch {
62
+ message.error('Failed to load logs');
63
+ } finally {
64
+ setLoading(false);
65
+ }
66
+ }, [api, open, node]);
67
+
68
+ useEffect(() => {
69
+ if (open) {
70
+ setLines([]);
71
+ setSearchText('');
72
+ setAutoRefresh(true);
73
+ fetchLogs();
74
+ }
75
+ }, [open, fetchLogs]);
76
+
77
+ useEffect(() => {
78
+ if (!open || !autoRefresh) return;
79
+ const timer = setInterval(fetchLogs, 5000);
80
+ return () => clearInterval(timer);
81
+ }, [open, autoRefresh, fetchLogs]);
82
+
83
+ useEffect(() => {
84
+ if (logEndRef.current && !searchText) {
85
+ logEndRef.current.scrollIntoView({ behavior: 'smooth' });
86
+ }
87
+ }, [lines, searchText]);
88
+
89
+ const filteredLines = searchText ? lines.filter((l) => l.toLowerCase().includes(searchText.toLowerCase())) : lines;
90
+
91
+ return (
92
+ <Modal
93
+ title={
94
+ <Space>
95
+ <FileTextOutlined />
96
+ {t('Instance Logs')} — {node?.name || ''}
97
+ {logMeta && (
98
+ <Tag>
99
+ {logMeta.hostname}:{logMeta.pid} ({logMeta.workerMode})
100
+ </Tag>
101
+ )}
102
+ </Space>
103
+ }
104
+ open={open}
105
+ onCancel={onClose}
106
+ footer={null}
107
+ width="80vw"
108
+ styles={{ body: { height: '70vh', display: 'flex', flexDirection: 'column', padding: '12px 24px' } }}
109
+ destroyOnClose
110
+ >
111
+ <Space style={{ marginBottom: 8, flexShrink: 0 }}>
112
+ <Input
113
+ placeholder={t('Search logs...')}
114
+ prefix={<SearchOutlined />}
115
+ value={searchText}
116
+ onChange={(e) => setSearchText(e.target.value)}
117
+ allowClear
118
+ style={{ width: 300 }}
119
+ />
120
+ <Switch
121
+ checked={autoRefresh}
122
+ onChange={setAutoRefresh}
123
+ checkedChildren={t('Auto 5s')}
124
+ unCheckedChildren={t('Paused')}
125
+ />
126
+ <Button icon={<ReloadOutlined />} onClick={fetchLogs} loading={loading} size="small">
127
+ {t('Refresh')}
128
+ </Button>
129
+ <span style={{ fontSize: 12, color: '#888' }}>
130
+ {filteredLines.length} / {lines.length} {t('lines')}
131
+ </span>
132
+ </Space>
133
+ <div
134
+ style={{
135
+ flex: 1,
136
+ overflow: 'auto',
137
+ background: '#1e1e1e',
138
+ color: '#d4d4d4',
139
+ fontFamily: 'Consolas, Monaco, "Courier New", monospace',
140
+ fontSize: 12,
141
+ lineHeight: 1.5,
142
+ padding: 12,
143
+ borderRadius: 6,
144
+ whiteSpace: 'pre-wrap',
145
+ wordBreak: 'break-all',
146
+ }}
147
+ >
148
+ {filteredLines.length === 0 && !loading && (
149
+ <div style={{ color: '#888', textAlign: 'center', paddingTop: 40 }}>
150
+ {lines.length === 0 ? t('No logs available') : t('No matching logs')}
151
+ </div>
152
+ )}
153
+ {filteredLines.map((line, i) => {
154
+ let color = '#d4d4d4';
155
+ if (/\berror\b/i.test(line)) color = '#f5222d';
156
+ else if (/\bwarn(ing)?\b/i.test(line)) color = '#faad14';
157
+ else if (/\bdebug\b/i.test(line)) color = '#8c8c8c';
158
+ return (
159
+ <div key={i} style={{ padding: '1px 0', color }}>
160
+ {line}
161
+ </div>
162
+ );
163
+ })}
164
+ <div ref={logEndRef} />
165
+ </div>
166
+ </Modal>
167
+ );
168
+ }
169
+
170
+ const statusColors: Record<string, string> = {
171
+ ok: 'green',
172
+ connected: 'green',
173
+ online: 'green',
174
+ warning: 'orange',
175
+ disconnected: 'red',
176
+ offline: 'red',
177
+ error: 'red',
178
+ not_configured: 'default',
179
+ };
180
+
181
+ function renderPackageGroup(packages?: string[]) {
182
+ if (!packages?.length) {
183
+ return <Typography.Text type="secondary">-</Typography.Text>;
184
+ }
185
+ return (
186
+ <Space wrap size={[4, 2]}>
187
+ {packages.map((pkg) => (
188
+ <Tag key={pkg}>{pkg}</Tag>
189
+ ))}
190
+ </Space>
191
+ );
192
+ }
193
+
194
+ function countPackages(packages?: { apt?: string[]; npm?: string[]; python?: string[] }) {
195
+ return (packages?.apt?.length || 0) + (packages?.npm?.length || 0) + (packages?.python?.length || 0);
196
+ }
197
+
198
+ export function ClusterNodes() {
199
+ const t = useT();
200
+ const api = useAPIClient();
201
+ const [loading, setLoading] = useState(false);
202
+ const [currentNode, setCurrentNode] = useState<any>(null);
203
+ const [environments, setEnvironments] = useState<any[]>([]);
204
+ const [health, setHealth] = useState<any>(null);
205
+ const [drift, setDrift] = useState<any>(null);
206
+ const [legacyDiagnostics, setLegacyDiagnostics] = useState<any>(null);
207
+ const [autoRefresh, setAutoRefresh] = useState<number | null>(null);
208
+ const [logNode, setLogNode] = useState<any>(null);
209
+ const [rollingRole, setRollingRole] = useState<'worker' | 'app' | 'sandbox' | 'all'>('worker');
210
+ const [rollingMode, setRollingMode] = useState<'soft' | 'hard'>('soft');
211
+ const [rollingDelayMs, setRollingDelayMs] = useState(5000);
212
+ const [rolling, setRolling] = useState(false);
213
+
214
+ const fetchData = useCallback(async () => {
215
+ setLoading(true);
216
+ try {
217
+ const [currentRes, listRes, healthRes, driftRes, legacyRes] = await Promise.all([
218
+ api.request({ url: 'clusterManagerCluster:current' }),
219
+ api.request({ url: 'clusterManagerCluster:list' }),
220
+ api.request({ url: 'clusterManagerCluster:health' }),
221
+ api.request({ url: 'clusterManagerCluster:drift' }),
222
+ api.request({ url: 'clusterManagerCluster:legacyDiagnostics' }),
223
+ ]);
224
+ setCurrentNode(currentRes?.data?.data);
225
+ setEnvironments(listRes?.data?.data?.data || []);
226
+ setHealth(healthRes?.data?.data);
227
+ setDrift(driftRes?.data?.data);
228
+ setLegacyDiagnostics(legacyRes?.data?.data);
229
+ } catch {
230
+ // Ignore
231
+ } finally {
232
+ setLoading(false);
233
+ }
234
+ }, [api]);
235
+
236
+ const handleRestartNode = async (hostname: string, mode: 'soft' | 'hard') => {
237
+ try {
238
+ await api.request({
239
+ url: 'clusterManagerCluster:restart',
240
+ method: 'POST',
241
+ data: { hostname, mode },
242
+ });
243
+ message.success(`[${mode}] Restart signal sent to ${hostname === '*' ? 'all nodes' : hostname}`);
244
+ setTimeout(fetchData, 3000);
245
+ } catch {
246
+ message.error(`Failed to send restart signal to ${hostname}`);
247
+ }
248
+ };
249
+
250
+ const handleRollingRestart = async () => {
251
+ setRolling(true);
252
+ try {
253
+ const res = await api.request({
254
+ url: 'clusterManagerCluster:rollingRestart',
255
+ method: 'POST',
256
+ data: {
257
+ role: rollingRole,
258
+ mode: rollingMode,
259
+ delayMs: rollingDelayMs,
260
+ },
261
+ });
262
+ const count = res?.data?.data?.published?.length || 0;
263
+ message.success(t('Rolling restart dispatched for {count} node(s)').replace('{count}', String(count)));
264
+ setTimeout(fetchData, 3000);
265
+ } catch (err: any) {
266
+ message.error(err?.response?.data?.errors?.[0]?.message || t('Failed to dispatch rolling restart'));
267
+ } finally {
268
+ setRolling(false);
269
+ }
270
+ };
271
+
272
+ useEffect(() => {
273
+ fetchData();
274
+ }, [fetchData]);
275
+
276
+ useEffect(() => {
277
+ if (!autoRefresh) return;
278
+ const timer = setInterval(fetchData, autoRefresh * 1000);
279
+ return () => clearInterval(timer);
280
+ }, [autoRefresh, fetchData]);
281
+
282
+ const onlineCount = environments.filter((e) => e.status === 'online').length;
283
+
284
+ const nodeColumns = [
285
+ { title: t('Name'), dataIndex: 'name', key: 'name', width: 200 },
286
+ {
287
+ title: t('Status'),
288
+ dataIndex: 'status',
289
+ key: 'status',
290
+ width: 100,
291
+ render: (status: string) => (
292
+ <Tag
293
+ color={statusColors[status] || 'default'}
294
+ icon={
295
+ status === 'online' ? (
296
+ <CheckCircleOutlined />
297
+ ) : status === 'warning' ? (
298
+ <WarningOutlined />
299
+ ) : (
300
+ <CloseCircleOutlined />
301
+ )
302
+ }
303
+ >
304
+ {status.toUpperCase()}
305
+ </Tag>
306
+ ),
307
+ },
308
+ {
309
+ title: t('Type'),
310
+ dataIndex: 'workerMode',
311
+ key: 'workerMode',
312
+ width: 100,
313
+ render: (mode: string, record: any) => {
314
+ if (record.isSandbox) {
315
+ return <Tag color="purple">SANDBOX</Tag>;
316
+ }
317
+ const isWorker = mode === 'worker' || mode === 'task' || mode === '*';
318
+ return <Tag color={isWorker ? 'blue' : 'green'}>{isWorker ? 'WORKER' : 'APP'}</Tag>;
319
+ },
320
+ },
321
+ { title: t('PID'), dataIndex: 'pid', key: 'pid', width: 80 },
322
+ { title: t('Version'), dataIndex: 'appVersion', key: 'appVersion', width: 120 },
323
+ { title: t('Last Heartbeat'), dataIndex: 'lastHeartbeatAt', key: 'lastHeartbeatAt', width: 200 },
324
+ {
325
+ title: 'Action',
326
+ key: 'action',
327
+ width: 100,
328
+ render: (_: any, r: any) => (
329
+ <Space size="small">
330
+ <Button
331
+ type="link"
332
+ icon={<FileTextOutlined />}
333
+ onClick={() => setLogNode(r)}
334
+ disabled={r.status === 'offline'}
335
+ >
336
+ {t('Logs')}
337
+ </Button>
338
+ </Space>
339
+ ),
340
+ },
341
+ ];
342
+
343
+ const healthChecks = health?.checks || {};
344
+ const driftSummary = drift?.summary || {};
345
+ const packageDrifts = drift?.packageDrifts || [];
346
+ const versionDrifts = drift?.versionDrifts || [];
347
+ const runtimeDrifts = drift?.runtimeDrifts || [];
348
+ const legacyFindings = legacyDiagnostics?.findings || [];
349
+ const renderFindingMessage = (finding: any) => {
350
+ const template = finding.messageKey ? t(finding.messageKey) : finding.message;
351
+ if (!finding.messageArgs) {
352
+ return template;
353
+ }
354
+ return Object.entries(finding.messageArgs).reduce(
355
+ (text, [key, value]) => text.replace(`{${key}}`, String(value)),
356
+ template,
357
+ );
358
+ };
359
+
360
+ return (
361
+ <Spin spinning={loading}>
362
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
363
+ {/* Toolbar */}
364
+ <Space>
365
+ <Button icon={<ReloadOutlined />} onClick={fetchData}>
366
+ {t('Refresh')}
367
+ </Button>
368
+ <Select
369
+ placeholder={t('Auto Refresh')}
370
+ allowClear
371
+ value={autoRefresh}
372
+ onChange={setAutoRefresh}
373
+ style={{ width: 140 }}
374
+ options={[
375
+ { value: 5, label: '5s' },
376
+ { value: 10, label: '10s' },
377
+ { value: 30, label: '30s' },
378
+ ]}
379
+ />
380
+ <Space>
381
+ <Popconfirm
382
+ title="Soft Restart ALL Nodes?"
383
+ description="Sends a soft reload signal to all active Nodes simultaneously."
384
+ onConfirm={() => handleRestartNode('*', 'soft')}
385
+ >
386
+ <Button style={{ color: '#faad14', borderColor: '#ffe58f' }} icon={<ReloadOutlined />}>
387
+ {t('Soft Restart Cluster')}
388
+ </Button>
389
+ </Popconfirm>
390
+ <Popconfirm
391
+ title="Hard Restart ALL Nodes?"
392
+ description="Sends a lethal signal. Docker will reboot ALL container infrastructure."
393
+ onConfirm={() => handleRestartNode('*', 'hard')}
394
+ >
395
+ <Button danger icon={<ReloadOutlined />}>
396
+ {t('Hard Restart Cluster')}
397
+ </Button>
398
+ </Popconfirm>
399
+ </Space>
400
+ </Space>
401
+
402
+ {/* Stats cards */}
403
+ <Row gutter={16}>
404
+ <Col span={6}>
405
+ <Card size="small">
406
+ <Statistic
407
+ title={t('Nodes Online')}
408
+ value={onlineCount}
409
+ suffix={`/ ${environments.length}`}
410
+ prefix={<ClusterOutlined />}
411
+ valueStyle={{ color: onlineCount === environments.length ? '#3f8600' : '#cf1322' }}
412
+ />
413
+ </Card>
414
+ </Col>
415
+ <Col span={6}>
416
+ <Card size="small">
417
+ <Statistic
418
+ title={t('Worker Nodes')}
419
+ value={
420
+ environments.filter(
421
+ (e) => e.workerMode === 'worker' || e.workerMode === 'task' || e.workerMode === '*',
422
+ ).length
423
+ }
424
+ prefix={<ClusterOutlined />}
425
+ />
426
+ </Card>
427
+ </Col>
428
+ <Col span={6}>
429
+ <Card size="small">
430
+ <Statistic
431
+ title={t('Process Uptime')}
432
+ value={currentNode ? formatUptime(currentNode.node.uptime) : '-'}
433
+ />
434
+ </Card>
435
+ </Col>
436
+ <Col span={6}>
437
+ <Card size="small">
438
+ <Statistic
439
+ title={t('Memory (Heap)')}
440
+ value={currentNode ? formatBytes(currentNode.memory.heapUsed) : '-'}
441
+ suffix={currentNode ? `/ ${formatBytes(currentNode.memory.heapTotal)}` : ''}
442
+ />
443
+ </Card>
444
+ </Col>
445
+ </Row>
446
+
447
+ <Card title={t('Rolling Restart')} size="small">
448
+ <Space wrap>
449
+ <Select
450
+ value={rollingRole}
451
+ onChange={setRollingRole}
452
+ style={{ width: 180 }}
453
+ options={[
454
+ { value: 'worker', label: t('Worker nodes only') },
455
+ { value: 'app', label: t('App nodes only') },
456
+ { value: 'sandbox', label: t('Sandbox nodes only') },
457
+ { value: 'all', label: t('All nodes') },
458
+ ]}
459
+ />
460
+ <Select
461
+ value={rollingMode}
462
+ onChange={setRollingMode}
463
+ style={{ width: 150 }}
464
+ options={[
465
+ { value: 'soft', label: t('Soft restart') },
466
+ { value: 'hard', label: t('Hard restart') },
467
+ ]}
468
+ />
469
+ <InputNumber
470
+ min={1000}
471
+ max={60000}
472
+ step={1000}
473
+ value={rollingDelayMs}
474
+ onChange={(value) => setRollingDelayMs(Number(value) || 5000)}
475
+ addonAfter="ms"
476
+ style={{ width: 150 }}
477
+ />
478
+ <Popconfirm
479
+ title={t('Start rolling restart?')}
480
+ description={t('Nodes will receive restart commands one-by-one with the configured delay.')}
481
+ onConfirm={handleRollingRestart}
482
+ okText={t('Start')}
483
+ cancelText={t('Cancel')}
484
+ >
485
+ <Button icon={<ReloadOutlined />} loading={rolling}>
486
+ {t('Rolling Restart')}
487
+ </Button>
488
+ </Popconfirm>
489
+ </Space>
490
+ </Card>
491
+
492
+ {/* Health checks */}
493
+ <Card title={t('Health Checks')} size="small">
494
+ {health && !health.healthy && (
495
+ <Alert type="warning" message={t('Some subsystems are unhealthy')} showIcon style={{ marginBottom: 12 }} />
496
+ )}
497
+ <Row gutter={[16, 8]}>
498
+ {Object.entries(healthChecks).map(([name, check]: [string, any]) => (
499
+ <Col span={4} key={name}>
500
+ <Card size="small" style={{ textAlign: 'center' }}>
501
+ <Tag color={statusColors[check.status] || 'default'}>{check.status}</Tag>
502
+ <div style={{ marginTop: 4, fontWeight: 500 }}>{name}</div>
503
+ {check.latency !== undefined && <div style={{ fontSize: 11, color: '#888' }}>{check.latency}ms</div>}
504
+ {check.detail && <div style={{ fontSize: 11, color: '#888' }}>{check.detail}</div>}
505
+ </Card>
506
+ </Col>
507
+ ))}
508
+ </Row>
509
+ </Card>
510
+
511
+ <Card
512
+ title={t('Cluster Drift')}
513
+ size="small"
514
+ extra={
515
+ drift?.checkedAt ? (
516
+ <Typography.Text type="secondary" style={{ fontSize: 12 }}>
517
+ {new Date(drift.checkedAt).toLocaleString()}
518
+ </Typography.Text>
519
+ ) : null
520
+ }
521
+ >
522
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
523
+ <Alert
524
+ type={drift?.healthy ? 'success' : 'warning'}
525
+ showIcon
526
+ message={drift?.healthy ? t('No cluster drift detected') : t('Cluster drift detected')}
527
+ description={
528
+ drift?.referenceVersion
529
+ ? `${t('Reference version')}: ${drift.referenceVersion}`
530
+ : t('No reference version available')
531
+ }
532
+ />
533
+ <Row gutter={16}>
534
+ <Col span={6}>
535
+ <Statistic title={t('Checked Nodes')} value={driftSummary.nodes || 0} />
536
+ </Col>
537
+ <Col span={6}>
538
+ <Statistic title={t('Version Drift')} value={driftSummary.versionDrifts || 0} />
539
+ </Col>
540
+ <Col span={6}>
541
+ <Statistic title={t('Runtime Drift')} value={driftSummary.runtimeDrifts || 0} />
542
+ </Col>
543
+ <Col span={6}>
544
+ <Statistic title={t('Package Drift')} value={driftSummary.packageDrifts || 0} />
545
+ </Col>
546
+ </Row>
547
+
548
+ {(versionDrifts.length > 0 || runtimeDrifts.length > 0) && (
549
+ <Table
550
+ size="small"
551
+ rowKey={(record: any) =>
552
+ `${record.id || record.name}:${record.actualVersion || record.actual?.nodeVersion}`
553
+ }
554
+ pagination={false}
555
+ dataSource={[
556
+ ...versionDrifts.map((item: any) => ({ ...item, driftType: t('Version') })),
557
+ ...runtimeDrifts.map((item: any) => ({
558
+ ...item,
559
+ driftType: t('Runtime'),
560
+ expectedVersion: item.expected?.nodeVersion,
561
+ actualVersion: item.actual?.nodeVersion,
562
+ })),
563
+ ]}
564
+ columns={[
565
+ { title: t('Type'), dataIndex: 'driftType', width: 120 },
566
+ { title: t('Name'), dataIndex: 'name' },
567
+ { title: t('Role'), dataIndex: 'role', width: 120 },
568
+ { title: t('Expected'), dataIndex: 'expectedVersion' },
569
+ { title: t('Actual'), dataIndex: 'actualVersion' },
570
+ ]}
571
+ />
572
+ )}
573
+
574
+ {packageDrifts.length > 0 && (
575
+ <Table
576
+ size="small"
577
+ rowKey={(record: any) => record.id || record.name}
578
+ pagination={{ pageSize: 5 }}
579
+ dataSource={packageDrifts}
580
+ columns={[
581
+ { title: t('Name'), dataIndex: 'name' },
582
+ { title: t('Role'), dataIndex: 'role', width: 110 },
583
+ {
584
+ title: t('Package Status'),
585
+ dataIndex: 'status',
586
+ width: 130,
587
+ render: (status: string) => <Tag color={status === 'succeeded' ? 'green' : 'orange'}>{status}</Tag>,
588
+ },
589
+ {
590
+ title: t('Missing Packages'),
591
+ dataIndex: 'missingPackages',
592
+ render: (packages: any) =>
593
+ countPackages(packages) === 0 ? (
594
+ <Typography.Text type="secondary">{t('No missing packages')}</Typography.Text>
595
+ ) : (
596
+ <Space direction="vertical" size={2}>
597
+ <div>APT: {renderPackageGroup(packages?.apt)}</div>
598
+ <div>NPM: {renderPackageGroup(packages?.npm)}</div>
599
+ <div>Python: {renderPackageGroup(packages?.python)}</div>
600
+ </Space>
601
+ ),
602
+ },
603
+ ]}
604
+ />
605
+ )}
606
+ </Space>
607
+ </Card>
608
+
609
+ <Card title={t('Legacy Multi-app Diagnostics')} size="small">
610
+ <Space direction="vertical" style={{ width: '100%' }}>
611
+ <Alert
612
+ type={legacyDiagnostics?.healthy ? 'success' : 'warning'}
613
+ showIcon
614
+ message={
615
+ legacyDiagnostics?.healthy
616
+ ? t('No legacy multi-app risk detected')
617
+ : t('Legacy multi-app risk detected')
618
+ }
619
+ />
620
+ {legacyFindings.map((finding: any) => (
621
+ <Alert
622
+ key={finding.code}
623
+ type={finding.level === 'warning' ? 'warning' : 'info'}
624
+ showIcon
625
+ message={renderFindingMessage(finding)}
626
+ />
627
+ ))}
628
+ <Space wrap>
629
+ {(legacyDiagnostics?.plugins || []).map((plugin: any) => (
630
+ <Tag key={plugin.name} color={plugin.enabled || plugin.loaded ? 'orange' : 'default'}>
631
+ {plugin.name}: {plugin.enabled ? t('Enabled') : t('Disabled')}
632
+ </Tag>
633
+ ))}
634
+ <Tag color={legacyDiagnostics?.appSupervisor?.enabled ? 'green' : 'default'}>
635
+ app-supervisor: {legacyDiagnostics?.appSupervisor?.enabled ? t('Enabled') : t('Disabled')}
636
+ </Tag>
637
+ <Tag>
638
+ {t('Legacy app records')}: {legacyDiagnostics?.legacyApplicationCount || 0}
639
+ </Tag>
640
+ </Space>
641
+ </Space>
642
+ </Card>
643
+
644
+ {/* Cluster nodes table */}
645
+ <Card title={t('Cluster Nodes')} size="small">
646
+ <Table
647
+ dataSource={environments}
648
+ columns={nodeColumns}
649
+ rowKey="id"
650
+ size="small"
651
+ pagination={false}
652
+ scroll={{ x: 'max-content' }}
653
+ />
654
+ </Card>
655
+
656
+ {/* Current node details (always APP node) */}
657
+ {currentNode && (
658
+ <Card
659
+ title={
660
+ <Space>
661
+ {t('Current Node Details')}
662
+ <Tag color="green">APP</Tag>
663
+ </Space>
664
+ }
665
+ size="small"
666
+ >
667
+ {currentNode._fallback && (
668
+ <Alert
669
+ type="warning"
670
+ message={t('APP node not found in cluster registry. Showing data from the responding worker node.')}
671
+ showIcon
672
+ style={{ marginBottom: 12 }}
673
+ />
674
+ )}
675
+ <Descriptions size="small" column={3}>
676
+ <Descriptions.Item label={t('Hostname')}>{currentNode.node.hostname}</Descriptions.Item>
677
+ <Descriptions.Item label="PID">{currentNode.node.pid}</Descriptions.Item>
678
+ <Descriptions.Item label="Node.js">{currentNode.node.nodeVersion}</Descriptions.Item>
679
+ <Descriptions.Item label={t('Worker Mode')}>
680
+ {currentNode.node.workerMode || '(default)'}
681
+ </Descriptions.Item>
682
+ <Descriptions.Item label={t('App Port')}>{currentNode.node.appPort}</Descriptions.Item>
683
+ <Descriptions.Item label={t('Cluster Mode')}>
684
+ {currentNode.node.clusterMode || '(disabled)'}
685
+ </Descriptions.Item>
686
+ <Descriptions.Item label={t('RSS Memory')}>{formatBytes(currentNode.memory.rss)}</Descriptions.Item>
687
+ <Descriptions.Item label={t('OS Memory')}>
688
+ {formatBytes(currentNode.os.freeMemory)} free / {formatBytes(currentNode.os.totalMemory)}
689
+ </Descriptions.Item>
690
+ <Descriptions.Item label={t('CPU Cores')}>{currentNode.os.cpuCount}</Descriptions.Item>
691
+ </Descriptions>
692
+ </Card>
693
+ )}
694
+ <LogViewerModal open={!!logNode} node={logNode} onClose={() => setLogNode(null)} />
695
+ </Space>
696
+ </Spin>
697
+ );
698
+ }