diff --git a/apps/greenhouse/src/components/admin/ExposedServices/ExposedServices.tsx b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServices.tsx new file mode 100644 index 0000000000..3e0facf249 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServices.tsx @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +// import { +// fetchExposedServicesStats, +// FETCH_PLUGIN_PRESETS_STATS_CACHE_KEY, +// } from "../api/exposed-services/fetchExposedServicesStats" +import { useRouteContext } from "@tanstack/react-router" +import { Stats } from "../common/Stats/Stats" + +export const ExposedServicesStats = () => { + const { apiClient, user } = useRouteContext({ from: "/admin/exposed-services" }) + + return ( + fetchExposedServicesStats({ apiClient, namespace: user.organization })} + /> + ) +} diff --git a/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/DataRows/index.tsx b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/DataRows/index.tsx new file mode 100644 index 0000000000..19d9f5b6ae --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/DataRows/index.tsx @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { DataGridRow, DataGridCell, Button, Icon, Stack } from "@cloudoperators/juno-ui-components" +import { useQuery, useSuspenseQuery } from "@tanstack/react-query" +import { useRouteContext, useSearch, useNavigate } from "@tanstack/react-router" +import { + fetchExposedServices, + FETCH_EXPOSED_SERVICES_CACHE_KEY, +} from "../../../api/plugin-exposed-services/fetchExposedServices" +import { extractFilterSettingsFromSearchParams } from "../../../utils" +import { EmptyDataGridRow } from "../../../common/EmptyDataGridRow" +import { PluginPreset } from "../../../types/k8sTypes" +import { getReadyCondition, isReady } from "../../../utils" +import { SUPPORT_GROUP_LABEL } from "../../../constants" + +interface DataRowsProps { + colSpan: number +} + +export const DataRows = ({ colSpan }: DataRowsProps) => { + const { apiClient, user } = useRouteContext({ from: "/admin/exposed-services" }) + const search = useSearch({ from: "/admin/exposed-services" }) + const navigate = useNavigate() + const filterSettings = extractFilterSettingsFromSearchParams(search) + + const { + data: ExposedServices, + isLoading, + error, + } = useQuery({ + queryKey: [FETCH_EXPOSED_SERVICES_CACHE_KEY, user.organization, filterSettings], + queryFn: () => + fetchExposedServices({ + apiClient, + namespace: user.organization, + filterSettings, + }), + }) + + if (!ExposedServices || ExposedServices.length === 0) { + return No exposed services found. + } + + // const handleRowClick = (presetName: string) => { + // navigate({ + // to: "/admin/plugin-presets/$pluginPresetName", + // params: { pluginPresetName: presetName }, + // }) + // } + + return ( + <> + {ExposedServices.map((service) => { + const clusterName = service.spec?.clusterName || "" + const pluginName = service.metadata?.name || "" + const ownedBy = service.metadata?.labels?.["greenhouse.sap/owned-by"] || "" // Owned-by field with fallback + const exposedServices = service.status?.exposedServices + + const serviceUrl = exposedServices ? Object.keys(exposedServices)[0] : "" + const serviceData = exposedServices ? Object.values(exposedServices)[0] : { name: "", namespace: "" } + + return ( + + {/* Name */} + + + + + {serviceData.name || ""} + + + + {/* Cluster */} + {clusterName} + {/* Plugin */} + {pluginName} + {/* Owned-by */} + {ownedBy} + + ) + })} + + ) +} diff --git a/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.test.tsx b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.test.tsx new file mode 100644 index 0000000000..0c2fe803f3 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router" +import { render, screen } from "@testing-library/react" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ExposedServicesDataGrid } from "./index" +import { mockExposedServices, MockExposedServicesResponse } from "../../__mocks__/ExposedServices" + +const renderComponent = async (mockPromise: Promise) => { + const rootRoute = createRootRoute({ + component: () => , + }) + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/admin/exposed-services/", + component: () => ( + + + + ), + loader: () => ({ + filterSettings: { + selectedFilters: [], + searchTerm: "", + }, + }), + }) + const routeTree = rootRoute.addChildren([testRoute]) + const router = createRouter({ + routeTree: routeTree, + defaultPendingMinMs: 0, + context: { + apiClient: { + get() { + return mockPromise + }, + }, + user: { + organization: "test-org", + supportGroups: [], + }, + }, + history: createMemoryHistory({ + initialEntries: ["/admin/exposed-services/"], + }), + }) + return await act(async () => render()) +} + +describe("ExposedServicesDataGrid", () => { + it("should render plugin presets", async () => { + await renderComponent(new Promise((resolve) => resolve(mockExposedServices))) + + // 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 - verify all 5 presets are rendered + expect(screen.getByText("preset-1")).toBeInTheDocument() + expect(screen.getByText("preset-2")).toBeInTheDocument() + expect(screen.getByText("preset-3")).toBeInTheDocument() + expect(screen.getByText("preset-4")).toBeInTheDocument() + expect(screen.getByText("preset-5")).toBeInTheDocument() + + // Check some instance counts + expect(screen.getByText("2/3")).toBeInTheDocument() + expect(screen.getByText("0/2")).toBeInTheDocument() + expect(screen.getByText("1/1")).toBeInTheDocument() + expect(screen.getByText("3/5")).toBeInTheDocument() + expect(screen.getByText("0/1")).toBeInTheDocument() + }) + + it("should render the error message while fetching data", async () => { + await renderComponent(new Promise((_, reject) => reject(new Error("Something went wrong")))) + // Wait for error to appear + expect(await screen.findByText("Error: Something went wrong")).toBeInTheDocument() + }) +}) diff --git a/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.tsx b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.tsx new file mode 100644 index 0000000000..737309d35f --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.tsx @@ -0,0 +1,39 @@ +/* + * 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 { useLoaderData } from "@tanstack/react-router" +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" + +const COLUMN_SPAN = 4 + +export const ExposedServicesDataGrid = () => { + const { filterSettings } = useLoaderData({ from: "/admin/exposed-services" }) + return ( + + + Name + {/* Service URL */} + Cluster + Plugin + Owner + + + + }> + + + + + ) +} diff --git a/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesFilters.tsx b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesFilters.tsx new file mode 100644 index 0000000000..8d58973eb0 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesFilters.tsx @@ -0,0 +1,125 @@ +/* + * 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, useRouteContext } from "@tanstack/react-router" +import { FilterSettings, SelectedFilter } from "../common/types" +import { getFiltersForUrl } from "../utils" +import { SELECTED_FILTER_PREFIX } from "../constants" +import { Stack, InputGroup, Button, SearchInput } from "@cloudoperators/juno-ui-components/index" +import { SelectedFilters } from "../common/SelectedFilters" +import { useQuery } from "@tanstack/react-query" +import { FilterSelect } from "../common/FilterSelect" +import { + FETCH_EXPOSED_SERVICES_FILTERS_CACHE_KEY, + fetchExposedServicesFilters, +} from "../api/plugin-exposed-services/fetchExposedServicesFilters" + +export const ExposedServicesFilters = () => { + const navigate = useNavigate() + const { apiClient, user } = useRouteContext({ from: "/admin/exposed-services" }) + const { filterSettings } = useLoaderData({ from: "/admin/exposed-services" }) + const { + data: filters, + isLoading, + error, + } = useQuery({ + queryKey: [FETCH_EXPOSED_SERVICES_FILTERS_CACHE_KEY, user.organization], + queryFn: () => + fetchExposedServicesFilters({ + apiClient, + namespace: user.organization, + }), + }) + + const updateFilters = useCallback( + (updatedFilterSettings: FilterSettings) => { + navigate({ + to: "/admin/exposed-services", + search: (prev) => { + const newFilterParams = getFiltersForUrl(updatedFilterSettings) + const cleanedPrev = Object.fromEntries( + Object.entries(prev).filter(([key]) => !key.startsWith(SELECTED_FILTER_PREFIX)) + ) + return { + ...cleanedPrev, + ...newFilterParams, + } + }, + }) + }, + [navigate] + ) + + const handleFilterDelete = useCallback( + (filterToRemove: SelectedFilter) => { + updateFilters({ + ...filterSettings, + selectedFilters: filterSettings.selectedFilters?.filter( + (filter) => !(filter.id === filterToRemove.id && filter.value === filterToRemove.value) + ), + }) + }, + [filterSettings, updateFilters] + ) + + return ( + + + + { + const filterExists = filterSettings.selectedFilters?.some( + (filter) => filter.id === selectedFilter.id && filter.value === selectedFilter.value + ) + //only add the filter if it does not already exist + if (!filterExists) { + updateFilters({ + ...filterSettings, + selectedFilters: [...(filterSettings.selectedFilters || []), selectedFilter], + }) + } + }} + /> + +