@openshift-migration-advisor/shared-components 0.25.0-6fd4e1ee8e11 → 0.25.0-db3dbade32ea

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.
@@ -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";
@@ -0,0 +1,145 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ chart_color_blue_300,
4
+ chart_color_green_300,
5
+ chart_color_purple_100,
6
+ chart_color_purple_300,
7
+ chart_color_teal_200,
8
+ chart_color_yellow_400,
9
+ } from "@patternfly/react-tokens";
10
+ import { cleanup, render, screen } from "@testing-library/react";
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import type { MigrationDonutChartProps } from "../../charts/MigrationDonutChart.js";
13
+ import { HostsOverview } from "../HostsOverview.js";
14
+
15
+ const donutProps = vi.hoisted(() => ({
16
+ current: null as MigrationDonutChartProps | null,
17
+ }));
18
+
19
+ vi.mock("../../charts/MigrationDonutChart.js", () => ({
20
+ MigrationDonutChart: (props: MigrationDonutChartProps): JSX.Element => {
21
+ donutProps.current = props;
22
+ return (
23
+ <div data-testid="hosts-donut">
24
+ {props.title} {props.subTitle}
25
+ </div>
26
+ );
27
+ },
28
+ }));
29
+
30
+ afterEach(() => {
31
+ donutProps.current = null;
32
+ cleanup();
33
+ });
34
+
35
+ const renderedDonut = (): MigrationDonutChartProps => {
36
+ const props = donutProps.current;
37
+ if (!props) {
38
+ throw new Error("expected the host model donut to render");
39
+ }
40
+ return props;
41
+ };
42
+
43
+ describe("HostsOverview", () => {
44
+ it("shows the shared empty state when no hosts are available", () => {
45
+ render(<HostsOverview />);
46
+
47
+ expect(screen.getByText("Host distribution by model")).toBeInTheDocument();
48
+ expect(screen.getByText("Top 5 models")).toBeInTheDocument();
49
+ expect(screen.getByText("Host data not collected")).toBeInTheDocument();
50
+ expect(screen.queryByTestId("hosts-donut")).not.toBeInTheDocument();
51
+ });
52
+
53
+ it("buckets host counts into the top 5 models plus Other models", () => {
54
+ render(
55
+ <HostsOverview
56
+ hosts={[
57
+ { model: "M1" },
58
+ { model: "M1" },
59
+ { model: "M1" },
60
+ { model: "M1" },
61
+ { model: "M1" },
62
+ { model: "M1" },
63
+ { model: "M2" },
64
+ { model: "M2" },
65
+ { model: "M2" },
66
+ { model: "M2" },
67
+ { model: "M2" },
68
+ { model: "M3" },
69
+ { model: "M3" },
70
+ { model: "M3" },
71
+ { model: "M3" },
72
+ { model: "M4" },
73
+ { model: "M4" },
74
+ { model: "M4" },
75
+ { model: "M5" },
76
+ { model: "M5" },
77
+ { model: "M6" },
78
+ { model: "M7" },
79
+ ]}
80
+ />,
81
+ );
82
+
83
+ expect(screen.getByTestId("hosts-donut")).toHaveTextContent("22 Hosts");
84
+
85
+ const props = renderedDonut();
86
+ expect(props.data.map((slice) => [slice.name, slice.count])).toEqual([
87
+ ["M1", 6],
88
+ ["M2", 5],
89
+ ["M3", 4],
90
+ ["M4", 3],
91
+ ["M5", 2],
92
+ ["Other models", 2],
93
+ ]);
94
+ expect(props.data[5]?.countDisplay).toBe("2 hosts");
95
+ expect(props.legend).toEqual({
96
+ M1: chart_color_blue_300.value,
97
+ M2: chart_color_purple_300.value,
98
+ M3: chart_color_purple_100.value,
99
+ M4: chart_color_teal_200.value,
100
+ M5: chart_color_yellow_400.value,
101
+ "Other models": chart_color_green_300.value,
102
+ });
103
+ expect(props.legendVariant).toBe("html");
104
+ expect(props.height).toBe(300);
105
+ expect(props.width).toBe(420);
106
+ expect(props.donutThickness).toBe(18);
107
+ expect(props.titleFontSize).toBe(34);
108
+ expect(props.legendWidth).toBe(680);
109
+ expect(props.itemsPerRow).toBe(2);
110
+ expect(props.labelFontSize).toBe(16);
111
+ expect(props.marginLeft).toBe("0%");
112
+ expect(props.subTitleColor).toBe(
113
+ "var(--pf-t--global--text--color--subtle)",
114
+ );
115
+ expect(
116
+ props.tooltipLabelFormatter?.({
117
+ datum: {
118
+ x: "M1",
119
+ y: 6,
120
+ countDisplay: "6 hosts",
121
+ legendCategory: "M1",
122
+ },
123
+ percent: 27.2727,
124
+ total: 22,
125
+ }),
126
+ ).toBe("6 hosts\n27.3%");
127
+ });
128
+
129
+ it("treats numeric models as labels and blank models as unknown", () => {
130
+ render(
131
+ <HostsOverview
132
+ legendVariant="chart"
133
+ hosts={[{ model: 42 }, { model: " " }, { model: " R640 " }]}
134
+ />,
135
+ );
136
+
137
+ expect(renderedDonut().data.map((slice) => slice.name)).toEqual([
138
+ "42",
139
+ "Unknown model",
140
+ "R640",
141
+ ]);
142
+ expect(renderedDonut().legendVariant).toBe("chart");
143
+ expect(renderedDonut().title).toBe("3");
144
+ });
145
+ });
@@ -0,0 +1,269 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ chart_color_black_500,
4
+ chart_color_blue_300,
5
+ chart_color_green_300,
6
+ chart_color_orange_100,
7
+ chart_color_purple_100,
8
+ chart_color_purple_300,
9
+ chart_color_red_orange_200,
10
+ chart_color_teal_200,
11
+ chart_color_teal_400,
12
+ chart_color_yellow_400,
13
+ } from "@patternfly/react-tokens";
14
+ import { cleanup, render, screen } from "@testing-library/react";
15
+ import userEvent from "@testing-library/user-event";
16
+ import { afterEach, describe, expect, it, vi } from "vitest";
17
+ import type { MigrationDonutChartProps } from "../../charts/MigrationDonutChart.js";
18
+ import { NetworkOverview } from "../NetworkOverview.js";
19
+
20
+ const donutProps = vi.hoisted(() => ({
21
+ current: null as MigrationDonutChartProps | null,
22
+ }));
23
+
24
+ vi.mock("../../charts/MigrationDonutChart.js", () => ({
25
+ MigrationDonutChart: (props: MigrationDonutChartProps): JSX.Element => {
26
+ donutProps.current = props;
27
+ return (
28
+ <div data-testid="network-donut">
29
+ {props.title} {props.subTitle}
30
+ </div>
31
+ );
32
+ },
33
+ }));
34
+
35
+ afterEach(() => {
36
+ donutProps.current = null;
37
+ cleanup();
38
+ });
39
+
40
+ const renderedDonut = (): MigrationDonutChartProps => {
41
+ const props = donutProps.current;
42
+ if (!props) {
43
+ throw new Error("expected a network donut to render");
44
+ }
45
+ return props;
46
+ };
47
+
48
+ describe("NetworkOverview", () => {
49
+ it("shows the network empty state when no networks were collected", () => {
50
+ render(<NetworkOverview />);
51
+
52
+ expect(screen.getByText("Networks")).toBeInTheDocument();
53
+ expect(screen.getByText("Top 5 networks")).toBeInTheDocument();
54
+ expect(screen.getByText("Network data not collected")).toBeInTheDocument();
55
+ expect(screen.queryByTestId("network-donut")).not.toBeInTheDocument();
56
+ });
57
+
58
+ it("keeps the top 4 networks and rolls the rest into one slice", () => {
59
+ render(
60
+ <NetworkOverview
61
+ infra={{
62
+ networks: [
63
+ { vmsCount: 10, vlanId: "100" },
64
+ { vmsCount: 8, vlanId: 200 },
65
+ { vmsCount: 6, vlanId: " " },
66
+ { vmsCount: 4 },
67
+ { vmsCount: 3, vlanId: "9" },
68
+ { vmsCount: 1, vlanId: "8" },
69
+ { vmsCount: Number.NaN },
70
+ ],
71
+ }}
72
+ />,
73
+ );
74
+
75
+ const props = renderedDonut();
76
+ expect(props.title).toBe("32");
77
+ expect(props.subTitle).toBe("VMs");
78
+ expect(props.data.map((slice) => [slice.name, slice.count])).toEqual([
79
+ ["Network 1", 10],
80
+ ["Network 2", 8],
81
+ ["Network 3", 6],
82
+ ["Network 4", 4],
83
+ ["Rest of networks", 4],
84
+ ]);
85
+ expect(props.legend).toEqual({
86
+ "Network 1": chart_color_blue_300.value,
87
+ "Network 2": chart_color_purple_300.value,
88
+ "Network 3": chart_color_purple_100.value,
89
+ "Network 4": chart_color_teal_200.value,
90
+ "Rest of networks": chart_color_yellow_400.value,
91
+ });
92
+ expect(props.legendVariant).toBe("html");
93
+ expect(props.height).toBe(300);
94
+ expect(props.width).toBe(420);
95
+ expect(props.donutThickness).toBe(18);
96
+ expect(props.titleFontSize).toBe(34);
97
+ expect(props.labelFontSize).toBe(18);
98
+ expect(props.itemsPerRow).toBe(3);
99
+ expect(props.marginLeft).toBe("0%");
100
+ expect(
101
+ props.tooltipLabelFormatter?.({
102
+ datum: {
103
+ x: "Network 1",
104
+ y: 10,
105
+ countDisplay: "10 VMs",
106
+ legendCategory: "Network 1",
107
+ },
108
+ percent: 31.25,
109
+ total: 32,
110
+ }),
111
+ ).toBe("10 VMs\n31.3%\nVLAN: 100");
112
+ expect(
113
+ props.tooltipLabelFormatter?.({
114
+ datum: {
115
+ x: "Network 2",
116
+ y: 8,
117
+ countDisplay: "8 VMs",
118
+ legendCategory: "Network 2",
119
+ },
120
+ percent: 25,
121
+ total: 32,
122
+ }),
123
+ ).toBe("8 VMs\n25.0%\nVLAN: 200");
124
+ expect(
125
+ props.tooltipLabelFormatter?.({
126
+ datum: {
127
+ x: "Rest of networks",
128
+ y: 4,
129
+ countDisplay: "4 VMs",
130
+ legendCategory: "Rest of networks",
131
+ },
132
+ percent: 12.5,
133
+ total: 32,
134
+ }),
135
+ ).toBe("4 VMs\n12.5%\nVLAN: -");
136
+ });
137
+
138
+ it("switches to NIC count and falls back to the histogram", async () => {
139
+ const user = userEvent.setup();
140
+ render(
141
+ <NetworkOverview
142
+ legendVariant="chart"
143
+ nicCount={{
144
+ total: 9,
145
+ histogram: { minValue: 1, step: 1, data: [4, 0, 5] },
146
+ }}
147
+ />,
148
+ );
149
+
150
+ await user.click(
151
+ screen.getByRole("button", { name: "VM distribution by network" }),
152
+ );
153
+ await user.click(
154
+ screen.getByRole("menuitem", { name: "VM distribution by NIC count" }),
155
+ );
156
+
157
+ expect(screen.queryByText("Top 5 networks")).not.toBeInTheDocument();
158
+ expect(
159
+ screen.getByRole("button", { name: "VM distribution by NIC count" }),
160
+ ).toBeInTheDocument();
161
+
162
+ const props = renderedDonut();
163
+ expect(props.legendVariant).toBe("chart");
164
+ expect(props.title).toBe("9");
165
+ expect(props.data.map((slice) => [slice.name, slice.count])).toEqual([
166
+ ["1 NIC", 4],
167
+ ["3 NIC", 5],
168
+ ]);
169
+ expect(
170
+ props.tooltipLabelFormatter?.({
171
+ datum: {
172
+ x: "1 NIC",
173
+ y: 4,
174
+ countDisplay: "4 VMs",
175
+ legendCategory: "1 NIC",
176
+ },
177
+ percent: 44.444,
178
+ total: 9,
179
+ }),
180
+ ).toBe("4 VMs\n44.4%");
181
+ });
182
+
183
+ it("plots distributionByNicCount ahead of the deprecated histogram", async () => {
184
+ const user = userEvent.setup();
185
+ render(
186
+ <NetworkOverview
187
+ distributionByNicCount={{ "2": 5, "4+": 2, "1": 3, "0": 1 }}
188
+ nicCount={{
189
+ total: 99,
190
+ histogram: { minValue: 1, step: 1, data: [9] },
191
+ }}
192
+ />,
193
+ );
194
+
195
+ await user.click(
196
+ screen.getByRole("button", { name: "VM distribution by network" }),
197
+ );
198
+ await user.click(
199
+ screen.getByRole("menuitem", { name: "VM distribution by NIC count" }),
200
+ );
201
+
202
+ const props = renderedDonut();
203
+ expect(props.title).toBe("11");
204
+ expect(props.data.map((slice) => slice.name)).toEqual([
205
+ "0 NIC",
206
+ "1 NIC",
207
+ "2 NIC",
208
+ "4+ NIC",
209
+ ]);
210
+ expect(props.legend).toEqual({
211
+ "0 NIC": chart_color_blue_300.value,
212
+ "1 NIC": chart_color_purple_300.value,
213
+ "2 NIC": chart_color_purple_100.value,
214
+ "4+ NIC": chart_color_teal_200.value,
215
+ });
216
+ });
217
+
218
+ it("shows the NIC empty state when the selected distribution is empty", async () => {
219
+ const user = userEvent.setup();
220
+ render(<NetworkOverview distributionByNicCount={{}} />);
221
+
222
+ await user.click(
223
+ screen.getByRole("button", { name: "VM distribution by network" }),
224
+ );
225
+ await user.click(
226
+ screen.getByRole("menuitem", { name: "VM distribution by NIC count" }),
227
+ );
228
+
229
+ expect(
230
+ screen.getByText("NIC count data not collected"),
231
+ ).toBeInTheDocument();
232
+ expect(screen.queryByTestId("network-donut")).not.toBeInTheDocument();
233
+ });
234
+
235
+ it("uses the extended palette when there are more NIC buckets than base colors", async () => {
236
+ const user = userEvent.setup();
237
+ render(
238
+ <NetworkOverview
239
+ distributionByNicCount={{
240
+ "0": 1,
241
+ "1": 1,
242
+ "2": 1,
243
+ "3": 1,
244
+ "4": 1,
245
+ "5": 1,
246
+ "6": 1,
247
+ "7": 1,
248
+ "8": 1,
249
+ "9": 1,
250
+ }}
251
+ />,
252
+ );
253
+
254
+ await user.click(
255
+ screen.getByRole("button", { name: "VM distribution by network" }),
256
+ );
257
+ await user.click(
258
+ screen.getByRole("menuitem", { name: "VM distribution by NIC count" }),
259
+ );
260
+
261
+ expect(renderedDonut().legend).toMatchObject({
262
+ "5 NIC": chart_color_green_300.value,
263
+ "6 NIC": chart_color_orange_100.value,
264
+ "7 NIC": chart_color_red_orange_200.value,
265
+ "8 NIC": chart_color_teal_400.value,
266
+ "9 NIC": chart_color_black_500.value,
267
+ });
268
+ });
269
+ });
@@ -25,6 +25,8 @@ export const REPORT_CARD_EMPTY_STATE_TITLES = {
25
25
  operatingSystems: "Operating system data not collected",
26
26
  migrationStatus: "Migration status data not collected",
27
27
  issuesBreakdown: "Issues breakdown data not collected",
28
+ errors: "Error data not collected",
29
+ warnings: "Warning data not collected",
28
30
  hostPowerStates: "Host power state data not collected",
29
31
  vmPowerStates: "VM power state data not collected",
30
32
  } as const;
