plugin-cluster-manager 1.1.10 → 1.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/client.js +1 -0
  2. package/dist/client/Doctor.d.ts +2 -0
  3. package/dist/client/NginxCacheManager.d.ts +2 -0
  4. package/dist/client/index.js +1 -1
  5. package/dist/client/utils/clientSafeCache.d.ts +3 -0
  6. package/dist/client/utils/requestDedupInterceptor.d.ts +2 -0
  7. package/dist/externalVersion.js +5 -5
  8. package/dist/locale/en-US.json +97 -1
  9. package/dist/locale/vi-VN.json +98 -1
  10. package/dist/locale/zh-CN.json +98 -1
  11. package/dist/server/actions/cache-monitor.d.ts +10 -0
  12. package/dist/server/actions/cache-monitor.js +301 -0
  13. package/dist/server/actions/cluster-nodes.d.ts +15 -0
  14. package/dist/server/actions/cluster-nodes.js +394 -10
  15. package/dist/server/actions/doctor.d.ts +82 -0
  16. package/dist/server/actions/doctor.js +1250 -0
  17. package/dist/server/collections/cluster-manager-doctor-runs.d.ts +3 -0
  18. package/dist/server/collections/cluster-manager-doctor-runs.js +52 -0
  19. package/dist/server/collections/cluster-manager-doctor.d.ts +18 -0
  20. package/dist/server/collections/cluster-manager-doctor.js +44 -0
  21. package/dist/server/hooks/cacheInvalidationHooks.d.ts +1 -0
  22. package/dist/server/hooks/cacheInvalidationHooks.js +81 -0
  23. package/dist/server/middlewares/listMetaCacheMiddleware.d.ts +2 -0
  24. package/dist/server/middlewares/listMetaCacheMiddleware.js +79 -0
  25. package/dist/server/orchestrator/PackageManager.js +20 -16
  26. package/dist/server/plugin.js +61 -8
  27. package/dist/server/utils/versionManager.d.ts +10 -0
  28. package/dist/server/utils/versionManager.js +91 -0
  29. package/package.json +41 -41
  30. package/server.js +1 -0
  31. package/src/client/CacheMonitor.tsx +166 -179
  32. package/src/client/ClusterManagerLayout.tsx +48 -42
  33. package/src/client/ClusterNodes.tsx +691 -418
  34. package/src/client/Doctor.tsx +559 -0
  35. package/src/client/NginxCacheManager.tsx +415 -0
  36. package/src/client/PluginOperations.tsx +234 -234
  37. package/src/client/index.tsx +22 -14
  38. package/src/client/utils/clientSafeCache.ts +41 -0
  39. package/src/client/utils/requestDedupInterceptor.ts +213 -0
  40. package/src/locale/en-US.json +97 -1
  41. package/src/locale/vi-VN.json +98 -1
  42. package/src/locale/zh-CN.json +98 -1
  43. package/src/server/__tests__/doctor.test.ts +53 -0
  44. package/src/server/actions/acl-cache.ts +272 -272
  45. package/src/server/actions/cache-monitor.ts +453 -116
  46. package/src/server/actions/cluster-nodes.ts +882 -378
  47. package/src/server/actions/doctor.ts +1540 -0
  48. package/src/server/collections/cluster-manager-doctor-runs.ts +23 -0
  49. package/src/server/collections/cluster-manager-doctor.ts +19 -0
  50. package/src/server/hooks/cacheInvalidationHooks.ts +58 -0
  51. package/src/server/middlewares/listMetaCacheMiddleware.ts +55 -0
  52. package/src/server/orchestrator/PackageManager.ts +19 -15
  53. package/src/server/plugin.ts +338 -263
  54. package/src/server/utils/versionManager.ts +69 -0
