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.
- package/client.js +1 -0
- package/dist/client/Doctor.d.ts +2 -0
- package/dist/client/NginxCacheManager.d.ts +2 -0
- package/dist/client/index.js +1 -1
- package/dist/client/utils/clientSafeCache.d.ts +3 -0
- package/dist/client/utils/requestDedupInterceptor.d.ts +2 -0
- package/dist/externalVersion.js +5 -5
- package/dist/locale/en-US.json +97 -1
- package/dist/locale/vi-VN.json +98 -1
- package/dist/locale/zh-CN.json +98 -1
- package/dist/server/actions/cache-monitor.d.ts +10 -0
- package/dist/server/actions/cache-monitor.js +301 -0
- package/dist/server/actions/cluster-nodes.d.ts +15 -0
- package/dist/server/actions/cluster-nodes.js +394 -10
- package/dist/server/actions/doctor.d.ts +82 -0
- package/dist/server/actions/doctor.js +1250 -0
- package/dist/server/collections/cluster-manager-doctor-runs.d.ts +3 -0
- package/dist/server/collections/cluster-manager-doctor-runs.js +52 -0
- package/dist/server/collections/cluster-manager-doctor.d.ts +18 -0
- package/dist/server/collections/cluster-manager-doctor.js +44 -0
- package/dist/server/hooks/cacheInvalidationHooks.d.ts +1 -0
- package/dist/server/hooks/cacheInvalidationHooks.js +81 -0
- package/dist/server/middlewares/listMetaCacheMiddleware.d.ts +2 -0
- package/dist/server/middlewares/listMetaCacheMiddleware.js +79 -0
- package/dist/server/orchestrator/PackageManager.js +20 -16
- package/dist/server/plugin.js +61 -8
- package/dist/server/utils/versionManager.d.ts +10 -0
- package/dist/server/utils/versionManager.js +91 -0
- package/package.json +41 -41
- package/server.js +1 -0
- package/src/client/CacheMonitor.tsx +166 -179
- package/src/client/ClusterManagerLayout.tsx +48 -42
- package/src/client/ClusterNodes.tsx +691 -418
- package/src/client/Doctor.tsx +559 -0
- package/src/client/NginxCacheManager.tsx +415 -0
- package/src/client/PluginOperations.tsx +234 -234
- package/src/client/index.tsx +22 -14
- package/src/client/utils/clientSafeCache.ts +41 -0
- package/src/client/utils/requestDedupInterceptor.ts +213 -0
- package/src/locale/en-US.json +97 -1
- package/src/locale/vi-VN.json +98 -1
- package/src/locale/zh-CN.json +98 -1
- package/src/server/__tests__/doctor.test.ts +53 -0
- package/src/server/actions/acl-cache.ts +272 -272
- package/src/server/actions/cache-monitor.ts +453 -116
- package/src/server/actions/cluster-nodes.ts +882 -378
- package/src/server/actions/doctor.ts +1540 -0
- package/src/server/collections/cluster-manager-doctor-runs.ts +23 -0
- package/src/server/collections/cluster-manager-doctor.ts +19 -0
- package/src/server/hooks/cacheInvalidationHooks.ts +58 -0
- package/src/server/middlewares/listMetaCacheMiddleware.ts +55 -0
- package/src/server/orchestrator/PackageManager.ts +19 -15
- package/src/server/plugin.ts +338 -263
- 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
|
+
}
|