@@ -65,6 +65,11 @@ export {
65
65
  EmptySearchResults,
66
66
  type EmptySearchResultsProps,
67
67
  } from "./EmptySearchResults.js";
68
+ export {
69
+ ErrorTable,
70
+ type ErrorTableProps,
71
+ type MigrationIssueLike,
72
+ } from "./ErrorTable.js";
68
73
  export {
69
74
  FeatureStatusBadge,
70
75
  type FeatureStatusBadgeProps,
@@ -73,6 +78,11 @@ export {
73
78
  HostPowerStates,
74
79
  type HostPowerStatesProps,
75
80
  } from "./HostPowerStates.js";
81
+ export {
82
+ type HostLike,
83
+ HostsOverview,
84
+ type HostsOverviewProps,
85
+ } from "./HostsOverview.js";
76
86
  export {
77
87
  buildHtmlFromCharts,
78
88
  buildHtmlReport,
@@ -108,6 +118,14 @@ export {
108
118
  visibleNetworks,
109
119
  vsanStatus,
110
120
  } from "./infrastructureSummaryModel.js";
121
+ export {
122
+ type NetworkInfraLike,
123
+ type NetworkLike,
124
+ NetworkOverview,
125
+ type NetworkOverviewProps,
126
+ type NicCountHistogram,
127
+ type NicCountSummary,
128
+ } from "./NetworkOverview.js";
111
129
  export { OSBarChart, OSDistribution } from "./OSDistribution.js";
112
130
  export { OsSupportTiersHelpPopover } from "./OsSupportTiersHelpPopover.js";
113
131
  export { OsUpgradeNotice } from "./OsUpgradeNotice.js";
@@ -159,6 +177,7 @@ export {
159
177
  ReportExportMenu,
160
178
  type ReportExportMenuProps,
161
179
  } from "./ReportExportMenu.js";
180
+ export { ReportTable, type ReportTableProps } from "./ReportTable.js";
162
181
  export {
163
182
  type ReportExportOption,
164
183
  type StandardReportExportHandlers,
@@ -179,4 +198,8 @@ export {
179
198
  VmPowerStates,
180
199
  type VmPowerStatesProps,
181
200
  } from "./VmPowerStates.js";
201
+ export {
202
+ WarningsTable,
203
+ type WarningsTableProps,
204
+ } from "./WarningsTable.js";
182
205
  export { zipChartPngs } from "./zipChartPngs.js";