Skip to content

Commit 7f68c2a

Browse files
committed
feat(greenhouse): plugin presets overview
1 parent 0ebf229 commit 7f68c2a

25 files changed

+2270
-74
lines changed

apps/greenhouse/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"vite": "7.2.4",
3737
"vite-plugin-svgr": "4.5.0",
3838
"vitest": "3.2.4",
39-
"zustand": "4.5.7"
39+
"zustand": "4.5.7",
40+
"react-error-boundary": "6.0.0"
4041
},
4142
"scripts": {
4243
"lint": "eslint",

apps/greenhouse/src/Shell.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import React, { StrictMode } from "react"
77
import { createBrowserHistory, createHashHistory, createRouter, RouterProvider } from "@tanstack/react-router"
88
import { AppShellProvider } from "@cloudoperators/juno-ui-components"
99
import { MessagesProvider } from "@cloudoperators/juno-messages-provider"
10+
import { createClient } from "@cloudoperators/juno-k8s-client"
1011
import Auth from "./components/Auth"
1112
import styles from "./styles.css?inline"
12-
import StoreProvider from "./components/StoreProvider"
13+
import StoreProvider, { useGlobalsApiEndpoint } from "./components/StoreProvider"
1314
import { AuthProvider, useAuth } from "./components/AuthProvider"
1415
import { routeTree } from "./routeTree.gen"
1516

@@ -18,6 +19,8 @@ const router = createRouter({
1819
routeTree,
1920
context: {
2021
appProps: undefined!,
22+
apiClient: null,
23+
organization: undefined!,
2124
},
2225
})
2326

@@ -50,8 +53,21 @@ const getBasePath = (auth: any) => {
5053
return orgString ? orgString.split(":")[1] : undefined
5154
}
5255

56+
const getOrganization = (auth: unknown) => {
57+
// @ts-expect-error - auth?.data type needs to be properly defined
58+
return auth?.data?.raw?.groups?.find((g: any) => g.startsWith("organization:"))?.split(":")[1]
59+
}
60+
5361
function App(props: AppProps) {
5462
const auth = useAuth()
63+
const apiEndpoint = useGlobalsApiEndpoint()
64+
// @ts-expect-error - useAuth return type is not properly typed
65+
const token = auth?.data?.JWT
66+
// Create k8s client if apiEndpoint and token are available
67+
// @ts-expect-error - apiEndpoint type needs to be properly typed as string
68+
const apiClient = apiEndpoint && token ? createClient({ apiEndpoint, token }) : null
69+
const organization = getOrganization(auth)
70+
5571
/*
5672
* Dynamically change the type of history on the router
5773
* based on the enableHashedRouting prop. This ensures that
@@ -60,7 +76,7 @@ function App(props: AppProps) {
6076
*/
6177
router.update({
6278
basepath: getBasePath(auth),
63-
context: { appProps: props },
79+
context: { appProps: props, apiClient, organization },
6480
history: props.enableHashedRouting ? createHashHistory() : createBrowserHistory(),
6581
})
6682
return <RouterProvider router={router} />

apps/greenhouse/src/components/admin/Layout/Navigation.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { useNavigate, useMatches, AnySchema } from "@tanstack/react-router"
88
import { TopNavigation, TopNavigationItem } from "@cloudoperators/juno-ui-components"
99

1010
export const navigationItems = [
11+
{
12+
label: "Plugin Presets",
13+
value: "/admin/plugin-presets",
14+
},
1115
{
1216
label: "Clusters",
1317
value: "/admin/clusters",
@@ -16,10 +20,6 @@ export const navigationItems = [
1620
label: "Teams",
1721
value: "/admin/teams",
1822
},
19-
{
20-
label: "Plugin Presets",
21-
value: "/admin/plugin-presets",
22-
},
2323
] as const
2424

2525
type NavigationItem = (typeof navigationItems)[number]

apps/greenhouse/src/components/admin/Layout/index.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,30 @@ import React from "react"
77
import { Container } from "@cloudoperators/juno-ui-components"
88
import { Breadcrumb } from "./Breadcrumb"
99
import { Navigation } from "./Navigation"
10+
import { ErrorMessage } from "../common/ErrorBoundary/ErrorMessage"
11+
import { Outlet } from "@tanstack/react-router"
1012

1113
type LayoutProps = {
12-
children: React.ReactNode
14+
error?: Error
1315
}
1416

15-
export const Layout = ({ children }: LayoutProps) => (
17+
export const Layout = ({ error }: LayoutProps) => (
1618
<>
1719
<Navigation />
1820
<Container py px>
1921
<Breadcrumb />
20-
<Container px={false}>{children}</Container>
22+
{/*
23+
This ensures that if an error was not caught by a sub-route,
24+
it is caught and displayed here keeping breadcrumb and the navigation visible,
25+
providing a consistent layout for error handling.
26+
*/}
27+
{error ? (
28+
<ErrorMessage error={error} />
29+
) : (
30+
<Container px={false}>
31+
<Outlet />
32+
</Container>
33+
)}
2134
</Container>
2235
</>
2336
)
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React, { use } from "react"
7+
import { DataGridRow, DataGridCell, Button, Icon } from "@cloudoperators/juno-ui-components"
8+
import { EmptyDataGridRow } from "../../common/EmptyDataGridRow"
9+
import { PluginPreset } from "../../types/k8sTypes"
10+
import { FilterSettings } from "../PluginPresetsFilter"
11+
12+
interface DataRowsProps {
13+
filterSettings: FilterSettings
14+
pluginPresetsPromise: Promise<PluginPreset[]>
15+
colSpan: number
16+
}
17+
18+
const getReadyCondition = (preset: PluginPreset) => {
19+
return preset.status?.statusConditions?.conditions?.find((condition) => condition.type === "Ready")
20+
}
21+
22+
export const DataRows = ({ filterSettings, pluginPresetsPromise, colSpan }: DataRowsProps) => {
23+
const pluginPresets = use(pluginPresetsPromise)
24+
25+
// TODO: Just for demonstration. Optimized filtering to be expected from backend in future.
26+
const filteredPresets = pluginPresets?.filter((preset) => {
27+
if (!filterSettings?.searchTerm) return true
28+
29+
const searchTerm = filterSettings.searchTerm.toLowerCase()
30+
const presetName = preset.metadata?.name?.toLowerCase() || ""
31+
const pluginDefinition = (
32+
preset.spec?.plugin?.pluginDefinitionRef?.name ||
33+
preset.spec?.plugin?.pluginDefinition ||
34+
""
35+
).toLowerCase()
36+
37+
return presetName.includes(searchTerm) || pluginDefinition.includes(searchTerm)
38+
})
39+
40+
if (!filteredPresets || filteredPresets.length === 0) {
41+
return <EmptyDataGridRow colSpan={colSpan}>No plugin presets found.</EmptyDataGridRow>
42+
}
43+
44+
return (
45+
<>
46+
{filteredPresets.map((preset: PluginPreset, idx: number) => (
47+
<DataGridRow key={idx}>
48+
<DataGridCell>
49+
<Icon
50+
icon={
51+
getReadyCondition(preset)?.type === "Ready" && getReadyCondition(preset)?.status === "True"
52+
? "checkCircle"
53+
: "error"
54+
}
55+
color={
56+
getReadyCondition(preset)?.type === "Ready" && getReadyCondition(preset)?.status === "True"
57+
? "text-theme-success"
58+
: "text-theme-danger"
59+
}
60+
/>
61+
</DataGridCell>
62+
<DataGridCell>
63+
{preset.status?.readyPlugins || 0}/{preset.status?.totalPlugins || 0}
64+
</DataGridCell>
65+
<DataGridCell>{preset.metadata?.name}</DataGridCell>
66+
<DataGridCell>
67+
{preset.spec?.plugin?.pluginDefinitionRef.name || preset.spec?.plugin?.pluginDefinition}
68+
</DataGridCell>
69+
<DataGridCell>
70+
{getReadyCondition(preset)?.type === "Ready" && getReadyCondition(preset)?.status !== "True"
71+
? getReadyCondition(preset)?.message
72+
: ""}
73+
</DataGridCell>
74+
<DataGridCell className="whitespace-nowrap">
75+
<Button size="small" variant="primary">
76+
View details
77+
</Button>
78+
</DataGridCell>
79+
</DataGridRow>
80+
))}
81+
</>
82+
)
83+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React, { act } from "react"
7+
import { render, screen } from "@testing-library/react"
8+
import { PluginPresetsDataGrid } from "./index"
9+
import { PluginPreset } from "../../types/k8sTypes"
10+
11+
const mockPluginPresets: PluginPreset[] = [
12+
{
13+
metadata: {
14+
name: "preset-1",
15+
},
16+
spec: {
17+
clusterSelector: {},
18+
deletionPolicy: "Delete",
19+
plugin: {
20+
pluginDefinitionRef: {
21+
name: "plugin-def-1",
22+
},
23+
deletionPolicy: "Delete",
24+
pluginDefinition: "plugin-def-1",
25+
},
26+
},
27+
status: {
28+
readyPlugins: 2,
29+
totalPlugins: 3,
30+
statusConditions: {
31+
conditions: [
32+
{
33+
lastTransitionTime: "2024-10-01T12:00:00Z",
34+
type: "Ready",
35+
status: "True",
36+
message: "",
37+
},
38+
],
39+
},
40+
},
41+
},
42+
{
43+
metadata: {
44+
name: "preset-2",
45+
},
46+
spec: {
47+
clusterSelector: {},
48+
deletionPolicy: "Delete",
49+
plugin: {
50+
pluginDefinitionRef: {
51+
name: "plugin-def-2",
52+
},
53+
deletionPolicy: "Delete",
54+
pluginDefinition: "plugin-def-2",
55+
},
56+
},
57+
status: {
58+
readyPlugins: 0,
59+
totalPlugins: 2,
60+
statusConditions: {
61+
conditions: [
62+
{
63+
lastTransitionTime: "2024-10-01T12:00:00Z",
64+
type: "Ready",
65+
status: "False",
66+
message: "Some error occurred",
67+
},
68+
],
69+
},
70+
},
71+
},
72+
]
73+
74+
describe("PluginPresetsDataGrid", () => {
75+
it("should render loading and column headers while the data is being fetched", async () => {
76+
const mockPluginPresetsPromise = Promise.resolve(mockPluginPresets)
77+
render(<PluginPresetsDataGrid filterSettings={{}} pluginPresetsPromise={mockPluginPresetsPromise} />)
78+
79+
// Loading should be gone
80+
expect(screen.queryByText("Loading...")).toBeInTheDocument()
81+
82+
// Check for column headers
83+
expect(screen.getByText("Instances")).toBeInTheDocument()
84+
expect(screen.getByText("Name")).toBeInTheDocument()
85+
expect(screen.getByText("Plugin Definition")).toBeInTheDocument()
86+
expect(screen.getByText("Message")).toBeInTheDocument()
87+
expect(screen.getByText("Actions")).toBeInTheDocument()
88+
})
89+
90+
it("should render the data", async () => {
91+
const mockPluginPresetsPromise = Promise.resolve(mockPluginPresets)
92+
await act(async () => {
93+
render(<PluginPresetsDataGrid filterSettings={{}} pluginPresetsPromise={mockPluginPresetsPromise} />)
94+
})
95+
96+
// Loading should be gone
97+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument()
98+
99+
// Check for column headers
100+
expect(screen.getByText("Instances")).toBeInTheDocument()
101+
expect(screen.getByText("Name")).toBeInTheDocument()
102+
expect(screen.getByText("Plugin Definition")).toBeInTheDocument()
103+
expect(screen.getByText("Message")).toBeInTheDocument()
104+
expect(screen.getByText("Actions")).toBeInTheDocument()
105+
106+
// Check for data
107+
expect(screen.getByText("2/3")).toBeInTheDocument()
108+
expect(screen.getByText("preset-1")).toBeInTheDocument()
109+
expect(screen.getByText("preset-2")).toBeInTheDocument()
110+
expect(screen.getByText("0/2")).toBeInTheDocument()
111+
})
112+
113+
it("should render the error message while fetching data", async () => {
114+
const mockPluginPresetsPromise = Promise.reject(new Error("Something went wrong"))
115+
await act(async () => {
116+
render(<PluginPresetsDataGrid filterSettings={{}} pluginPresetsPromise={mockPluginPresetsPromise} />)
117+
})
118+
119+
// Loading should be gone
120+
expect(screen.queryByText("Loading...")).not.toBeInTheDocument()
121+
122+
// Check for column headers
123+
expect(screen.getByText("Instances")).toBeInTheDocument()
124+
expect(screen.getByText("Name")).toBeInTheDocument()
125+
expect(screen.getByText("Plugin Definition")).toBeInTheDocument()
126+
expect(screen.getByText("Message")).toBeInTheDocument()
127+
expect(screen.getByText("Actions")).toBeInTheDocument()
128+
129+
// Check for error
130+
expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument()
131+
})
132+
})
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React, { Suspense } from "react"
7+
import { DataGrid, DataGridRow, DataGridHeadCell, Icon } from "@cloudoperators/juno-ui-components"
8+
import { DataRows } from "./DataRows"
9+
import { LoadingDataRow } from "../../common/LoadingDataRow"
10+
import { ErrorBoundary } from "../../common/ErrorBoundary"
11+
import { getErrorDataRowComponent } from "../../common/getErrorDataRow"
12+
import { PluginPreset } from "../../types/k8sTypes"
13+
import { FilterSettings } from "../PluginPresetsFilter"
14+
15+
const COLUMN_SPAN = 6
16+
17+
interface PluginPresetsDataGridProps {
18+
filterSettings: FilterSettings
19+
pluginPresetsPromise: Promise<PluginPreset[]>
20+
}
21+
22+
export const PluginPresetsDataGrid = ({ filterSettings, pluginPresetsPromise }: PluginPresetsDataGridProps) => {
23+
return (
24+
<div className="datagrid-hover">
25+
<DataGrid minContentColumns={[0, 1, 5]} columns={COLUMN_SPAN}>
26+
<DataGridRow>
27+
<DataGridHeadCell>
28+
<Icon icon="monitorHeart" />
29+
</DataGridHeadCell>
30+
<DataGridHeadCell>Instances</DataGridHeadCell>
31+
<DataGridHeadCell>Name</DataGridHeadCell>
32+
<DataGridHeadCell>Plugin Definition</DataGridHeadCell>
33+
<DataGridHeadCell>Message</DataGridHeadCell>
34+
<DataGridHeadCell>Actions</DataGridHeadCell>
35+
</DataGridRow>
36+
37+
<ErrorBoundary
38+
displayErrorMessage
39+
fallbackRender={getErrorDataRowComponent({ colspan: COLUMN_SPAN })}
40+
resetKeys={[pluginPresetsPromise]}
41+
>
42+
<Suspense fallback={<LoadingDataRow colSpan={COLUMN_SPAN} />}>
43+
<DataRows
44+
filterSettings={filterSettings}
45+
pluginPresetsPromise={pluginPresetsPromise}
46+
colSpan={COLUMN_SPAN}
47+
/>
48+
</Suspense>
49+
</ErrorBoundary>
50+
</DataGrid>
51+
</div>
52+
)
53+
}

0 commit comments

Comments
 (0)