@openshift-migration-advisor/shared-components 0.25.0-0ad1e70d1fa5 → 0.25.0-1701f64050d7
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/dist/report/ChartExportProvider.d.ts +3 -2
- package/dist/report/ChartExportProvider.d.ts.map +1 -1
- package/dist/report/ChartExportProvider.js +3 -3
- package/dist/report/ErrorTable.d.ts +18 -0
- package/dist/report/ErrorTable.d.ts.map +1 -0
- package/dist/report/ErrorTable.js +20 -0
- package/dist/report/HostsOverview.d.ts +13 -0
- package/dist/report/HostsOverview.d.ts.map +1 -0
- package/dist/report/HostsOverview.js +84 -0
- package/dist/report/NetworkOverview.d.ts +31 -0
- package/dist/report/NetworkOverview.d.ts.map +1 -0
- package/dist/report/NetworkOverview.js +201 -0
- package/dist/report/ReportTable.d.ts +14 -0
- package/dist/report/ReportTable.d.ts.map +1 -0
- package/dist/report/ReportTable.js +23 -0
- package/dist/report/WarningsTable.d.ts +9 -0
- package/dist/report/WarningsTable.d.ts.map +1 -0
- package/dist/report/WarningsTable.js +20 -0
- package/dist/report/chartExport.d.ts +16 -0
- package/dist/report/chartExport.d.ts.map +1 -1
- package/dist/report/chartExportContext.d.ts +6 -2
- package/dist/report/chartExportContext.d.ts.map +1 -1
- package/dist/report/constants.d.ts +2 -0
- package/dist/report/constants.d.ts.map +1 -1
- package/dist/report/constants.js +2 -0
- package/dist/report/index.d.ts +7 -2
- package/dist/report/index.d.ts.map +1 -1
- package/dist/report/index.js +5 -0
- package/dist/report/pdfExport.d.ts +2 -2
- package/dist/report/pdfExport.d.ts.map +1 -1
- package/dist/report/pdfExport.js +52 -3
- package/package.json +2 -2
- package/src/report/ChartExportProvider.tsx +13 -5
- package/src/report/ErrorTable.tsx +85 -0
- package/src/report/HostsOverview.tsx +185 -0
- package/src/report/NetworkOverview.tsx +405 -0
- package/src/report/ReportTable.tsx +81 -0
- package/src/report/WarningsTable.tsx +75 -0
- package/src/report/__tests__/ChartExportProvider.test.tsx +70 -2
- package/src/report/__tests__/HostsOverview.test.tsx +145 -0
- package/src/report/__tests__/NetworkOverview.test.tsx +269 -0
- package/src/report/__tests__/chartExportPackagers.test.ts +58 -0
- package/src/report/chartExport.ts +18 -0
- package/src/report/chartExportContext.tsx +9 -1
- package/src/report/constants.ts +2 -0
- package/src/report/index.ts +26 -0
- package/src/report/pdfExport.ts +81 -2
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { css } from "@emotion/css";
|
|
2
|
+
import {
|
|
3
|
+
Card,
|
|
4
|
+
CardBody,
|
|
5
|
+
CardTitle,
|
|
6
|
+
Dropdown,
|
|
7
|
+
DropdownItem,
|
|
8
|
+
DropdownList,
|
|
9
|
+
Flex,
|
|
10
|
+
FlexItem,
|
|
11
|
+
MenuToggle,
|
|
12
|
+
type MenuToggleElement,
|
|
13
|
+
} from "@patternfly/react-core";
|
|
14
|
+
import { TopologyIcon } from "@patternfly/react-icons";
|
|
15
|
+
import {
|
|
16
|
+
chart_color_black_500,
|
|
17
|
+
chart_color_blue_300,
|
|
18
|
+
chart_color_green_300,
|
|
19
|
+
chart_color_orange_100,
|
|
20
|
+
chart_color_purple_100,
|
|
21
|
+
chart_color_purple_300,
|
|
22
|
+
chart_color_red_orange_200,
|
|
23
|
+
chart_color_teal_200,
|
|
24
|
+
chart_color_teal_400,
|
|
25
|
+
chart_color_yellow_400,
|
|
26
|
+
} from "@patternfly/react-tokens";
|
|
27
|
+
import type { FC, Ref } from "react";
|
|
28
|
+
import { useMemo, useState } from "react";
|
|
29
|
+
import {
|
|
30
|
+
MigrationDonutChart,
|
|
31
|
+
type MigrationDonutChartDatum,
|
|
32
|
+
type MigrationDonutChartLegendVariant,
|
|
33
|
+
} from "../charts/MigrationDonutChart.js";
|
|
34
|
+
import { CardEmptyState } from "./CardEmptyState.js";
|
|
35
|
+
import { ChartHeaderActions } from "./ChartDownloadButton.js";
|
|
36
|
+
import { ChartExportSurface } from "./ChartExportSurface.js";
|
|
37
|
+
import { chartExportViewsFromLabels } from "./chartExport.js";
|
|
38
|
+
import { REPORT_CARD_EMPTY_STATE_TITLES } from "./constants.js";
|
|
39
|
+
import { dashboardStyles } from "./dashboardStyles.js";
|
|
40
|
+
|
|
41
|
+
/** Network fields this card reads. Apps pass SDK networks structurally. */
|
|
42
|
+
export interface NetworkLike {
|
|
43
|
+
vmsCount?: number;
|
|
44
|
+
vlanId?: string | number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Infra fields this card reads. */
|
|
48
|
+
export interface NetworkInfraLike {
|
|
49
|
+
networks?: NetworkLike[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Deprecated NIC histogram used when `distributionByNicCount` is absent. */
|
|
53
|
+
export interface NicCountHistogram {
|
|
54
|
+
data?: number[];
|
|
55
|
+
minValue?: number;
|
|
56
|
+
step?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** NIC summary fields this card reads from the VM resource breakdown. */
|
|
60
|
+
export interface NicCountSummary {
|
|
61
|
+
total?: number;
|
|
62
|
+
histogram?: NicCountHistogram;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface NetworkOverviewProps {
|
|
66
|
+
infra?: NetworkInfraLike;
|
|
67
|
+
nicCount?: NicCountSummary;
|
|
68
|
+
distributionByNicCount?: Record<string, number>;
|
|
69
|
+
/** `"html"` in agent-ui; ui-app passes `"chart"`. */
|
|
70
|
+
legendVariant?: MigrationDonutChartLegendVariant;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type ViewMode = "networkDistribution" | "nicCount";
|
|
74
|
+
|
|
75
|
+
const VIEW_MODE_LABELS: Record<ViewMode, string> = {
|
|
76
|
+
networkDistribution: "VM distribution by network",
|
|
77
|
+
nicCount: "VM distribution by NIC count",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const TOP_NETWORKS = 4;
|
|
81
|
+
const REST_OF_NETWORKS_LABEL = "Rest of networks";
|
|
82
|
+
const CHART_ID = "network-overview";
|
|
83
|
+
const CHART_TITLE = "Networks";
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Categorical order from the ui-app network donut, mapped onto PatternFly
|
|
87
|
+
* `chart_color_*` tokens (nearest token where the old hex was not a token).
|
|
88
|
+
*/
|
|
89
|
+
const NETWORK_COLORS = [
|
|
90
|
+
chart_color_blue_300.value,
|
|
91
|
+
chart_color_purple_300.value,
|
|
92
|
+
chart_color_purple_100.value,
|
|
93
|
+
chart_color_teal_200.value,
|
|
94
|
+
chart_color_yellow_400.value,
|
|
95
|
+
chart_color_green_300.value,
|
|
96
|
+
chart_color_orange_100.value,
|
|
97
|
+
chart_color_red_orange_200.value,
|
|
98
|
+
chart_color_teal_400.value,
|
|
99
|
+
chart_color_black_500.value,
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const cardSubtitle = css`
|
|
103
|
+
color: var(--pf-t--global--text--color--subtle);
|
|
104
|
+
font-size: 0.85rem;
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
const menuToggleMinWidth = css`
|
|
108
|
+
min-width: 250px;
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
const titleRow = css`
|
|
112
|
+
width: 100%;
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
const isViewMode = (value: string | number | undefined): value is ViewMode =>
|
|
116
|
+
value === "networkDistribution" || value === "nicCount";
|
|
117
|
+
|
|
118
|
+
const legendFor = (categories: string[]): Record<string, string> => {
|
|
119
|
+
const legend: Record<string, string> = {};
|
|
120
|
+
categories.forEach((category, index) => {
|
|
121
|
+
const color = NETWORK_COLORS[index % NETWORK_COLORS.length];
|
|
122
|
+
if (color) {
|
|
123
|
+
legend[category] = color;
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return legend;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const vlanLabel = (vlanId: NetworkLike["vlanId"]): string => {
|
|
130
|
+
if (typeof vlanId !== "string" && typeof vlanId !== "number") {
|
|
131
|
+
return "-";
|
|
132
|
+
}
|
|
133
|
+
const value = String(vlanId).trim();
|
|
134
|
+
return value !== "" ? value : "-";
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const buildNetworkDonut = (
|
|
138
|
+
networks: NetworkLike[] | undefined,
|
|
139
|
+
): {
|
|
140
|
+
slices: MigrationDonutChartDatum[];
|
|
141
|
+
legend: Record<string, string>;
|
|
142
|
+
title: string;
|
|
143
|
+
vlanBySlice: Record<string, string>;
|
|
144
|
+
} => {
|
|
145
|
+
const items = (Array.isArray(networks) ? networks : [])
|
|
146
|
+
.map((network) => ({
|
|
147
|
+
vmsCount:
|
|
148
|
+
typeof network?.vmsCount === "number" ? network.vmsCount : Number.NaN,
|
|
149
|
+
vlanId: vlanLabel(network?.vlanId),
|
|
150
|
+
}))
|
|
151
|
+
.filter(
|
|
152
|
+
(network) => Number.isFinite(network.vmsCount) && network.vmsCount >= 0,
|
|
153
|
+
)
|
|
154
|
+
.sort((a, b) => b.vmsCount - a.vmsCount);
|
|
155
|
+
|
|
156
|
+
const totalVMs = items.reduce((sum, network) => sum + network.vmsCount, 0);
|
|
157
|
+
const top = items.slice(0, TOP_NETWORKS);
|
|
158
|
+
const restSum = items
|
|
159
|
+
.slice(TOP_NETWORKS)
|
|
160
|
+
.reduce((sum, network) => sum + network.vmsCount, 0);
|
|
161
|
+
|
|
162
|
+
const vlanBySlice: Record<string, string> = {};
|
|
163
|
+
const slices: MigrationDonutChartDatum[] = top.map((network, index) => {
|
|
164
|
+
const name = `Network ${index + 1}`;
|
|
165
|
+
vlanBySlice[name] = network.vlanId;
|
|
166
|
+
return {
|
|
167
|
+
name,
|
|
168
|
+
count: network.vmsCount,
|
|
169
|
+
countDisplay: `${network.vmsCount} VMs`,
|
|
170
|
+
legendCategory: name,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
if (restSum > 0) {
|
|
175
|
+
slices.push({
|
|
176
|
+
name: REST_OF_NETWORKS_LABEL,
|
|
177
|
+
count: restSum,
|
|
178
|
+
countDisplay: `${restSum} VMs`,
|
|
179
|
+
legendCategory: REST_OF_NETWORKS_LABEL,
|
|
180
|
+
});
|
|
181
|
+
vlanBySlice[REST_OF_NETWORKS_LABEL] = "-";
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
slices,
|
|
186
|
+
legend: legendFor(slices.map((slice) => slice.legendCategory)),
|
|
187
|
+
title: `${totalVMs}`,
|
|
188
|
+
vlanBySlice,
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const nicBucketLabel = (bucket: string): string => {
|
|
193
|
+
const count = Number.parseInt(bucket, 10);
|
|
194
|
+
if (bucket.endsWith("+")) {
|
|
195
|
+
return `${count}+ NIC`;
|
|
196
|
+
}
|
|
197
|
+
return count === 1 ? "1 NIC" : `${count} NIC`;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const compareNicBuckets = (
|
|
201
|
+
a: { bucket: string },
|
|
202
|
+
b: { bucket: string },
|
|
203
|
+
): number => {
|
|
204
|
+
const aPlus = a.bucket.endsWith("+");
|
|
205
|
+
const bPlus = b.bucket.endsWith("+");
|
|
206
|
+
if (aPlus !== bPlus) {
|
|
207
|
+
return aPlus ? 1 : -1;
|
|
208
|
+
}
|
|
209
|
+
const aNum = Number.parseInt(a.bucket, 10);
|
|
210
|
+
const bNum = Number.parseInt(b.bucket, 10);
|
|
211
|
+
if (Number.isFinite(aNum) && Number.isFinite(bNum)) {
|
|
212
|
+
return aNum - bNum;
|
|
213
|
+
}
|
|
214
|
+
return a.bucket.localeCompare(b.bucket);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const slicesFromBuckets = (
|
|
218
|
+
entries: { bucket: string; count: number }[],
|
|
219
|
+
): MigrationDonutChartDatum[] =>
|
|
220
|
+
entries.map((entry) => {
|
|
221
|
+
const label = nicBucketLabel(entry.bucket);
|
|
222
|
+
return {
|
|
223
|
+
name: label,
|
|
224
|
+
count: entry.count,
|
|
225
|
+
countDisplay: `${entry.count} VMs`,
|
|
226
|
+
legendCategory: label,
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const buildNicDonut = (
|
|
231
|
+
distributionByNicCount: Record<string, number> | undefined,
|
|
232
|
+
nicCount: NicCountSummary | undefined,
|
|
233
|
+
): {
|
|
234
|
+
slices: MigrationDonutChartDatum[];
|
|
235
|
+
legend: Record<string, string>;
|
|
236
|
+
title: string;
|
|
237
|
+
} => {
|
|
238
|
+
if (distributionByNicCount != null) {
|
|
239
|
+
const entries = Object.entries(distributionByNicCount)
|
|
240
|
+
.map(([bucket, value]) => ({
|
|
241
|
+
bucket,
|
|
242
|
+
count: Number.isFinite(Number(value)) ? Number(value) : 0,
|
|
243
|
+
}))
|
|
244
|
+
.filter((entry) => entry.count > 0)
|
|
245
|
+
.sort(compareNicBuckets);
|
|
246
|
+
const slices = slicesFromBuckets(entries);
|
|
247
|
+
const total = entries.reduce((sum, entry) => sum + entry.count, 0);
|
|
248
|
+
return {
|
|
249
|
+
slices,
|
|
250
|
+
legend: legendFor(slices.map((slice) => slice.legendCategory)),
|
|
251
|
+
title: `${total}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const histogram = nicCount?.histogram;
|
|
256
|
+
const data = Array.isArray(histogram?.data) ? histogram.data : [];
|
|
257
|
+
const minValue =
|
|
258
|
+
typeof histogram?.minValue === "number" ? histogram.minValue : 0;
|
|
259
|
+
const step = typeof histogram?.step === "number" ? histogram.step : 1;
|
|
260
|
+
const buckets = data
|
|
261
|
+
.map((count, index) => ({
|
|
262
|
+
bucket: String(minValue + index * step),
|
|
263
|
+
count: Number(count) || 0,
|
|
264
|
+
}))
|
|
265
|
+
.filter((entry) => entry.count > 0)
|
|
266
|
+
.sort((a, b) => Number(a.bucket) - Number(b.bucket));
|
|
267
|
+
const slices = slicesFromBuckets(buckets);
|
|
268
|
+
const total =
|
|
269
|
+
typeof nicCount?.total === "number"
|
|
270
|
+
? nicCount.total
|
|
271
|
+
: buckets.reduce((sum, entry) => sum + entry.count, 0);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
slices,
|
|
275
|
+
legend: legendFor(slices.map((slice) => slice.legendCategory)),
|
|
276
|
+
title: `${total}`,
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const NetworkOverview: FC<NetworkOverviewProps> = ({
|
|
281
|
+
infra,
|
|
282
|
+
nicCount,
|
|
283
|
+
distributionByNicCount,
|
|
284
|
+
legendVariant = "html",
|
|
285
|
+
}) => {
|
|
286
|
+
const [viewMode, setViewMode] = useState<ViewMode>("networkDistribution");
|
|
287
|
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
288
|
+
|
|
289
|
+
const networkChart = useMemo(
|
|
290
|
+
() => buildNetworkDonut(infra?.networks),
|
|
291
|
+
[infra?.networks],
|
|
292
|
+
);
|
|
293
|
+
const nicChart = useMemo(
|
|
294
|
+
() => buildNicDonut(distributionByNicCount, nicCount),
|
|
295
|
+
[distributionByNicCount, nicCount],
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const chartTitle = `${CHART_TITLE} — ${VIEW_MODE_LABELS[viewMode]}`;
|
|
299
|
+
const activeChart =
|
|
300
|
+
viewMode === "networkDistribution" ? networkChart : nicChart;
|
|
301
|
+
const emptyTitle =
|
|
302
|
+
viewMode === "networkDistribution"
|
|
303
|
+
? REPORT_CARD_EMPTY_STATE_TITLES.networks
|
|
304
|
+
: REPORT_CARD_EMPTY_STATE_TITLES.nicCount;
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<ChartExportSurface
|
|
308
|
+
id={CHART_ID}
|
|
309
|
+
title={chartTitle}
|
|
310
|
+
exportViews={chartExportViewsFromLabels(CHART_TITLE, VIEW_MODE_LABELS)}
|
|
311
|
+
activeExportViewId={viewMode}
|
|
312
|
+
onExportViewChange={(viewId) => {
|
|
313
|
+
if (isViewMode(viewId)) {
|
|
314
|
+
setViewMode(viewId);
|
|
315
|
+
}
|
|
316
|
+
}}
|
|
317
|
+
>
|
|
318
|
+
<Card className={dashboardStyles.card}>
|
|
319
|
+
<CardTitle>
|
|
320
|
+
<Flex
|
|
321
|
+
className={titleRow}
|
|
322
|
+
justifyContent={{ default: "justifyContentSpaceBetween" }}
|
|
323
|
+
alignItems={{ default: "alignItemsCenter" }}
|
|
324
|
+
>
|
|
325
|
+
<FlexItem>
|
|
326
|
+
<div>
|
|
327
|
+
<div>
|
|
328
|
+
<TopologyIcon /> {CHART_TITLE}
|
|
329
|
+
</div>
|
|
330
|
+
{viewMode === "networkDistribution" && (
|
|
331
|
+
<div className={cardSubtitle}>Top 5 networks</div>
|
|
332
|
+
)}
|
|
333
|
+
</div>
|
|
334
|
+
</FlexItem>
|
|
335
|
+
<ChartHeaderActions chartId={CHART_ID} title={chartTitle}>
|
|
336
|
+
<Dropdown
|
|
337
|
+
isOpen={isDropdownOpen}
|
|
338
|
+
onSelect={(_event, value) => {
|
|
339
|
+
if (isViewMode(value)) {
|
|
340
|
+
setViewMode(value);
|
|
341
|
+
}
|
|
342
|
+
setIsDropdownOpen(false);
|
|
343
|
+
}}
|
|
344
|
+
onOpenChange={setIsDropdownOpen}
|
|
345
|
+
toggle={(toggleRef: Ref<MenuToggleElement>) => (
|
|
346
|
+
<MenuToggle
|
|
347
|
+
ref={toggleRef}
|
|
348
|
+
onClick={() => setIsDropdownOpen((open) => !open)}
|
|
349
|
+
isExpanded={isDropdownOpen}
|
|
350
|
+
className={menuToggleMinWidth}
|
|
351
|
+
>
|
|
352
|
+
{VIEW_MODE_LABELS[viewMode]}
|
|
353
|
+
</MenuToggle>
|
|
354
|
+
)}
|
|
355
|
+
>
|
|
356
|
+
<DropdownList>
|
|
357
|
+
<DropdownItem
|
|
358
|
+
value="networkDistribution"
|
|
359
|
+
key="networkDistribution"
|
|
360
|
+
>
|
|
361
|
+
{VIEW_MODE_LABELS.networkDistribution}
|
|
362
|
+
</DropdownItem>
|
|
363
|
+
<DropdownItem value="nicCount" key="nicCount">
|
|
364
|
+
{VIEW_MODE_LABELS.nicCount}
|
|
365
|
+
</DropdownItem>
|
|
366
|
+
</DropdownList>
|
|
367
|
+
</Dropdown>
|
|
368
|
+
</ChartHeaderActions>
|
|
369
|
+
</Flex>
|
|
370
|
+
</CardTitle>
|
|
371
|
+
<CardBody className={dashboardStyles.cardBodyScrollable}>
|
|
372
|
+
{activeChart.slices.length === 0 ? (
|
|
373
|
+
<CardEmptyState title={emptyTitle} />
|
|
374
|
+
) : (
|
|
375
|
+
<MigrationDonutChart
|
|
376
|
+
legendVariant={legendVariant}
|
|
377
|
+
data={activeChart.slices}
|
|
378
|
+
legend={activeChart.legend}
|
|
379
|
+
height={300}
|
|
380
|
+
width={420}
|
|
381
|
+
donutThickness={18}
|
|
382
|
+
titleFontSize={34}
|
|
383
|
+
title={activeChart.title}
|
|
384
|
+
subTitle="VMs"
|
|
385
|
+
subTitleColor="var(--pf-t--global--text--color--subtle)"
|
|
386
|
+
itemsPerRow={Math.ceil(activeChart.slices.length / 2)}
|
|
387
|
+
labelFontSize={18}
|
|
388
|
+
marginLeft="0%"
|
|
389
|
+
tooltipLabelFormatter={({ datum, percent }) => {
|
|
390
|
+
const summary = `${datum.countDisplay}\n${percent.toFixed(1)}%`;
|
|
391
|
+
if (viewMode !== "networkDistribution") {
|
|
392
|
+
return summary;
|
|
393
|
+
}
|
|
394
|
+
const vlan = networkChart.vlanBySlice[datum.legendCategory];
|
|
395
|
+
return `${summary}\nVLAN: ${vlan ?? "-"}`;
|
|
396
|
+
}}
|
|
397
|
+
/>
|
|
398
|
+
)}
|
|
399
|
+
</CardBody>
|
|
400
|
+
</Card>
|
|
401
|
+
</ChartExportSurface>
|
|
402
|
+
);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
NetworkOverview.displayName = "NetworkOverview";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Button } from "@patternfly/react-core";
|
|
2
|
+
import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table";
|
|
3
|
+
import type React from "react";
|
|
4
|
+
import { tableFullWidthStyle } from "./dashboardStyles.js";
|
|
5
|
+
|
|
6
|
+
export interface ReportTableProps<DataItem> {
|
|
7
|
+
columns: string[];
|
|
8
|
+
data: DataItem[];
|
|
9
|
+
fields: Array<keyof DataItem>;
|
|
10
|
+
caption?: string;
|
|
11
|
+
onRowClick?: (item: DataItem) => void;
|
|
12
|
+
clickableFields?: Array<keyof DataItem>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ReportTable<DataItem>(
|
|
16
|
+
props: ReportTableProps<DataItem>,
|
|
17
|
+
): React.ReactNode {
|
|
18
|
+
const {
|
|
19
|
+
columns,
|
|
20
|
+
data,
|
|
21
|
+
fields,
|
|
22
|
+
caption,
|
|
23
|
+
onRowClick,
|
|
24
|
+
clickableFields = [],
|
|
25
|
+
} = props;
|
|
26
|
+
|
|
27
|
+
const renderCell = (
|
|
28
|
+
item: DataItem,
|
|
29
|
+
field: keyof DataItem,
|
|
30
|
+
): React.ReactNode => {
|
|
31
|
+
const value = item[field];
|
|
32
|
+
|
|
33
|
+
if (value === "" || value == null) {
|
|
34
|
+
return "-";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof value === "boolean") {
|
|
38
|
+
return value ? "True" : "False";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Make field clickable if specified
|
|
42
|
+
if (clickableFields.includes(field) && onRowClick) {
|
|
43
|
+
return (
|
|
44
|
+
<Button
|
|
45
|
+
variant="link"
|
|
46
|
+
isInline
|
|
47
|
+
onClick={() => onRowClick(item)}
|
|
48
|
+
style={{ padding: 0, fontSize: "inherit" }}
|
|
49
|
+
>
|
|
50
|
+
{value as React.ReactNode}
|
|
51
|
+
</Button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return value as React.ReactNode;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<Table variant="compact" borders={false} className={tableFullWidthStyle}>
|
|
60
|
+
{caption && <caption>{caption}</caption>}
|
|
61
|
+
<Thead>
|
|
62
|
+
<Tr>
|
|
63
|
+
{columns.map((name) => (
|
|
64
|
+
<Th key={name}>{name}</Th>
|
|
65
|
+
))}
|
|
66
|
+
</Tr>
|
|
67
|
+
</Thead>
|
|
68
|
+
<Tbody>
|
|
69
|
+
{data.map((item) => (
|
|
70
|
+
<Tr key={`row-${String(item[fields[0]])}`}>
|
|
71
|
+
{fields.map((f) => (
|
|
72
|
+
<Td key={String(f)}>{renderCell(item, f)}</Td>
|
|
73
|
+
))}
|
|
74
|
+
</Tr>
|
|
75
|
+
))}
|
|
76
|
+
</Tbody>
|
|
77
|
+
</Table>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
ReportTable.displayName = "ReportTable";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Card,
|
|
3
|
+
CardBody,
|
|
4
|
+
CardTitle,
|
|
5
|
+
Flex,
|
|
6
|
+
FlexItem,
|
|
7
|
+
Icon,
|
|
8
|
+
} from "@patternfly/react-core";
|
|
9
|
+
import { ExclamationTriangleIcon } from "@patternfly/react-icons";
|
|
10
|
+
import type React from "react";
|
|
11
|
+
import { CardEmptyState } from "./CardEmptyState.js";
|
|
12
|
+
import { ChartHeaderActions } from "./ChartDownloadButton.js";
|
|
13
|
+
import { ChartExportSurface } from "./ChartExportSurface.js";
|
|
14
|
+
import { REPORT_CARD_EMPTY_STATE_TITLES } from "./constants.js";
|
|
15
|
+
import { dashboardStyles } from "./dashboardStyles.js";
|
|
16
|
+
import type { MigrationIssueLike } from "./ErrorTable.js";
|
|
17
|
+
import { ReportTable } from "./ReportTable.js";
|
|
18
|
+
|
|
19
|
+
export interface WarningsTableProps {
|
|
20
|
+
warnings: MigrationIssueLike[];
|
|
21
|
+
/** Optional drill-down callback invoked with an issue's `label`. */
|
|
22
|
+
onConcernClick?: (concernLabel: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const WarningsTable: React.FC<WarningsTableProps> = ({
|
|
26
|
+
warnings,
|
|
27
|
+
onConcernClick,
|
|
28
|
+
}) => {
|
|
29
|
+
const handleRowClick = (issue: MigrationIssueLike) => {
|
|
30
|
+
if (issue.label && onConcernClick) {
|
|
31
|
+
onConcernClick(issue.label);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const chartId = "warnings-table";
|
|
36
|
+
const chartTitle = "Warnings";
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<ChartExportSurface id={chartId} title={chartTitle}>
|
|
40
|
+
<Card className={dashboardStyles.card}>
|
|
41
|
+
<CardTitle>
|
|
42
|
+
<Flex
|
|
43
|
+
justifyContent={{ default: "justifyContentSpaceBetween" }}
|
|
44
|
+
alignItems={{ default: "alignItemsCenter" }}
|
|
45
|
+
>
|
|
46
|
+
<FlexItem>
|
|
47
|
+
<Icon status="warning">
|
|
48
|
+
<ExclamationTriangleIcon />
|
|
49
|
+
</Icon>{" "}
|
|
50
|
+
Warnings
|
|
51
|
+
</FlexItem>
|
|
52
|
+
<ChartHeaderActions chartId={chartId} title={chartTitle} />
|
|
53
|
+
</Flex>
|
|
54
|
+
</CardTitle>
|
|
55
|
+
<CardBody className={dashboardStyles.cardBodyScrollable}>
|
|
56
|
+
{warnings.length === 0 ? (
|
|
57
|
+
<CardEmptyState title={REPORT_CARD_EMPTY_STATE_TITLES.warnings} />
|
|
58
|
+
) : (
|
|
59
|
+
<div>
|
|
60
|
+
<ReportTable<MigrationIssueLike>
|
|
61
|
+
data={warnings}
|
|
62
|
+
columns={["Description", "Total VMs"]}
|
|
63
|
+
fields={["assessment", "count"]}
|
|
64
|
+
onRowClick={onConcernClick ? handleRowClick : undefined}
|
|
65
|
+
clickableFields={onConcernClick ? ["assessment"] : []}
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</CardBody>
|
|
70
|
+
</Card>
|
|
71
|
+
</ChartExportSurface>
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
WarningsTable.displayName = "WarningsTable";
|
|
@@ -101,12 +101,18 @@ function DownloadChartButton({ id }: { id: string }): JSX.Element {
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
function DownloadPdfButton(
|
|
104
|
+
function DownloadPdfButton({
|
|
105
|
+
extraPages,
|
|
106
|
+
}: {
|
|
107
|
+
extraPages?: Parameters<
|
|
108
|
+
NonNullable<ReturnType<typeof useChartExport>>["downloadPdf"]
|
|
109
|
+
>[1];
|
|
110
|
+
}): JSX.Element {
|
|
105
111
|
const exportApi = useChartExport();
|
|
106
112
|
return (
|
|
107
113
|
<button
|
|
108
114
|
type="button"
|
|
109
|
-
onClick={() => void exportApi?.downloadPdf("Overview")}
|
|
115
|
+
onClick={() => void exportApi?.downloadPdf("Overview", extraPages)}
|
|
110
116
|
>
|
|
111
117
|
Download pdf
|
|
112
118
|
</button>
|
|
@@ -236,6 +242,68 @@ describe("ChartExportProvider", () => {
|
|
|
236
242
|
expect(downloadFile).toHaveBeenCalledWith(expect.any(Blob), "report.pdf");
|
|
237
243
|
});
|
|
238
244
|
|
|
245
|
+
it("forwards extra text pages (e.g. cluster sizing recommendations) to the pdf builder", async () => {
|
|
246
|
+
const user = userEvent.setup();
|
|
247
|
+
const capture = vi.fn(async () => fakeCanvas());
|
|
248
|
+
const buildPdf = vi.fn(async () => new Blob(["pdf"]));
|
|
249
|
+
const downloadFile = vi.fn();
|
|
250
|
+
const extraPages = [
|
|
251
|
+
{
|
|
252
|
+
title: "Cluster sizing recommendations",
|
|
253
|
+
items: [
|
|
254
|
+
{ label: "Cluster name", value: "cluster-1" },
|
|
255
|
+
{ label: "Target platform", value: "Bare metal" },
|
|
256
|
+
],
|
|
257
|
+
footer: "Note: estimates only.",
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
|
|
261
|
+
render(
|
|
262
|
+
<ChartExportProvider
|
|
263
|
+
capture={capture}
|
|
264
|
+
zipFiles={vi.fn()}
|
|
265
|
+
buildPdf={buildPdf}
|
|
266
|
+
downloadFile={downloadFile}
|
|
267
|
+
getPdfFilename={() => "report.pdf"}
|
|
268
|
+
>
|
|
269
|
+
<RegisteredChart id="storage" title="Disks" />
|
|
270
|
+
<DownloadPdfButton extraPages={extraPages} />
|
|
271
|
+
</ChartExportProvider>,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
await user.click(screen.getByRole("button", { name: "Download pdf" }));
|
|
275
|
+
|
|
276
|
+
expect(buildPdf).toHaveBeenCalledTimes(1);
|
|
277
|
+
expect(buildPdf.mock.calls[0]?.[2]).toEqual(extraPages);
|
|
278
|
+
expect(downloadFile).toHaveBeenCalledWith(expect.any(Blob), "report.pdf");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("builds a PDF from extra text pages alone when no charts are registered", async () => {
|
|
282
|
+
const user = userEvent.setup();
|
|
283
|
+
const buildPdf = vi.fn(async () => new Blob(["pdf"]));
|
|
284
|
+
const downloadFile = vi.fn();
|
|
285
|
+
const extraPages = [{ title: "Cluster sizing recommendations", items: [] }];
|
|
286
|
+
|
|
287
|
+
render(
|
|
288
|
+
<ChartExportProvider
|
|
289
|
+
capture={vi.fn()}
|
|
290
|
+
zipFiles={vi.fn()}
|
|
291
|
+
buildPdf={buildPdf}
|
|
292
|
+
downloadFile={downloadFile}
|
|
293
|
+
getPdfFilename={() => "report.pdf"}
|
|
294
|
+
>
|
|
295
|
+
<DownloadPdfButton extraPages={extraPages} />
|
|
296
|
+
</ChartExportProvider>,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
await user.click(screen.getByRole("button", { name: "Download pdf" }));
|
|
300
|
+
|
|
301
|
+
expect(buildPdf).toHaveBeenCalledTimes(1);
|
|
302
|
+
expect(buildPdf.mock.calls[0]?.[0]).toEqual([]);
|
|
303
|
+
expect(buildPdf.mock.calls[0]?.[2]).toEqual(extraPages);
|
|
304
|
+
expect(downloadFile).toHaveBeenCalledWith(expect.any(Blob), "report.pdf");
|
|
305
|
+
});
|
|
306
|
+
|
|
239
307
|
it("builds HTML from the same captured canvases", async () => {
|
|
240
308
|
const user = userEvent.setup();
|
|
241
309
|
const capture = vi.fn(async () => fakeCanvas());
|