diff --git a/apps/greenhouse/package.json b/apps/greenhouse/package.json index 64bb570443..5c42beaeee 100644 --- a/apps/greenhouse/package.json +++ b/apps/greenhouse/package.json @@ -36,7 +36,8 @@ "vite": "7.3.0", "vite-plugin-svgr": "4.5.0", "vitest": "3.2.4", - "zustand": "4.5.7" + "zustand": "4.5.7", + "react-error-boundary": "6.0.0" }, "scripts": { "lint": "eslint", diff --git a/apps/greenhouse/src/Shell.tsx b/apps/greenhouse/src/Shell.tsx index b86415c69c..eabe570624 100644 --- a/apps/greenhouse/src/Shell.tsx +++ b/apps/greenhouse/src/Shell.tsx @@ -7,9 +7,10 @@ import React, { StrictMode } from "react" import { createBrowserHistory, createHashHistory, createRouter, RouterProvider } from "@tanstack/react-router" import { AppShellProvider } from "@cloudoperators/juno-ui-components" import { MessagesProvider } from "@cloudoperators/juno-messages-provider" +import { createClient } from "@cloudoperators/juno-k8s-client" import Auth from "./components/Auth" import styles from "./styles.css?inline" -import StoreProvider from "./components/StoreProvider" +import StoreProvider, { useGlobalsApiEndpoint } from "./components/StoreProvider" import { AuthProvider, useAuth } from "./components/AuthProvider" import { routeTree } from "./routeTree.gen" @@ -18,6 +19,8 @@ const router = createRouter({ routeTree, context: { appProps: undefined!, + apiClient: null, + organization: undefined!, }, }) @@ -50,8 +53,21 @@ const getBasePath = (auth: any) => { return orgString ? orgString.split(":")[1] : undefined } +const getOrganization = (auth: unknown) => { + // @ts-expect-error - auth?.data type needs to be properly defined + return auth?.data?.raw?.groups?.find((g: any) => g.startsWith("organization:"))?.split(":")[1] +} + function App(props: AppProps) { const auth = useAuth() + const apiEndpoint = useGlobalsApiEndpoint() + // @ts-expect-error - useAuth return type is not properly typed + const token = auth?.data?.JWT + // Create k8s client if apiEndpoint and token are available + // @ts-expect-error - apiEndpoint type needs to be properly typed as string + const apiClient = apiEndpoint && token ? createClient({ apiEndpoint, token }) : null + const organization = getOrganization(auth) + /* * Dynamically change the type of history on the router * based on the enableHashedRouting prop. This ensures that @@ -60,7 +76,7 @@ function App(props: AppProps) { */ router.update({ basepath: getBasePath(auth), - context: { appProps: props }, + context: { appProps: props, apiClient, organization }, history: props.enableHashedRouting ? createHashHistory() : createBrowserHistory(), }) return diff --git a/apps/greenhouse/src/components/admin/Layout/Navigation.tsx b/apps/greenhouse/src/components/admin/Layout/Navigation.tsx index 445132ec20..28428d81ba 100644 --- a/apps/greenhouse/src/components/admin/Layout/Navigation.tsx +++ b/apps/greenhouse/src/components/admin/Layout/Navigation.tsx @@ -8,6 +8,10 @@ import { useNavigate, useMatches, AnySchema } from "@tanstack/react-router" import { TopNavigation, TopNavigationItem } from "@cloudoperators/juno-ui-components" export const navigationItems = [ + { + label: "Plugin Presets", + value: "/admin/plugin-presets", + }, { label: "Clusters", value: "/admin/clusters", @@ -16,10 +20,6 @@ export const navigationItems = [ label: "Teams", value: "/admin/teams", }, - { - label: "Plugin Presets", - value: "/admin/plugin-presets", - }, ] as const type NavigationItem = (typeof navigationItems)[number] diff --git a/apps/greenhouse/src/components/admin/Layout/index.tsx b/apps/greenhouse/src/components/admin/Layout/index.tsx index 23bae61086..95bad3904b 100644 --- a/apps/greenhouse/src/components/admin/Layout/index.tsx +++ b/apps/greenhouse/src/components/admin/Layout/index.tsx @@ -7,17 +7,30 @@ import React from "react" import { Container } from "@cloudoperators/juno-ui-components" import { Breadcrumb } from "./Breadcrumb" import { Navigation } from "./Navigation" +import { ErrorMessage } from "../common/ErrorBoundary/ErrorMessage" +import { Outlet } from "@tanstack/react-router" type LayoutProps = { - children: React.ReactNode + error?: Error } -export const Layout = ({ children }: LayoutProps) => ( +export const Layout = ({ error }: LayoutProps) => ( <> - {children} + {/* + This ensures that if an error was not caught by a sub-route, + it is caught and displayed here keeping breadcrumb and the navigation visible, + providing a consistent layout for error handling. + */} + {error ? ( + + ) : ( + + + + )} ) diff --git a/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsDataGrid/DataRows.tsx b/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsDataGrid/DataRows.tsx new file mode 100644 index 0000000000..8a4af1f6b2 --- /dev/null +++ b/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsDataGrid/DataRows.tsx @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { use } from "react" +import { DataGridRow, DataGridCell, Button, Icon } from "@cloudoperators/juno-ui-components" +import { EmptyDataGridRow } from "../../common/EmptyDataGridRow" +import { PluginPreset } from "../../types/k8sTypes" +import { getReadyCondition, isReady } from "../../utils" + +interface DataRowsProps { + pluginPresetsPromise: Promise + colSpan: number +} +export const DataRows = ({ pluginPresetsPromise, colSpan }: DataRowsProps) => { + const pluginPresets = use(pluginPresetsPromise) + + if (!pluginPresets || pluginPresets.length === 0) { + return No plugin presets found. + } + + return ( + <> + {pluginPresets.map((preset: PluginPreset, idx: number) => ( + + + + + + {preset.status?.readyPlugins || 0}/{preset.status?.totalPlugins || 0} + + {preset.metadata?.name} + + {preset.spec?.plugin?.pluginDefinitionRef.name || preset.spec?.plugin?.pluginDefinition} + + {!isReady(preset) ? getReadyCondition(preset)?.message : ""} + + + + + ))} + + ) +} diff --git a/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsDataGrid/index.test.tsx b/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsDataGrid/index.test.tsx new file mode 100644 index 0000000000..036a05ae34 --- /dev/null +++ b/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsDataGrid/index.test.tsx @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { act } from "react" +import { render, screen } from "@testing-library/react" +import { PluginPresetsDataGrid } from "./index" +import { PluginPreset } from "../../types/k8sTypes" + +const mockPluginPresets: PluginPreset[] = [ + { + metadata: { + name: "preset-1", + }, + spec: { + clusterSelector: {}, + deletionPolicy: "Delete", + plugin: { + pluginDefinitionRef: { + name: "plugin-def-1", + }, + deletionPolicy: "Delete", + pluginDefinition: "plugin-def-1", + }, + }, + status: { + readyPlugins: 2, + totalPlugins: 3, + statusConditions: { + conditions: [ + { + lastTransitionTime: "2024-10-01T12:00:00Z", + type: "Ready", + status: "True", + message: "", + }, + ], + }, + }, + }, + { + metadata: { + name: "preset-2", + }, + spec: { + clusterSelector: {}, + deletionPolicy: "Delete", + plugin: { + pluginDefinitionRef: { + name: "plugin-def-2", + }, + deletionPolicy: "Delete", + pluginDefinition: "plugin-def-2", + }, + }, + status: { + readyPlugins: 0, + totalPlugins: 2, + statusConditions: { + conditions: [ + { + lastTransitionTime: "2024-10-01T12:00:00Z", + type: "Ready", + status: "False", + message: "Some error occurred", + }, + ], + }, + }, + }, +] + +describe("PluginPresetsDataGrid", () => { + it("should render loading and column headers while the data is being fetched", async () => { + const mockPluginPresetsPromise = Promise.resolve(mockPluginPresets) + render() + + // Loading should be gone + expect(screen.queryByText("Loading...")).toBeInTheDocument() + + // Check for column headers + expect(screen.getByText("Instances")).toBeInTheDocument() + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getByText("Plugin Definition")).toBeInTheDocument() + expect(screen.getByText("Message")).toBeInTheDocument() + expect(screen.getByText("Actions")).toBeInTheDocument() + }) + + it("should render the data", async () => { + const mockPluginPresetsPromise = Promise.resolve(mockPluginPresets) + await act(async () => { + render() + }) + + // Loading should be gone + expect(screen.queryByText("Loading...")).not.toBeInTheDocument() + + // Check for column headers + expect(screen.getByText("Instances")).toBeInTheDocument() + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getByText("Plugin Definition")).toBeInTheDocument() + expect(screen.getByText("Message")).toBeInTheDocument() + expect(screen.getByText("Actions")).toBeInTheDocument() + + // Check for data + expect(screen.getByText("2/3")).toBeInTheDocument() + expect(screen.getByText("preset-1")).toBeInTheDocument() + expect(screen.getByText("preset-2")).toBeInTheDocument() + expect(screen.getByText("0/2")).toBeInTheDocument() + }) + + it("should render the error message while fetching data", async () => { + const mockPluginPresetsPromise = Promise.reject(new Error("Something went wrong")) + await act(async () => { + render() + }) + + // Loading should be gone + expect(screen.queryByText("Loading...")).not.toBeInTheDocument() + + // Check for column headers + expect(screen.getByText("Instances")).toBeInTheDocument() + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getByText("Plugin Definition")).toBeInTheDocument() + expect(screen.getByText("Message")).toBeInTheDocument() + expect(screen.getByText("Actions")).toBeInTheDocument() + + // Check for error + expect(screen.getByText("Error: Something went wrong")).toBeInTheDocument() + }) +}) diff --git a/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsDataGrid/index.tsx b/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsDataGrid/index.tsx new file mode 100644 index 0000000000..ab770eba66 --- /dev/null +++ b/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsDataGrid/index.tsx @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Suspense } from "react" +import { DataGrid, DataGridRow, DataGridHeadCell, Icon } from "@cloudoperators/juno-ui-components" +import { DataRows } from "./DataRows" +import { LoadingDataRow } from "../../common/LoadingDataRow" +import { ErrorBoundary } from "../../common/ErrorBoundary" +import { getErrorDataRowComponent } from "../../common/getErrorDataRow" +import { PluginPreset } from "../../types/k8sTypes" + +const COLUMN_SPAN = 6 + +interface PluginPresetsDataGridProps { + pluginPresetsPromise: Promise +} + +export const PluginPresetsDataGrid = ({ pluginPresetsPromise }: PluginPresetsDataGridProps) => { + return ( +
+ + + + + + Instances + Name + Plugin Definition + Message + Actions + + + + }> + + + + +
+ ) +} diff --git a/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsFilter.tsx b/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsFilter.tsx new file mode 100644 index 0000000000..e6efe9bdb0 --- /dev/null +++ b/apps/greenhouse/src/components/admin/PluginPresets/PluginPresetsFilter.tsx @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from "react" +import { useLoaderData, useNavigate } from "@tanstack/react-router" +import { Stack, SearchInput, InputGroup, Button } from "@cloudoperators/juno-ui-components" +import { FilterSelect } from "../common/FilterSelect" + +export type FilterSettings = { + searchTerm?: string +} + +const filters = [ + { + id: "pluginPresetDefinition", + label: "Plugin Preset Definition", + values: ["active", "inactive", "deprecated"], + }, +] + +export const PluginPresetsFilter = () => { + const navigate = useNavigate() + const { filterSettings } = useLoaderData({ from: "/admin/plugin-presets" }) + + const handleSearchChange = useCallback( + (value?: string) => { + navigate({ + to: "/admin/plugin-presets", + search: (prev) => ({ + ...prev, + searchTerm: value, + }), + }) + }, + [navigate] + ) + + return ( + + + + {}} /> + +