Skip to content

Commit 3e8cf7f

Browse files
exposed services
1 parent 6bea057 commit 3e8cf7f

16 files changed

Lines changed: 709 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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 from "react"
7+
// import {
8+
// fetchExposedServicesStats,
9+
// FETCH_PLUGIN_PRESETS_STATS_CACHE_KEY,
10+
// } from "../api/exposed-services/fetchExposedServicesStats"
11+
import { useRouteContext } from "@tanstack/react-router"
12+
import { Stats } from "../common/Stats/Stats"
13+
14+
export const ExposedServicesStats = () => {
15+
const { apiClient, user } = useRouteContext({ from: "/admin/exposed-services" })
16+
17+
return (
18+
<Stats
19+
title="PluginPreset Health Distribution"
20+
// queryKey={[FETCH_PLUGIN_PRESETS_STATS_CACHE_KEY, user.organization]}
21+
// queryFn={() => fetchExposedServicesStats({ apiClient, namespace: user.organization })}
22+
/>
23+
)
24+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 from "react"
7+
import { DataGridRow, DataGridCell, Button, Icon, Stack } from "@cloudoperators/juno-ui-components"
8+
import { useQuery, useSuspenseQuery } from "@tanstack/react-query"
9+
import { useRouteContext, useSearch, useNavigate } from "@tanstack/react-router"
10+
import {
11+
fetchExposedServices,
12+
FETCH_EXPOSED_SERVICES_CACHE_KEY,
13+
} from "../../../api/plugin-exposed-services/fetchExposedServices"
14+
import { extractFilterSettingsFromSearchParams } from "../../../utils"
15+
import { EmptyDataGridRow } from "../../../common/EmptyDataGridRow"
16+
import { PluginPreset } from "../../../types/k8sTypes"
17+
import { getReadyCondition, isReady } from "../../../utils"
18+
import { SUPPORT_GROUP_LABEL } from "../../../constants"
19+
20+
interface DataRowsProps {
21+
colSpan: number
22+
}
23+
24+
export const DataRows = ({ colSpan }: DataRowsProps) => {
25+
const { apiClient, user } = useRouteContext({ from: "/admin/exposed-services" })
26+
const search = useSearch({ from: "/admin/exposed-services" })
27+
const navigate = useNavigate()
28+
const filterSettings = extractFilterSettingsFromSearchParams(search)
29+
30+
const {
31+
data: ExposedServices,
32+
isLoading,
33+
error,
34+
} = useQuery({
35+
queryKey: [FETCH_EXPOSED_SERVICES_CACHE_KEY, user.organization, filterSettings],
36+
queryFn: () =>
37+
fetchExposedServices({
38+
apiClient,
39+
namespace: user.organization,
40+
filterSettings,
41+
}),
42+
})
43+
44+
if (!ExposedServices || ExposedServices.length === 0) {
45+
return <EmptyDataGridRow colSpan={colSpan}>No exposed services found.</EmptyDataGridRow>
46+
}
47+
48+
// const handleRowClick = (presetName: string) => {
49+
// navigate({
50+
// to: "/admin/plugin-presets/$pluginPresetName",
51+
// params: { pluginPresetName: presetName },
52+
// })
53+
// }
54+
55+
return (
56+
<>
57+
{ExposedServices.map((service) => {
58+
const clusterName = service.spec?.clusterName || ""
59+
const pluginName = service.metadata?.name || ""
60+
const ownedBy = service.metadata?.labels?.["greenhouse.sap/owned-by"] || "" // Owned-by field with fallback
61+
const exposedServices = service.status?.exposedServices
62+
63+
const serviceUrl = exposedServices ? Object.keys(exposedServices)[0] : ""
64+
const serviceData = exposedServices ? Object.values(exposedServices)[0] : { name: "", namespace: "" }
65+
66+
return (
67+
<DataGridRow key={`${pluginName}-${serviceUrl}`} className="cursor-pointer">
68+
{/* Name */}
69+
<DataGridCell>
70+
<a href={serviceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
71+
<Stack gap="2">
72+
<Icon size="18" color="jn-global-text" icon="openInNew" />
73+
{serviceData.name || ""}
74+
</Stack>
75+
</a>
76+
</DataGridCell>
77+
{/* Cluster */}
78+
<DataGridCell>{clusterName}</DataGridCell>
79+
{/* Plugin */}
80+
<DataGridCell>{pluginName}</DataGridCell>
81+
{/* Owned-by */}
82+
<DataGridCell>{ownedBy}</DataGridCell>
83+
</DataGridRow>
84+
)
85+
})}
86+
</>
87+
)
88+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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 {
8+
createMemoryHistory,
9+
createRootRoute,
10+
createRoute,
11+
createRouter,
12+
Outlet,
13+
RouterProvider,
14+
} from "@tanstack/react-router"
15+
import { render, screen } from "@testing-library/react"
16+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
17+
import { ExposedServicesDataGrid } from "./index"
18+
import { mockExposedServices, MockExposedServicesResponse } from "../../__mocks__/ExposedServices"
19+
20+
const renderComponent = async (mockPromise: Promise<MockExposedServicesResponse | unknown>) => {
21+
const rootRoute = createRootRoute({
22+
component: () => <Outlet />,
23+
})
24+
const testRoute = createRoute({
25+
getParentRoute: () => rootRoute,
26+
path: "/admin/exposed-services/",
27+
component: () => (
28+
<QueryClientProvider
29+
client={
30+
new QueryClient({
31+
defaultOptions: {
32+
queries: {
33+
retry: false,
34+
},
35+
},
36+
})
37+
}
38+
>
39+
<ExposedServicesDataGrid />
40+
</QueryClientProvider>
41+
),
42+
loader: () => ({
43+
filterSettings: {
44+
selectedFilters: [],
45+
searchTerm: "",
46+
},
47+
}),
48+
})
49+
const routeTree = rootRoute.addChildren([testRoute])
50+
const router = createRouter({
51+
routeTree: routeTree,
52+
defaultPendingMinMs: 0,
53+
context: {
54+
apiClient: {
55+
get() {
56+
return mockPromise
57+
},
58+
},
59+
user: {
60+
organization: "test-org",
61+
supportGroups: [],
62+
},
63+
},
64+
history: createMemoryHistory({
65+
initialEntries: ["/admin/exposed-services/"],
66+
}),
67+
})
68+
return await act(async () => render(<RouterProvider router={router} />))
69+
}
70+
71+
describe("ExposedServicesDataGrid", () => {
72+
it("should render plugin presets", async () => {
73+
await renderComponent(new Promise<MockExposedServicesResponse>((resolve) => resolve(mockExposedServices)))
74+
75+
// Check for column headers
76+
expect(screen.getByText("Instances")).toBeInTheDocument()
77+
expect(screen.getByText("Name")).toBeInTheDocument()
78+
expect(screen.getByText("Plugin Definition")).toBeInTheDocument()
79+
expect(screen.getByText("Message")).toBeInTheDocument()
80+
expect(screen.getByText("Actions")).toBeInTheDocument()
81+
82+
// Check for data - verify all 5 presets are rendered
83+
expect(screen.getByText("preset-1")).toBeInTheDocument()
84+
expect(screen.getByText("preset-2")).toBeInTheDocument()
85+
expect(screen.getByText("preset-3")).toBeInTheDocument()
86+
expect(screen.getByText("preset-4")).toBeInTheDocument()
87+
expect(screen.getByText("preset-5")).toBeInTheDocument()
88+
89+
// Check some instance counts
90+
expect(screen.getByText("2/3")).toBeInTheDocument()
91+
expect(screen.getByText("0/2")).toBeInTheDocument()
92+
expect(screen.getByText("1/1")).toBeInTheDocument()
93+
expect(screen.getByText("3/5")).toBeInTheDocument()
94+
expect(screen.getByText("0/1")).toBeInTheDocument()
95+
})
96+
97+
it("should render the error message while fetching data", async () => {
98+
await renderComponent(new Promise((_, reject) => reject(new Error("Something went wrong"))))
99+
// Wait for error to appear
100+
expect(await screen.findByText("Error: Something went wrong")).toBeInTheDocument()
101+
})
102+
})
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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 { useLoaderData } from "@tanstack/react-router"
8+
import { DataGrid, DataGridRow, DataGridHeadCell, Icon } from "@cloudoperators/juno-ui-components"
9+
import { DataRows } from "./DataRows"
10+
import { LoadingDataRow } from "../../common/LoadingDataRow"
11+
import { ErrorBoundary } from "../../common/ErrorBoundary"
12+
import { getErrorDataRowComponent } from "../../common/getErrorDataRow"
13+
14+
const COLUMN_SPAN = 4
15+
16+
export const ExposedServicesDataGrid = () => {
17+
const { filterSettings } = useLoaderData({ from: "/admin/exposed-services" })
18+
return (
19+
<DataGrid columns={COLUMN_SPAN}>
20+
<DataGridRow>
21+
<DataGridHeadCell>Name</DataGridHeadCell>
22+
{/* <DataGridHeadCell>Service URL</DataGridHeadCell> */}
23+
<DataGridHeadCell>Cluster</DataGridHeadCell>
24+
<DataGridHeadCell>Plugin</DataGridHeadCell>
25+
<DataGridHeadCell>Owner</DataGridHeadCell>
26+
</DataGridRow>
27+
28+
<ErrorBoundary
29+
displayErrorMessage
30+
fallbackRender={getErrorDataRowComponent({ colspan: COLUMN_SPAN })}
31+
resetKeys={[filterSettings]} // Reset on filter changes
32+
>
33+
<Suspense fallback={<LoadingDataRow colSpan={COLUMN_SPAN} />}>
34+
<DataRows colSpan={COLUMN_SPAN} />
35+
</Suspense>
36+
</ErrorBoundary>
37+
</DataGrid>
38+
)
39+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React, { useCallback } from "react"
7+
import { useLoaderData, useNavigate, useRouteContext } from "@tanstack/react-router"
8+
import { FilterSettings, SelectedFilter } from "../common/types"
9+
import { getFiltersForUrl } from "../utils"
10+
import { SELECTED_FILTER_PREFIX } from "../constants"
11+
import { Stack, InputGroup, Button, SearchInput } from "@cloudoperators/juno-ui-components/index"
12+
import { SelectedFilters } from "../common/SelectedFilters"
13+
import { useQuery } from "@tanstack/react-query"
14+
import { FilterSelect } from "../common/FilterSelect"
15+
import {
16+
FETCH_EXPOSED_SERVICES_FILTERS_CACHE_KEY,
17+
fetchExposedServicesFilters,
18+
} from "../api/plugin-exposed-services/fetchExposedServicesFilters"
19+
20+
export const ExposedServicesFilters = () => {
21+
const navigate = useNavigate()
22+
const { apiClient, user } = useRouteContext({ from: "/admin/exposed-services" })
23+
const { filterSettings } = useLoaderData({ from: "/admin/exposed-services" })
24+
const {
25+
data: filters,
26+
isLoading,
27+
error,
28+
} = useQuery({
29+
queryKey: [FETCH_EXPOSED_SERVICES_FILTERS_CACHE_KEY, user.organization],
30+
queryFn: () =>
31+
fetchExposedServicesFilters({
32+
apiClient,
33+
namespace: user.organization,
34+
}),
35+
})
36+
37+
const updateFilters = useCallback(
38+
(updatedFilterSettings: FilterSettings) => {
39+
navigate({
40+
to: "/admin/exposed-services",
41+
search: (prev) => {
42+
const newFilterParams = getFiltersForUrl(updatedFilterSettings)
43+
const cleanedPrev = Object.fromEntries(
44+
Object.entries(prev).filter(([key]) => !key.startsWith(SELECTED_FILTER_PREFIX))
45+
)
46+
return {
47+
...cleanedPrev,
48+
...newFilterParams,
49+
}
50+
},
51+
})
52+
},
53+
[navigate]
54+
)
55+
56+
const handleFilterDelete = useCallback(
57+
(filterToRemove: SelectedFilter) => {
58+
updateFilters({
59+
...filterSettings,
60+
selectedFilters: filterSettings.selectedFilters?.filter(
61+
(filter) => !(filter.id === filterToRemove.id && filter.value === filterToRemove.value)
62+
),
63+
})
64+
},
65+
[filterSettings, updateFilters]
66+
)
67+
68+
return (
69+
<Stack direction="vertical" gap="4" className="bg-theme-background-lvl-1 py-2 px-4 mb-px">
70+
<Stack alignment="start" gap="4">
71+
<InputGroup>
72+
<FilterSelect
73+
filters={filters}
74+
isLoading={isLoading}
75+
error={error}
76+
onChange={(selectedFilter: SelectedFilter) => {
77+
const filterExists = filterSettings.selectedFilters?.some(
78+
(filter) => filter.id === selectedFilter.id && filter.value === selectedFilter.value
79+
)
80+
//only add the filter if it does not already exist
81+
if (!filterExists) {
82+
updateFilters({
83+
...filterSettings,
84+
selectedFilters: [...(filterSettings.selectedFilters || []), selectedFilter],
85+
})
86+
}
87+
}}
88+
/>
89+
</InputGroup>
90+
<Button
91+
label="Clear all"
92+
className="ml-4"
93+
onClick={() =>
94+
updateFilters({
95+
...filterSettings,
96+
selectedFilters: [],
97+
})
98+
}
99+
variant="subdued"
100+
/>
101+
<SearchInput
102+
placeholder={`search term for exposed service name`}
103+
className="w-96 ml-auto"
104+
data-testid="searchbar"
105+
value={filterSettings.searchTerm}
106+
onSearch={(searchTerm) => {
107+
updateFilters({
108+
...filterSettings,
109+
searchTerm,
110+
})
111+
}}
112+
onClear={() =>
113+
updateFilters({
114+
...filterSettings,
115+
searchTerm: "",
116+
})
117+
}
118+
/>
119+
</Stack>
120+
{filterSettings.selectedFilters && filterSettings.selectedFilters.length > 0 && (
121+
<SelectedFilters selectedFilters={filterSettings.selectedFilters} onDelete={handleFilterDelete} />
122+
)}
123+
</Stack>
124+
)
125+
}

0 commit comments

Comments
 (0)