Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 (
<Stats
title="PluginPreset Health Distribution"
// queryKey={[FETCH_PLUGIN_PRESETS_STATS_CACHE_KEY, user.organization]}
// queryFn={() => fetchExposedServicesStats({ apiClient, namespace: user.organization })}
/>
)
}
Original file line number Diff line number Diff line change
@@ -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 <EmptyDataGridRow colSpan={colSpan}>No exposed services found.</EmptyDataGridRow>
}

// 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 (
<DataGridRow key={`${pluginName}-${serviceUrl}`} className="cursor-pointer">
{/* Name */}
<DataGridCell>
<a href={serviceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
<Stack gap="2">
<Icon size="18" color="jn-global-text" icon="openInNew" />
{serviceData.name || ""}
</Stack>
</a>
</DataGridCell>
{/* Cluster */}
<DataGridCell>{clusterName}</DataGridCell>
{/* Plugin */}
<DataGridCell>{pluginName}</DataGridCell>
{/* Owned-by */}
<DataGridCell>{ownedBy}</DataGridCell>
</DataGridRow>
)
})}
</>
)
}
Original file line number Diff line number Diff line change
@@ -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<MockExposedServicesResponse | unknown>) => {
const rootRoute = createRootRoute({
component: () => <Outlet />,
})
const testRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/admin/exposed-services/",
component: () => (
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
}
>
<ExposedServicesDataGrid />
</QueryClientProvider>
),
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(<RouterProvider router={router} />))
}

describe("ExposedServicesDataGrid", () => {
it("should render plugin presets", async () => {
await renderComponent(new Promise<MockExposedServicesResponse>((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()
})
})
Original file line number Diff line number Diff line change
@@ -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 (
<DataGrid columns={COLUMN_SPAN}>
<DataGridRow>
<DataGridHeadCell>Name</DataGridHeadCell>
{/* <DataGridHeadCell>Service URL</DataGridHeadCell> */}
<DataGridHeadCell>Cluster</DataGridHeadCell>
<DataGridHeadCell>Plugin</DataGridHeadCell>
<DataGridHeadCell>Owner</DataGridHeadCell>
</DataGridRow>

<ErrorBoundary
displayErrorMessage
fallbackRender={getErrorDataRowComponent({ colspan: COLUMN_SPAN })}
resetKeys={[filterSettings]} // Reset on filter changes
>
<Suspense fallback={<LoadingDataRow colSpan={COLUMN_SPAN} />}>
<DataRows colSpan={COLUMN_SPAN} />
</Suspense>
</ErrorBoundary>
</DataGrid>
)
}
Original file line number Diff line number Diff line change
@@ -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 (
<Stack direction="vertical" gap="4" className="bg-theme-background-lvl-1 py-2 px-4 mb-px">
<Stack alignment="start" gap="4">
<InputGroup>
<FilterSelect
filters={filters}
isLoading={isLoading}
error={error}
onChange={(selectedFilter: SelectedFilter) => {
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],
})
}
}}
/>
</InputGroup>
<Button
label="Clear all"
className="ml-4"
onClick={() =>
updateFilters({
...filterSettings,
selectedFilters: [],
})
}
variant="subdued"
/>
<SearchInput
placeholder={`search term for exposed service name`}
className="w-96 ml-auto"
data-testid="searchbar"
value={filterSettings.searchTerm}
onSearch={(searchTerm) => {
updateFilters({
...filterSettings,
searchTerm,
})
}}
onClear={() =>
updateFilters({
...filterSettings,
searchTerm: "",
})
}
/>
</Stack>
{filterSettings.selectedFilters && filterSettings.selectedFilters.length > 0 && (
<SelectedFilters selectedFilters={filterSettings.selectedFilters} onDelete={handleFilterDelete} />
)}
</Stack>
)
}
Loading
Loading