@@ -0,0 +1,559 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ Alert,
4
+ Button,
5
+ Card,
6
+ Col,
7
+ Descriptions,
8
+ InputNumber,
9
+ message,
10
+ Progress,
11
+ Row,
12
+ Space,
13
+ Statistic,
14
+ Table,
15
+ Tag,
16
+ Typography,
17
+ } from 'antd';
18
+ import type { ColumnsType } from 'antd/es/table';
19
+ import {
20
+ DownloadOutlined,
21
+ MedicineBoxOutlined,
22
+ ReloadOutlined,
23
+ StopOutlined,
24
+ WarningOutlined,
25
+ } from '@ant-design/icons';
26
+ import { useAPIClient } from '@nocobase/client';
27
+ import { useT } from './utils';
28
+
29
+ const { Text } = Typography;
30
+
31
+ interface DoctorRun {
32
+ runId: string;
33
+ status: string;
34
+ durationMs: number;
35
+ progress: number;
36
+ startedAt: string;
37
+ deadlineAt: string;
38
+ finishedAt?: string;
39
+ finishReason?: string;
40
+ summary?: DoctorSummary;
41
+ report?: DoctorReport;
42
+ hasReport?: boolean;
43
+ error?: string;
44
+ }
45
+
46
+ interface ActiveRun {
47
+ runId: string;
48
+ startedAt: string;
49
+ deadlineAt: string;
50
+ durationMs: number;
51
+ startedBy?: string;
52
+ }
53
+
54
+ interface DoctorSummary {
55
+ status: 'healthy' | 'warning' | 'critical';
56
+ nodes: number;
57
+ snapshotErrors: number;
58
+ errors: number;
59
+ warnings: number;
60
+ versionDrift: boolean;
61
+ runtimeDrift: boolean;
62
+ pluginVersionDrifts: number;
63
+ pluginLoadDrifts: number;
64
+ packageDrifts: number;
65
+ failedTasks?: number | null;
66
+ failedWorkflows?: number | null;
67
+ }
68
+
69
+ interface DoctorReport {
70
+ runId: string;
71
+ startedAt: string;
72
+ finishedAt: string;
73
+ durationMs: number;
74
+ finishReason: string;
75
+ summary: DoctorSummary;
76
+ recommendations?: Array<{ level: string; code: string; message: string }>;
77
+ logAnalysis?: {
78
+ topSignatures?: Array<{
79
+ signature: string;
80
+ level: string;
81
+ count: number;
82
+ nodes?: string[];
83
+ sources?: string[];
84
+ samples?: string[];
85
+ }>;
86
+ byNode?: Array<{
87
+ nodeId: string;
88
+ hostname: string;
89
+ role: string;
90
+ levels?: Record<string, number>;
91
+ files?: Array<{ file: string; lineCount: number }>;
92
+ error?: string;
93
+ }>;
94
+ };
95
+ pluginDiagnostics?: {
96
+ plugins?: Array<{
97
+ name: string;
98
+ packageName: string;
99
+ enabled: boolean;
100
+ dbVersion?: string;
101
+ runtimeVersions?: string[];
102
+ versionDrift?: boolean;
103
+ loadDrift?: boolean;
104
+ }>;
105
+ };
106
+ packageDiagnostics?: {
107
+ packageDrifts?: Array<{
108
+ nodeId?: string;
109
+ hostname?: string;
110
+ role?: string;
111
+ status?: string;
112
+ missingPackages?: { apt?: string[]; npm?: string[]; python?: string[] };
113
+ }>;
114
+ };
115
+ }
116
+
117
+ function unwrapData(response: unknown) {
118
+ const value = response as { data?: { data?: unknown } | unknown };
119
+ if (value?.data && typeof value.data === 'object' && 'data' in value.data) {
120
+ return (value.data as { data?: unknown }).data;
121
+ }
122
+ return value?.data;
123
+ }
124
+
125
+ function getApiErrorMessage(error: unknown, fallback: string) {
126
+ const apiError = error as { response?: { data?: { errors?: Array<{ message?: string }> } }; message?: string };
127
+ return apiError?.response?.data?.errors?.[0]?.message || apiError?.message || fallback;
128
+ }
129
+
130
+ function formatSeconds(ms: number) {
131
+ const seconds = Math.max(0, Math.ceil(ms / 1000));
132
+ const minutes = Math.floor(seconds / 60);
133
+ const rest = seconds % 60;
134
+ return minutes > 0 ? `${minutes}m ${rest}s` : `${rest}s`;
135
+ }
136
+
137
+ function statusColor(status?: string) {
138
+ if (status === 'healthy' || status === 'finished') return 'green';
139
+ if (status === 'critical' || status === 'failed') return 'red';
140
+ if (status === 'running') return 'processing';
141
+ return 'orange';
142
+ }
143
+
144
+ function countMissingPackages(packages?: { apt?: string[]; npm?: string[]; python?: string[] }) {
145
+ return (packages?.apt?.length || 0) + (packages?.npm?.length || 0) + (packages?.python?.length || 0);
146
+ }
147
+
148
+ export function Doctor() {
149
+ const t = useT();
150
+ const api = useAPIClient();
151
+ const [loading, setLoading] = useState(false);
152
+ const [starting, setStarting] = useState(false);
153
+ const [stopping, setStopping] = useState(false);
154
+ const [durationMs, setDurationMs] = useState(120000);
155
+ const [activeRun, setActiveRun] = useState<ActiveRun | null>(null);
156
+ const [run, setRun] = useState<DoctorRun | null>(null);
157
+ const [report, setReport] = useState<DoctorReport | null>(null);
158
+ const [now, setNow] = useState(Date.now());
159
+
160
+ const isRunning = Boolean(activeRun) || run?.status === 'running';
161
+ const currentRunId = activeRun?.runId || run?.runId;
162
+ const summary = report?.summary || run?.summary;
163
+ const deadlineAt = activeRun?.deadlineAt || run?.deadlineAt;
164
+ const remainingMs = deadlineAt ? Math.max(0, Date.parse(deadlineAt) - now) : 0;
165
+
166
+ const loadStatus = useCallback(
167
+ async (silent = false) => {
168
+ if (!silent) setLoading(true);
169
+ try {
170
+ const response = await api.request({ url: 'clusterManagerDoctor:status' });
171
+ const body = unwrapData(response) as { activeRun?: ActiveRun | null; run?: DoctorRun | null };
172
+ setActiveRun(body?.activeRun || null);
173
+ setRun(body?.run || null);
174
+ if (body?.run?.report) {
175
+ setReport(body.run.report);
176
+ } else if (!body?.activeRun && body?.run?.hasReport && body?.run?.runId) {
177
+ const reportResponse = await api.request({
178
+ url: 'clusterManagerDoctor:report',
179
+ params: { runId: body.run.runId },
180
+ });
181
+ const reportBody = unwrapData(reportResponse) as { report?: DoctorReport | null };
182
+ setReport(reportBody?.report || null);
183
+ }
184
+ } catch {
185
+ message.error(t('Failed to load diagnostic status'));
186
+ } finally {
187
+ if (!silent) setLoading(false);
188
+ }
189
+ },
190
+ [api, t],
191
+ );
192
+
193
+ useEffect(() => {
194
+ loadStatus();
195
+ }, [loadStatus]);
196
+
197
+ useEffect(() => {
198
+ if (!isRunning) return;
199
+ const timer = setInterval(() => {
200
+ setNow(Date.now());
201
+ loadStatus(true);
202
+ }, 3000);
203
+ return () => clearInterval(timer);
204
+ }, [isRunning, loadStatus]);
205
+
206
+ useEffect(() => {
207
+ if (!isRunning) return;
208
+ const timer = setInterval(() => setNow(Date.now()), 1000);
209
+ return () => clearInterval(timer);
210
+ }, [isRunning]);
211
+
212
+ const startDoctor = async () => {
213
+ setStarting(true);
214
+ setReport(null);
215
+ try {
216
+ const response = await api.request({
217
+ url: 'clusterManagerDoctor:start',
218
+ method: 'post',
219
+ data: { durationMs },
220
+ });
221
+ const body = unwrapData(response) as ActiveRun;
222
+ setActiveRun(body);
223
+ message.success(t('Diagnostic session started'));
224
+ await loadStatus(true);
225
+ } catch (error) {
226
+ message.error(getApiErrorMessage(error, t('Failed to start diagnostic session')));
227
+ } finally {
228
+ setStarting(false);
229
+ }
230
+ };
231
+
232
+ const stopDoctor = async () => {
233
+ if (!currentRunId) return;
234
+ setStopping(true);
235
+ try {
236
+ const response = await api.request({
237
+ url: 'clusterManagerDoctor:stop',
238
+ method: 'post',
239
+ data: { runId: currentRunId },
240
+ });
241
+ const body = unwrapData(response) as DoctorRun | null;
242
+ setActiveRun(null);
243
+ setRun(body || null);
244
+ setReport(body?.report || null);
245
+ message.success(t('Diagnostic report is ready'));
246
+ } catch (error) {
247
+ message.error(getApiErrorMessage(error, t('Failed to stop diagnostic session')));
248
+ } finally {
249
+ setStopping(false);
250
+ }
251
+ };
252
+
253
+ const downloadReport = async () => {
254
+ const runId = report?.runId || run?.runId;
255
+ if (!runId) return;
256
+ try {
257
+ const response = await api.request({
258
+ url: 'clusterManagerDoctor:download',
259
+ params: { runId },
260
+ responseType: 'blob',
261
+ });
262
+ const url = window.URL.createObjectURL(new Blob([response.data], { type: 'application/json' }));
263
+ const link = document.createElement('a');
264
+ link.href = url;
265
+ link.download = `doctor-report-${runId}.json`;
266
+ link.click();
267
+ link.remove();
268
+ window.URL.revokeObjectURL(url);
269
+ } catch {
270
+ message.error(t('Failed to download diagnostic report'));
271
+ }
272
+ };
273
+
274
+ const topSignatures = report?.logAnalysis?.topSignatures || [];
275
+ const nodeRows = report?.logAnalysis?.byNode || [];
276
+ const pluginDrifts = useMemo(
277
+ () => (report?.pluginDiagnostics?.plugins || []).filter((plugin) => plugin.versionDrift || plugin.loadDrift),
278
+ [report],
279
+ );
280
+ const packageDrifts = report?.packageDiagnostics?.packageDrifts || [];
281
+ const recommendations = report?.recommendations || [];
282
+
283
+ const signatureColumns: ColumnsType<(typeof topSignatures)[number]> = [
284
+ {
285
+ title: t('Level'),
286
+ dataIndex: 'level',
287
+ width: 90,
288
+ render: (level: string) => <Tag color={level === 'error' ? 'red' : 'orange'}>{level}</Tag>,
289
+ },
290
+ { title: t('Count'), dataIndex: 'count', width: 90 },
291
+ {
292
+ title: t('Signature'),
293
+ dataIndex: 'signature',
294
+ render: (value: string) => <Text ellipsis={{ tooltip: value }}>{value}</Text>,
295
+ },
296
+ {
297
+ title: t('Nodes'),
298
+ dataIndex: 'nodes',
299
+ width: 220,
300
+ render: (nodes?: string[]) => (nodes || []).slice(0, 3).join(', '),
301
+ },
302
+ ];
303
+
304
+ const nodeColumns: ColumnsType<(typeof nodeRows)[number]> = [
305
+ { title: t('Node'), dataIndex: 'hostname' },
306
+ { title: t('Role'), dataIndex: 'role', width: 110 },
307
+ {
308
+ title: t('Errors'),
309
+ dataIndex: 'levels',
310
+ width: 100,
311
+ render: (levels?: Record<string, number>) => levels?.error || 0,
312
+ },
313
+ {
314
+ title: t('Warnings'),
315
+ dataIndex: 'levels',
316
+ width: 110,
317
+ render: (levels?: Record<string, number>) => levels?.warn || 0,
318
+ },
319
+ {
320
+ title: t('Log Files'),
321
+ dataIndex: 'files',
322
+ render: (files?: Array<{ file: string; lineCount: number }>) =>
323
+ files?.length ? files.map((file) => `${file.file} (${file.lineCount})`).join(', ') : '-',
324
+ },
325
+ ];
326
+
327
+ return (
328
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
329
+ <Card
330
+ size="small"
331
+ title={
332
+ <Space>
333
+ <MedicineBoxOutlined />
334
+ {t('Doctor')}
335
+ </Space>
336
+ }
337
+ extra={
338
+ <Button
339
+ icon={<ReloadOutlined />}
340
+ onClick={() => loadStatus(false)}
341
+ loading={loading}
342
+ aria-label={t('Refresh')}
343
+ >
344
+ {t('Refresh')}
345
+ </Button>
346
+ }
347
+ >
348
+ <Space direction="vertical" style={{ width: '100%' }}>
349
+ <Space wrap>
350
+ <InputNumber
351
+ min={10}
352
+ max={120}
353
+ step={10}
354
+ value={Math.round(durationMs / 1000)}
355
+ onChange={(value) => setDurationMs((Number(value) || 120) * 1000)}
356
+ addonAfter="s"
357
+ disabled={isRunning}
358
+ aria-label={t('Duration')}
359
+ style={{ width: 140 }}
360
+ />
361
+ <Button
362
+ type="primary"
363
+ icon={<MedicineBoxOutlined />}
364
+ onClick={startDoctor}
365
+ loading={starting}
366
+ disabled={isRunning}
367
+ >
368
+ {t('Start Doctor')}
369
+ </Button>
370
+ <Button danger icon={<StopOutlined />} onClick={stopDoctor} loading={stopping} disabled={!isRunning}>
371
+ {t('Stop Doctor')}
372
+ </Button>
373
+ <Button
374
+ icon={<DownloadOutlined />}
375
+ onClick={downloadReport}
376
+ disabled={!report?.runId && !run?.hasReport}
377
+ aria-label={t('Download Report')}
378
+ >
379
+ {t('Download Report')}
380
+ </Button>
381
+ </Space>
382
+
383
+ {isRunning && (
384
+ <div>
385
+ <Progress percent={run?.progress || 5} status="active" />
386
+ <Text type="secondary">
387
+ {t('Running')} - {formatSeconds(remainingMs)}
388
+ </Text>
389
+ </div>
390
+ )}
391
+
392
+ {run?.error && <Alert type="error" showIcon message={run.error} />}
393
+
394
+ {run && (
395
+ <Descriptions size="small" column={3}>
396
+ <Descriptions.Item label={t('Run ID')}>{run.runId}</Descriptions.Item>
397
+ <Descriptions.Item label={t('Status')}>
398
+ <Tag color={statusColor(run.status)}>{t(run.status)}</Tag>
399
+ </Descriptions.Item>
400
+ <Descriptions.Item label={t('Started At')}>
401
+ {run.startedAt ? new Date(run.startedAt).toLocaleString() : '-'}
402
+ </Descriptions.Item>
403
+ <Descriptions.Item label={t('Finished At')}>
404
+ {run.finishedAt ? new Date(run.finishedAt).toLocaleString() : '-'}
405
+ </Descriptions.Item>
406
+ <Descriptions.Item label={t('Finish Reason')}>{run.finishReason || '-'}</Descriptions.Item>
407
+ <Descriptions.Item label={t('Duration')}>
408
+ {formatSeconds(report?.durationMs || run.durationMs)}
409
+ </Descriptions.Item>
410
+ </Descriptions>
411
+ )}
412
+ </Space>
413
+ </Card>
414
+
415
+ {summary && (
416
+ <Row gutter={16}>
417
+ <Col span={4}>
418
+ <Card size="small">
419
+ <Statistic
420
+ title={t('Report Status')}
421
+ value={t(summary.status)}
422
+ valueStyle={{
423
+ color:
424
+ summary.status === 'critical' ? '#cf1322' : summary.status === 'warning' ? '#d48806' : '#3f8600',
425
+ }}
426
+ />
427
+ </Card>
428
+ </Col>
429
+ <Col span={4}>
430
+ <Card size="small">
431
+ <Statistic title={t('Nodes')} value={summary.nodes} />
432
+ </Card>
433
+ </Col>
434
+ <Col span={4}>
435
+ <Card size="small">
436
+ <Statistic
437
+ title={t('Errors')}
438
+ value={summary.errors}
439
+ valueStyle={{ color: summary.errors ? '#cf1322' : undefined }}
440
+ />
441
+ </Card>
442
+ </Col>
443
+ <Col span={4}>
444
+ <Card size="small">
445
+ <Statistic title={t('Warnings')} value={summary.warnings} />
446
+ </Card>
447
+ </Col>
448
+ <Col span={4}>
449
+ <Card size="small">
450
+ <Statistic title={t('Plugin Drift')} value={summary.pluginVersionDrifts + summary.pluginLoadDrifts} />
451
+ </Card>
452
+ </Col>
453
+ <Col span={4}>
454
+ <Card size="small">
455
+ <Statistic title={t('Package Drift')} value={summary.packageDrifts} />
456
+ </Card>
457
+ </Col>
458
+ </Row>
459
+ )}
460
+
461
+ {recommendations.length > 0 && (
462
+ <Card
463
+ title={
464
+ <Space>
465
+ <WarningOutlined />
466
+ {t('Findings')}
467
+ </Space>
468
+ }
469
+ size="small"
470
+ >
471
+ <Space direction="vertical" style={{ width: '100%' }}>
472
+ {recommendations.map((item) => (
473
+ <Alert
474
+ key={item.code}
475
+ type={item.level === 'critical' ? 'error' : 'warning'}
476
+ showIcon
477
+ message={item.message}
478
+ />
479
+ ))}
480
+ </Space>
481
+ </Card>
482
+ )}
483
+
484
+ {report && (
485
+ <>
486
+ <Card title={t('Node Log Distribution')} size="small">
487
+ <Table
488
+ rowKey="nodeId"
489
+ size="small"
490
+ pagination={{ pageSize: 5 }}
491
+ dataSource={nodeRows}
492
+ columns={nodeColumns}
493
+ />
494
+ </Card>
495
+
496
+ <Card title={t('Top Error Signatures')} size="small">
497
+ <Table
498
+ rowKey={(record) => `${record.level}:${record.signature}`}
499
+ size="small"
500
+ pagination={{ pageSize: 5 }}
501
+ dataSource={topSignatures}
502
+ columns={signatureColumns}
503
+ />
504
+ </Card>
505
+
506
+ <Card title={t('Plugin Drift')} size="small">
507
+ <Table
508
+ rowKey={(record) => record.packageName || record.name}
509
+ size="small"
510
+ pagination={{ pageSize: 6 }}
511
+ dataSource={pluginDrifts}
512
+ columns={[
513
+ { title: t('Plugin'), dataIndex: 'name' },
514
+ { title: t('Package'), dataIndex: 'packageName' },
515
+ { title: t('DB Version'), dataIndex: 'dbVersion', width: 140 },
516
+ {
517
+ title: t('Runtime Versions'),
518
+ dataIndex: 'runtimeVersions',
519
+ render: (versions: string[]) => versions?.join(', ') || '-',
520
+ },
521
+ {
522
+ title: t('Type'),
523
+ key: 'type',
524
+ width: 160,
525
+ render: (_, record) => (
526
+ <Space size={4}>
527
+ {record.versionDrift && <Tag color="orange">{t('Version')}</Tag>}
528
+ {record.loadDrift && <Tag color="purple">{t('Loaded')}</Tag>}
529
+ </Space>
530
+ ),
531
+ },
532
+ ]}
533
+ />
534
+ </Card>
535
+
536
+ <Card title={t('Package Drift')} size="small">
537
+ <Table
538
+ rowKey={(record, index) => record.nodeId || record.hostname || String(index)}
539
+ size="small"
540
+ pagination={{ pageSize: 6 }}
541
+ dataSource={packageDrifts}
542
+ columns={[
543
+ { title: t('Node'), dataIndex: 'hostname' },
544
+ { title: t('Role'), dataIndex: 'role', width: 110 },
545
+ { title: t('Package Status'), dataIndex: 'status', width: 140 },
546
+ {
547
+ title: t('Missing Packages'),
548
+ dataIndex: 'missingPackages',
549
+ render: (packages: { apt?: string[]; npm?: string[]; python?: string[] }) =>
550
+ countMissingPackages(packages) || '-',
551
+ },
552
+ ]}
553
+ />
554
+ </Card>
555
+ </>
556
+ )}
557
+ </Space>
558
+ );
559
+ }