diff --git a/.changeset/wide-cups-beam.md b/.changeset/wide-cups-beam.md new file mode 100644 index 0000000000..8b336d4f63 --- /dev/null +++ b/.changeset/wide-cups-beam.md @@ -0,0 +1,6 @@ +--- +"@cloudoperators/juno-app-greenhouse": minor +--- + +- Add a list of exposed services for all plugins +- Updated the plugin instance detail page to display a list of exposed services under `Details`, instead of a separate section. 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..f6bf5bd82f --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/DataRows/index.tsx @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { useSuspenseQuery } from "@tanstack/react-query" +import { useRouteContext, useSearch } from "@tanstack/react-router" +import { DataGridRow, DataGridCell } from "@cloudoperators/juno-ui-components" + +import { NO_VALUE_DEFAULT } from "../../../constants" +import { + fetchExposedServices, + FETCH_EXPOSED_SERVICES_CACHE_KEY, +} from "../../../api/exposed-services/fetchExposedServices" +import { EmptyDataGridRow } from "../../../common/EmptyDataGridRow" +import { extractFilterSettingsFromSearchParams } from "../../../utils" +import { ExternalLink } from "../../../common/ExternalLink" + +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 filterSettings = extractFilterSettingsFromSearchParams(search) + + const { data: flattenedExposedServices } = useSuspenseQuery({ + queryKey: [FETCH_EXPOSED_SERVICES_CACHE_KEY, user.organization, filterSettings], + queryFn: () => + fetchExposedServices({ + apiClient, + namespace: user.organization, + filterSettings, + }), + }) + + if (!flattenedExposedServices || flattenedExposedServices.length === 0) { + return No exposed services found. + } + + return ( + <> + {flattenedExposedServices.map((service, index) => ( + + {} + {service.clusterName || NO_VALUE_DEFAULT} + {service.pluginName || NO_VALUE_DEFAULT} + {service.supportGroup || NO_VALUE_DEFAULT} + + ))} + > + ) +} 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..3d7a0cd79c --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.test.tsx @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: 2026 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, MockPluginsWithExposedServicesResponse } 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 exposed services", async () => { + await renderComponent( + new Promise((resolve) => resolve(mockExposedServices)) + ) + + // Check for column headers + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getByText("Cluster")).toBeInTheDocument() + expect(screen.getByText("Plugin")).toBeInTheDocument() + expect(screen.getByText("Support Group")).toBeInTheDocument() + + // Verify service names are in the document + expect(screen.getByText("service1")).toBeInTheDocument() + expect(screen.getByText("service2")).toBeInTheDocument() + expect(screen.getByText("service3")).toBeInTheDocument() + + // Verify service links with correct URLs + const service1Link = screen.getByRole("link", { name: /service1/i }) + const service2Link = screen.getByRole("link", { name: /service2/i }) + + expect(service1Link).toHaveAttribute("href", "https://demo-service1.sci.greenhouse-qa.eu-nl-1.cloud.sap/") + expect(service2Link).toHaveAttribute("href", "https://demo-service2.sci.greenhouse-qa.eu-nl-1.cloud.sap/") + + // Ensure no link rendered without URL + const service3Link = screen.queryByRole("link", { name: /service3/i }) + expect(service3Link).toBeNull() + }) +}) 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..5397cbf152 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesDataGrid/index.tsx @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: 2026 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 } from "@cloudoperators/juno-ui-components" + +import { DataRows } from "./DataRows" +import { ErrorBoundary } from "../../common/ErrorBoundary" +import { LoadingDataRow } from "../../common/LoadingDataRow" +import { getErrorDataRowComponent } from "../../common/getErrorDataRow" + +const COLUMN_SPAN = 4 + +export const ExposedServicesDataGrid = () => { + const { filterSettings } = useLoaderData({ from: "/admin/exposed-services" }) + return ( + + + Name + Cluster + Plugin + Support Group + + + + }> + + + + + ) +} 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..93e003a21e --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/ExposedServicesFilters.tsx @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: 2026 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 { Stack, InputGroup, Button, SearchInput } from "@cloudoperators/juno-ui-components/index" + +import { getFiltersForUrl } from "../utils" +import { useQuery } from "@tanstack/react-query" +import { SELECTED_FILTER_PREFIX } from "../constants" +import { FilterSelect } from "../common/FilterSelect" +import { SelectedFilters } from "../common/SelectedFilters" +import { + FETCH_EXPOSED_SERVICES_FILTERS_CACHE_KEY, + fetchExposedServicesFilters, +} from "../api/exposed-services/fetchExposedServicesFilters" +import { FilterSettings, SelectedFilter } from "../common/types" + +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 filter if it does not already exist + if (!filterExists) { + updateFilters({ + ...filterSettings, + selectedFilters: [...(filterSettings.selectedFilters || []), selectedFilter], + }) + } + }} + /> + + + updateFilters({ + ...filterSettings, + selectedFilters: [], + }) + } + variant="subdued" + /> + { + updateFilters({ + ...filterSettings, + searchTerm, + }) + }} + onClear={() => + updateFilters({ + ...filterSettings, + searchTerm: "", + }) + } + /> + + {filterSettings.selectedFilters && filterSettings.selectedFilters.length > 0 && ( + + )} + + ) +} diff --git a/apps/greenhouse/src/components/admin/ExposedServices/index.tsx b/apps/greenhouse/src/components/admin/ExposedServices/index.tsx new file mode 100644 index 0000000000..d9a7a9e979 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ExposedServices/index.tsx @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from "react" +import { useRouteContext, useSearch } from "@tanstack/react-router" +import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query" +import { Container, ContentHeading, Button, Stack, DataGridToolbar } from "@cloudoperators/juno-ui-components" + +import { extractFilterSettingsFromSearchParams } from "../utils" +import { ExposedServicesFilters } from "./ExposedServicesFilters" +import { ExposedServicesDataGrid } from "./ExposedServicesDataGrid" +import { FETCH_EXPOSED_SERVICES_CACHE_KEY, fetchExposedServices } from "../api/exposed-services/fetchExposedServices" + +export const ExposedServices = () => { + const [lastUpdatedAt, setLastUpdatedAt] = useState(Date.now()) + const isFetching = useIsFetching({ queryKey: [FETCH_EXPOSED_SERVICES_CACHE_KEY] }) + const queryClient = useQueryClient() + const { apiClient, user } = useRouteContext({ from: "/admin/exposed-services" }) + const search = useSearch({ from: "/admin/exposed-services" }) + const filterSettings = extractFilterSettingsFromSearchParams(search) + + const handleRefresh = () => { + queryClient.invalidateQueries({ queryKey: [FETCH_EXPOSED_SERVICES_CACHE_KEY] }) + setLastUpdatedAt(Date.now()) + } + + const { data: exposedServices } = useQuery({ + queryKey: [FETCH_EXPOSED_SERVICES_CACHE_KEY, user.organization, filterSettings], + queryFn: () => + fetchExposedServices({ + apiClient, + namespace: user.organization, + filterSettings, + }), + }) + + const total = exposedServices?.length ?? 0 + + return ( + <> + + Exposed Services Overview + Monitor exposed services for all plugins + + + + + + + {`${total} exposed services`} + + + {lastUpdatedAt && `Last update: ${new Date(lastUpdatedAt).toLocaleString()}`} + 0 ? "Loading..." : "Refresh"} + className="ml-4 min-w-[90px]" + onClick={handleRefresh} + variant="subdued" + disabled={isFetching > 0} + /> + + + + + + > + ) +} diff --git a/apps/greenhouse/src/components/admin/Layout/Navigation.tsx b/apps/greenhouse/src/components/admin/Layout/Navigation.tsx index 51fd4cac78..666cbf39d6 100644 --- a/apps/greenhouse/src/components/admin/Layout/Navigation.tsx +++ b/apps/greenhouse/src/components/admin/Layout/Navigation.tsx @@ -16,6 +16,10 @@ export const navigationItems = [ label: "Clusters", value: "/admin/clusters", }, + { + label: "Exposed Services", + value: "/admin/exposed-services", + }, ] as const type NavigationItem = (typeof navigationItems)[number] diff --git a/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/Details.tsx b/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/Details.tsx index c8fed70467..ed8eef45d9 100644 --- a/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/Details.tsx +++ b/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/Details.tsx @@ -14,51 +14,67 @@ import { ContentHeading, } from "@cloudoperators/juno-ui-components" import { Plugin } from "../../types/k8sTypes" +import { SUPPORT_GROUP_LABEL } from "../../constants" +import { ExternalLink } from "../../common/ExternalLink" interface DetailsProps { plugin: Plugin } -export const Details: React.FC = ({ plugin }) => ( - - Details - - - - Name - {plugin.metadata?.name} - - - PluginPreset - {plugin.metadata?.labels?.["greenhouse.sap/pluginpreset"]} - - - Plugin Definition - {plugin.spec?.pluginDefinitionRef?.name} - - - - - Owning Team - {plugin.metadata?.labels?.["greenhouse.sap/owned-by"]} - - - Cluster - {plugin.spec?.clusterName} - - {plugin.metadata?.labels && Object.keys(plugin.metadata.labels).length > 0 && ( +export const Details: React.FC = ({ plugin }) => { + const exposedServices = plugin.status?.exposedServices || {} + const exposedServicesEntries = Object.entries(exposedServices) + const exposedServicesLinks = exposedServicesEntries.map(([url, service], index) => ( + + {} + {index < exposedServicesEntries.length - 1 && " "} + + )) + return ( + + Details + + - Labels - - - {Object.entries(plugin.metadata.labels).map(([key, value]) => ( - - ))} - - + Name + {plugin.metadata?.name} - )} - + + PluginPreset + {plugin.metadata?.labels?.["greenhouse.sap/pluginpreset"]} + + + Plugin Definition + {plugin.spec?.pluginDefinitionRef?.name} + + + Owning Team + {plugin.metadata?.labels?.[SUPPORT_GROUP_LABEL]} + + + + + Cluster + {plugin.spec?.clusterName} + + {plugin.metadata?.labels && Object.keys(plugin.metadata.labels).length > 0 && ( + + Labels + + + {Object.entries(plugin.metadata.labels).map(([key, value]) => ( + + ))} + + + + )} + + Exposed Services + {exposedServicesLinks.length > 0 ? exposedServicesLinks : "--"} + + + - -) + ) +} diff --git a/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/ExposedServices.test.tsx b/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/ExposedServices.test.tsx deleted file mode 100644 index 44e6789532..0000000000 --- a/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/ExposedServices.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from "react" -import { render, screen } from "@testing-library/react" -import { ExposedServices } from "./ExposedServices" -import { Plugin } from "../../types/k8sTypes" - -describe("ExposedServices", () => { - it("should render 'No exposed services' when exposedServices is empty", () => { - const mockPlugin: Plugin = { - metadata: { - name: "test-plugin", - }, - spec: { - pluginDefinition: "test-definition", - pluginDefinitionRef: { name: "test-definition" }, - deletionPolicy: "Delete", - }, - status: { - exposedServices: {}, - }, - } - - render() - - expect(screen.getByText("Exposed Services")).toBeInTheDocument() - expect(screen.getByText("No exposed services")).toBeInTheDocument() - }) - - it("should render exposed services in a data grid", () => { - const mockPlugin: Plugin = { - metadata: { - name: "test-plugin", - }, - spec: { - pluginDefinition: "test-definition", - pluginDefinitionRef: { name: "test-definition" }, - deletionPolicy: "Delete", - }, - status: { - exposedServices: { - "https://example.com": { - name: "example-service", - namespace: "default", - type: "ingress", - protocol: "https", - port: 443, - }, - "https://api.example.com": { - name: "api-service", - namespace: "api-namespace", - type: "service", - protocol: "http", - port: 8080, - }, - }, - }, - } - - render() - - expect(screen.getByText("Exposed Services")).toBeInTheDocument() - - // Check table headers - expect(screen.getByText("Service")).toBeInTheDocument() - expect(screen.getByText("Namespace")).toBeInTheDocument() - expect(screen.getByText("Port")).toBeInTheDocument() - expect(screen.getByText("Protocol")).toBeInTheDocument() - - // Check first service - expect(screen.getByText("example-service")).toBeInTheDocument() - expect(screen.getByText("default")).toBeInTheDocument() - expect(screen.getByText("443")).toBeInTheDocument() - expect(screen.getByText("https")).toBeInTheDocument() - - // Check second service - expect(screen.getByText("api-service")).toBeInTheDocument() - expect(screen.getByText("api-namespace")).toBeInTheDocument() - expect(screen.getByText("8080")).toBeInTheDocument() - expect(screen.getByText("http")).toBeInTheDocument() - }) - - it("should render service names as links to their URLs", () => { - const mockPlugin: Plugin = { - metadata: { - name: "test-plugin", - }, - spec: { - pluginDefinition: "test-definition", - pluginDefinitionRef: { name: "test-definition" }, - deletionPolicy: "Delete", - }, - status: { - exposedServices: { - "https://example.com": { - name: "example-service", - namespace: "default", - type: "ingress", - protocol: "https", - port: 443, - }, - }, - }, - } - - render() - - const link = screen.getByRole("link", { name: /example-service/i }) - expect(link).toBeInTheDocument() - expect(link).toHaveAttribute("href", "https://example.com") - expect(link).toHaveAttribute("target", "_blank") - expect(link).toHaveAttribute("rel", "noopener noreferrer") - }) -}) diff --git a/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/ExposedServices.tsx b/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/ExposedServices.tsx deleted file mode 100644 index cbdc8981bc..0000000000 --- a/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/ExposedServices.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from "react" -import { - Stack, - DataGrid, - DataGridRow, - DataGridHeadCell, - DataGridCell, - ContentHeading, -} from "@cloudoperators/juno-ui-components" -import { Plugin } from "../../types/k8sTypes" - -type ExposedServicesProps = { - plugin: Plugin -} - -export const ExposedServices: React.FC = ({ plugin }) => { - const exposedServices = plugin.status?.exposedServices || {} - const entries = Object.entries(exposedServices) - - return ( - - Exposed Services - {entries.length === 0 ? ( - No exposed services - ) : ( - - - Service - Namespace - Port - Protocol - - {entries.map(([url, service]) => ( - - - {url ? ( - - {service.name} - - ) : ( - service.name - )} - - {service.namespace} - {service.port} - {service.protocol} - - ))} - - )} - - ) -} diff --git a/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/index.tsx b/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/index.tsx index c8a2ca69fb..bf87c6a52f 100644 --- a/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/index.tsx +++ b/apps/greenhouse/src/components/admin/PluginInstanceDetail/Overview/index.tsx @@ -7,7 +7,6 @@ import React from "react" import { Container } from "@cloudoperators/juno-ui-components" import { Details } from "./Details" import { Conditions } from "./Conditions" -import { ExposedServices } from "./ExposedServices" import { Plugin } from "../../types/k8sTypes" const Section = ({ children, ...rest }: React.HTMLAttributes) => ( @@ -24,8 +23,5 @@ export const Overview = ({ plugin }: { plugin: Plugin }) => ( - - - > ) diff --git a/apps/greenhouse/src/components/admin/PluginPresetDetail/Overview/Details.tsx b/apps/greenhouse/src/components/admin/PluginPresetDetail/Overview/Details.tsx index cf8d2a8882..c484548f9d 100644 --- a/apps/greenhouse/src/components/admin/PluginPresetDetail/Overview/Details.tsx +++ b/apps/greenhouse/src/components/admin/PluginPresetDetail/Overview/Details.tsx @@ -14,6 +14,7 @@ import { ContentHeading, } from "@cloudoperators/juno-ui-components" import { PluginPreset } from "../../types/k8sTypes" +import { SUPPORT_GROUP_LABEL } from "../../constants" interface DetailsProps { pluginPreset: PluginPreset @@ -41,7 +42,7 @@ export const Details: React.FC = ({ pluginPreset }) => ( Owning Team - {pluginPreset.metadata?.labels?.["greenhouse.sap/owned-by"] ?? "--"} + {pluginPreset.metadata?.labels?.[SUPPORT_GROUP_LABEL] ?? "--"} {pluginPreset.metadata?.labels && Object.keys(pluginPreset.metadata.labels).length > 0 && ( diff --git a/apps/greenhouse/src/components/admin/PluginPresets/index.tsx b/apps/greenhouse/src/components/admin/PluginPresets/index.tsx index 1f64b84fb9..889a0dff6e 100644 --- a/apps/greenhouse/src/components/admin/PluginPresets/index.tsx +++ b/apps/greenhouse/src/components/admin/PluginPresets/index.tsx @@ -4,7 +4,7 @@ */ import React, { useState } from "react" -import { Container, ContentHeading, Button, Stack } from "@cloudoperators/juno-ui-components" +import { Container, ContentHeading, Button, Stack, DataGridToolbar } from "@cloudoperators/juno-ui-components" import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query" import { useRouteContext, useSearch } from "@tanstack/react-router" import { fetchPluginPresets, FETCH_PLUGIN_PRESETS_CACHE_KEY } from "../api/plugin-presets/fetchPluginPresets" @@ -42,23 +42,25 @@ export const PluginPresets = () => { - - - {`${total} pluginpresets`} - {`(${ready} ready, ${notReady} not ready)`} - - - {lastUpdatedAt && `Last update: ${new Date(lastUpdatedAt).toLocaleString()}`} - 0 ? "Loading..." : "Refresh"} - className="ml-4 min-w-[90px]" - onClick={handleRefresh} - variant="subdued" - disabled={isFetching > 0} - /> + + + + {`${total} pluginpresets`} + {`(${ready} ready, ${notReady} not ready)`} + + + {lastUpdatedAt && `Last update: ${new Date(lastUpdatedAt).toLocaleString()}`} + 0 ? "Loading..." : "Refresh"} + className="ml-4 min-w-[90px]" + onClick={handleRefresh} + variant="subdued" + disabled={isFetching > 0} + /> + - + > diff --git a/apps/greenhouse/src/components/admin/__mocks__/exposedServices.ts b/apps/greenhouse/src/components/admin/__mocks__/exposedServices.ts new file mode 100644 index 0000000000..85dbd7c801 --- /dev/null +++ b/apps/greenhouse/src/components/admin/__mocks__/exposedServices.ts @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginsWithExposedServices } from "../types/k8sTypes" + +export type MockPluginsWithExposedServicesResponse = { + items: PluginsWithExposedServices[] +} + +// The standalone mock data can remain as PluginsWithExposedServices[] +export const mockExposedServices: MockPluginsWithExposedServicesResponse = { + items: [ + { + apiVersion: "greenhouse.sap/v1alpha1", + kind: "Plugin", + metadata: { + name: "multi-service-demo", + namespace: "sci", + labels: { + "greenhouse.sap/cluster": "demo", + }, + }, + spec: { + clusterName: "demo", + deletionPolicy: "Delete", + pluginDefinitionRef: { + kind: "PluginDefinition", + name: "multi-service-feature", + }, + releaseName: "multi-service", + releaseNamespace: "multi-service", + }, + status: { + description: "A test plugin with multiple services exposed via Greenhouse", + exposedServices: { + "https://demo-service1.sci.greenhouse-qa.eu-nl-1.cloud.sap": { + name: "service1", + namespace: "multi-service", + port: 8080, + type: "service", + }, + "https://demo-service2.sci.greenhouse-qa.eu-nl-1.cloud.sap": { + name: "service2", + namespace: "multi-service", + port: 9090, + type: "service", + }, + }, + helmChart: { + name: "multi-service", + repository: "oci://ghcr.io/cloudoperators/greenhouse-extensions/charts", + version: "2.1.7", + }, + helmReleaseStatus: { + firstDeployed: "2026-03-16T14:49:09Z", + lastDeployed: "2026-03-19T23:10:25Z", + pluginOptionChecksum: "abcdefgh123456789", + status: "deployed", + }, + }, + }, + { + apiVersion: "greenhouse.sap/v1alpha1", + kind: "Plugin", + metadata: { + name: "multi-service-demo", + namespace: "sci", + labels: { + "greenhouse.sap/cluster": "demo", + }, + }, + spec: { + clusterName: "demo", + deletionPolicy: "Delete", + pluginDefinitionRef: { + kind: "PluginDefinition", + name: "multi-service-feature", + }, + releaseName: "multi-service", + releaseNamespace: "multi-service", + }, + status: { + description: "A test plugin with multiple services exposed via Greenhouse", + exposedServices: { + "": { + name: "service3", + namespace: "multi-service", + port: 8080, + type: "service", + }, + }, + helmChart: { + name: "multi-service", + repository: "oci://ghcr.io/cloudoperators/greenhouse-extensions/charts", + version: "2.1.7", + }, + helmReleaseStatus: { + firstDeployed: "2026-03-16T14:49:09Z", + lastDeployed: "2026-03-19T23:10:25Z", + pluginOptionChecksum: "abcdefgh123456789", + status: "deployed", + }, + }, + }, + ], +} diff --git a/apps/greenhouse/src/components/admin/api/exposed-services/fetchExposedServices.ts b/apps/greenhouse/src/components/admin/api/exposed-services/fetchExposedServices.ts new file mode 100644 index 0000000000..1785670e82 --- /dev/null +++ b/apps/greenhouse/src/components/admin/api/exposed-services/fetchExposedServices.ts @@ -0,0 +1,101 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FilterSettings } from "../../common/types" +import { PluginsWithExposedServices } from "../../types/k8sTypes" +import { NO_VALUE_DEFAULT, FILTER_IDS, SUPPORT_GROUP_LABEL, EXPOSED_SERVICES_LABEL_SELECTOR } from "../../constants" + +export const FETCH_EXPOSED_SERVICES_CACHE_KEY = "exposedServices" + +export interface FlattenedExposedServices { + serviceUrl: string // URL of the exposed service + serviceName: string + clusterName: string + pluginName: string + supportGroup: string +} + +// Function to flatten plugins and extract exposed services +const flattenExposedServices = (plugins: PluginsWithExposedServices[]): FlattenedExposedServices[] => { + const flattenedServices: FlattenedExposedServices[] = [] + + plugins.forEach((plugin) => { + const clusterName = plugin.spec?.clusterName || NO_VALUE_DEFAULT + const pluginName = plugin.metadata?.name || NO_VALUE_DEFAULT + const supportGroup = plugin.metadata?.labels?.[SUPPORT_GROUP_LABEL] || NO_VALUE_DEFAULT + const exposedServices = plugin.status?.exposedServices || {} + + Object.entries(exposedServices).forEach(([url, service]) => { + flattenedServices.push({ + serviceUrl: url, + serviceName: service.name || NO_VALUE_DEFAULT, + clusterName: clusterName, + pluginName: pluginName, + supportGroup: supportGroup, + }) + }) + }) + + return flattenedServices +} + +// Apply filter settings and sort services +const applyFilterAndSortSettings = ( + flattenedServices: FlattenedExposedServices[], + filterSettings?: FilterSettings +): FlattenedExposedServices[] => { + if (filterSettings?.selectedFilters) { + // Filter by cluster + const clusterValues = filterSettings.selectedFilters.filter((f) => f.id === FILTER_IDS.CLUSTER).map((f) => f.value) + if (clusterValues.length > 0) { + flattenedServices = flattenedServices.filter((service) => clusterValues.includes(service.clusterName)) + } + + // Filter by plugin name + const pluginValues = filterSettings.selectedFilters.filter((f) => f.id === FILTER_IDS.PLUGIN).map((f) => f.value) + if (pluginValues.length > 0) { + flattenedServices = flattenedServices.filter((service) => pluginValues.includes(service.pluginName)) + } + + // Filter by support group + const supportGroupValues = filterSettings.selectedFilters + .filter((f) => f.id === FILTER_IDS.SUPPORT_GROUP) + .map((f) => f.value) + if (supportGroupValues.length > 0) { + flattenedServices = flattenedServices.filter((service) => supportGroupValues.includes(service.supportGroup)) + } + } + + // Filter by search term + if (filterSettings?.searchTerm) { + const searchTerm = filterSettings.searchTerm.toLowerCase() + flattenedServices = flattenedServices.filter((service) => service.serviceName.toLowerCase().includes(searchTerm)) + } + + // Sort services alphabetically by service name + flattenedServices.sort((a, b) => a.serviceName.localeCompare(b.serviceName)) + + return flattenedServices +} + +export const fetchExposedServices = async ({ + apiClient, + namespace, + filterSettings, +}: { + apiClient: any + namespace: string + filterSettings?: FilterSettings +}): Promise => { + const response = await apiClient.get(`/apis/greenhouse.sap/v1alpha1/namespaces/${namespace}/plugins`, { + params: { + labelSelector: EXPOSED_SERVICES_LABEL_SELECTOR, + }, + }) + + const flattenedServices = flattenExposedServices(Array.isArray(response?.items) ? response.items : []) + + return applyFilterAndSortSettings(flattenedServices, filterSettings) +} diff --git a/apps/greenhouse/src/components/admin/api/exposed-services/fetchExposedServicesFilters.ts b/apps/greenhouse/src/components/admin/api/exposed-services/fetchExposedServicesFilters.ts new file mode 100644 index 0000000000..e1c2fd7967 --- /dev/null +++ b/apps/greenhouse/src/components/admin/api/exposed-services/fetchExposedServicesFilters.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Filter } from "../../common/types" +import { PluginsWithExposedServices } from "../../types/k8sTypes" +import { FILTER_IDS, SUPPORT_GROUP_LABEL, EXPOSED_SERVICES_LABEL_SELECTOR } from "../../constants" + +const getClusterValues = (pluginWithExposedServices: PluginsWithExposedServices[]) => + Array.from( + new Set( + pluginWithExposedServices.map((plugin) => { + return plugin.spec?.clusterName + }) + ) + ).filter((value): value is string => !!value) + +const getPluginValues = (pluginWithExposedServices: PluginsWithExposedServices[]) => + Array.from( + new Set( + pluginWithExposedServices.map((plugin) => { + return plugin.metadata?.name + }) + ) + ).filter((value): value is string => !!value) + +const getSupportGroupValues = (pluginWithExposedServices: PluginsWithExposedServices[]) => + Array.from( + new Set( + pluginWithExposedServices.map((plugin) => { + return plugin.metadata?.labels?.[SUPPORT_GROUP_LABEL] + }) + ) + ).filter((value): value is string => !!value) + +const extractFilters = (pluginsWithExposedServices: PluginsWithExposedServices[]) => { + return [ + { + id: FILTER_IDS.CLUSTER, + label: "Cluster", + values: getClusterValues(pluginsWithExposedServices), + }, + { + id: FILTER_IDS.PLUGIN, + label: "Plugin", + values: getPluginValues(pluginsWithExposedServices), + }, + { + id: FILTER_IDS.SUPPORT_GROUP, + label: "Support Group", + values: getSupportGroupValues(pluginsWithExposedServices), + }, + ] +} + +export const FETCH_EXPOSED_SERVICES_FILTERS_CACHE_KEY = "exposedServicesFilters" + +export const fetchExposedServicesFilters = async ({ + apiClient, + namespace, +}: { + apiClient: any + namespace: string +}): Promise => { + // Filter only by plugins that have exposed services + const response = await apiClient.get(`/apis/greenhouse.sap/v1alpha1/namespaces/${namespace}/plugins`, { + params: { + labelSelector: EXPOSED_SERVICES_LABEL_SELECTOR, + }, + }) + return Array.isArray(response?.items) ? extractFilters(response.items) : [] +} diff --git a/apps/greenhouse/src/components/admin/common/ExternalLink.tsx b/apps/greenhouse/src/components/admin/common/ExternalLink.tsx new file mode 100644 index 0000000000..d3ee169a26 --- /dev/null +++ b/apps/greenhouse/src/components/admin/common/ExternalLink.tsx @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { Icon, Stack } from "@cloudoperators/juno-ui-components" +import { getSafeExternalUrl } from "../utils" + +interface LinkElementProps { + url: string + label: string +} + +export const ExternalLink: React.FC = ({ url, label }) => { + const placeholder = "Unknown Service" + const safeUrl = getSafeExternalUrl(url) + const displayName = label || placeholder + + return safeUrl ? ( + + + {displayName} + + + + ) : ( + {displayName} + ) +} diff --git a/apps/greenhouse/src/components/admin/constants.ts b/apps/greenhouse/src/components/admin/constants.ts index 2ef5492291..42e1d6362b 100644 --- a/apps/greenhouse/src/components/admin/constants.ts +++ b/apps/greenhouse/src/components/admin/constants.ts @@ -5,9 +5,14 @@ export const SELECTED_FILTER_PREFIX = "f_" +export const NO_VALUE_DEFAULT = "--" + export const SUPPORT_GROUP_LABEL = "greenhouse.sap/owned-by" +export const EXPOSED_SERVICES_LABEL_SELECTOR = "greenhouse.sap/plugin-exposed-services=true" export const FILTER_IDS = { PLUGIN_PRESET_DEFINITION: "pluginPresetDefinition", + PLUGIN: "plugin", + CLUSTER: "cluster", SUPPORT_GROUP: "supportGroup", } as const diff --git a/apps/greenhouse/src/components/admin/types/k8sTypes.ts b/apps/greenhouse/src/components/admin/types/k8sTypes.ts index 2acdc3d7c4..f0a7a05dfc 100644 --- a/apps/greenhouse/src/components/admin/types/k8sTypes.ts +++ b/apps/greenhouse/src/components/admin/types/k8sTypes.ts @@ -7,5 +7,6 @@ import type { components } from "./schema" export type PluginPreset = components["schemas"]["PluginPreset"] export type Plugin = components["schemas"]["Plugin"] +export type PluginsWithExposedServices = components["schemas"]["PluginsWithExposedServices"] export type PluginOptionValues = NonNullable["optionValues"] export type PluginOptionValue = NonNullable[number] diff --git a/apps/greenhouse/src/components/admin/types/schema.d.ts b/apps/greenhouse/src/components/admin/types/schema.d.ts index 20235210b6..252c218ae0 100644 --- a/apps/greenhouse/src/components/admin/types/schema.d.ts +++ b/apps/greenhouse/src/components/admin/types/schema.d.ts @@ -1545,6 +1545,164 @@ export interface components { } } } + /** + * PluginsWithExposedServices + * @description PluginsWithExposedServices is the Schema for plugins filtered by those that have exposed services + */ + PluginsWithExposedServices: { + /** + * @description APIVersion defines the versioned schema of this representation of an object. + * Servers should convert recognized schemas to the latest internal value, and + * may reject unrecognized values. + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + */ + apiVersion?: string + + /** + * @description Kind is a string value representing the REST resource this object represents. + * Servers may infer this from the endpoint the client submits requests to. + * Cannot be updated. + * In CamelCase. + * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + */ + kind?: string + + metadata?: { + name?: string + namespace?: string + /** Format: uuid */ + uid?: string + resourceVersion?: string + /** Format: date-time */ + creationTimestamp?: string + /** Format: date-time */ + deletionTimestamp?: string + labels?: { + [key: string]: string + } + annotations?: { + [key: string]: string + } + finalizers?: string[] + managedFields?: { + apiVersion?: string + fieldsType?: string + fieldsV1?: Record + manager?: string + operation?: string + time?: string + subresource?: string + }[] + ownerReferences?: { + apiVersion?: string + blockOwnerDeletion?: boolean + controller?: boolean + kind?: string + name?: string + /** Format: uuid */ + uid?: string + }[] + } + + spec?: { + clusterName?: string + /** + * @description DeletionPolicy defines how Plugins owned by an PluginsWithExposedServices are handled on deletion. + * Supported values are "Delete" and "Retain". If not set, defaults to "Delete". + * @default Delete + * @enum {string} + */ + deletionPolicy: "Delete" | "Retain" + displayName?: string + optionValues?: { + name: string + value?: unknown + valueFrom?: { + secret?: { + /** @description Key in the secret to select the value from. */ + key: string + /** @description Name of the secret in the same namespace. */ + name: string + } + } + }[] + pluginDefinitionRef?: { + /** + * @description Kind of the referent. Supported values: PluginDefinition, ClusterPluginDefinition. + * @enum {string} + */ + kind?: "PluginDefinition" | "ClusterPluginDefinition" + name?: string + } + /** + * @description ReleaseName is the name of the helm release in the remote cluster where the backend is deployed. + * If the Plugin was already deployed, the Plugin's name is used as the release name. + * If newly created, the releaseName defaults to the PluginDefinitions HelmChart name. + */ + releaseName?: string + /** + * @description ReleaseNamespace is the namespace in the remote cluster where the backend is deployed. + * Defaults to the Greenhouse managed namespace if not set. + */ + releaseNamespace?: string + } + + status?: { + description?: string + exposedServices?: { + [url: string]: { + /** @description The name of the service */ + name: string + /** @description The namespace of the service */ + namespace: string + /** @description The port where the service is exposed */ + port: number + /** @description The type of service, typically "service" */ + type: string + } + } + helmChart?: { + name: string + repository: string + version: string + } + helmReleaseStatus?: { + /** + * Format: date-time + * @description The first time the release was deployed + */ + firstDeployed: string + /** + * Format: date-time + * @description The last time the release was deployed + */ + lastDeployed: string + /** @description Checksum of plugin options */ + pluginOptionChecksum: string + /** @description Current status of the Helm release */ + status: string + } + statusConditions?: { + conditions: { + /** + * Format: date-time + * @description Last time the condition transitioned from one status to another + */ + lastTransitionTime: string + /** @description Optional message with details about the last transition */ + message?: string + /** @description One-word, CamelCase reason for the condition's last transition */ + reason?: string + /** @description Status of the condition */ + status: string + /** @description Type of the condition */ + type: string + }[] + } + /** @description Observed version of the PluginsWithExposedServices */ + version?: string + } + } } responses: never parameters: never diff --git a/apps/greenhouse/src/components/admin/utils.ts b/apps/greenhouse/src/components/admin/utils.ts index cf751da413..92d92acfca 100644 --- a/apps/greenhouse/src/components/admin/utils.ts +++ b/apps/greenhouse/src/components/admin/utils.ts @@ -94,3 +94,16 @@ export const getFiltersForUrl = (filterSettings: FilterSettings) => { return result } + +// Check if a URL is safe +export const getSafeExternalUrl = (url: string) => { + try { + const parsedUrl = new URL(url) + if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") { + return parsedUrl.toString() + } + } catch { + return null + } + return null +} diff --git a/apps/greenhouse/src/routeTree.gen.ts b/apps/greenhouse/src/routeTree.gen.ts index 10b2553b7c..a7c0434114 100644 --- a/apps/greenhouse/src/routeTree.gen.ts +++ b/apps/greenhouse/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as OrgAdminRouteImport } from './routes/org-admin' import { Route as AdminRouteRouteImport } from './routes/admin/route' import { Route as IndexRouteImport } from './routes/index' import { Route as AdminIndexRouteImport } from './routes/admin/index' +import { Route as AdminExposedServicesRouteImport } from './routes/admin/exposed-services' import { Route as AdminClustersRouteImport } from './routes/admin/clusters' import { Route as ExtensionIdSplatRouteImport } from './routes/$extensionId.$' import { Route as AdminPluginPresetsRouteRouteImport } from './routes/admin/plugin-presets/route' @@ -42,6 +43,11 @@ const AdminIndexRoute = AdminIndexRouteImport.update({ path: '/', getParentRoute: () => AdminRouteRoute, } as any) +const AdminExposedServicesRoute = AdminExposedServicesRouteImport.update({ + id: '/exposed-services', + path: '/exposed-services', + getParentRoute: () => AdminRouteRoute, +} as any) const AdminClustersRoute = AdminClustersRouteImport.update({ id: '/clusters', path: '/clusters', @@ -97,6 +103,7 @@ export interface FileRoutesByFullPath { '/admin/plugin-presets': typeof AdminPluginPresetsRouteRouteWithChildren '/$extensionId/$': typeof ExtensionIdSplatRoute '/admin/clusters': typeof AdminClustersRoute + '/admin/exposed-services': typeof AdminExposedServicesRoute '/admin/': typeof AdminIndexRoute '/admin/plugin-presets/$pluginPresetName': typeof AdminPluginPresetsPluginPresetNameRouteRouteWithChildren '/admin/plugin-presets/': typeof AdminPluginPresetsIndexRoute @@ -109,6 +116,7 @@ export interface FileRoutesByTo { '/org-admin': typeof OrgAdminRoute '/$extensionId/$': typeof ExtensionIdSplatRoute '/admin/clusters': typeof AdminClustersRoute + '/admin/exposed-services': typeof AdminExposedServicesRoute '/admin': typeof AdminIndexRoute '/admin/plugin-presets': typeof AdminPluginPresetsIndexRoute '/admin/plugin-presets/$pluginPresetName/plugin-instances': typeof AdminPluginPresetsPluginPresetNamePluginInstancesRouteRouteWithChildren @@ -123,6 +131,7 @@ export interface FileRoutesById { '/admin/plugin-presets': typeof AdminPluginPresetsRouteRouteWithChildren '/$extensionId/$': typeof ExtensionIdSplatRoute '/admin/clusters': typeof AdminClustersRoute + '/admin/exposed-services': typeof AdminExposedServicesRoute '/admin/': typeof AdminIndexRoute '/admin/plugin-presets/$pluginPresetName': typeof AdminPluginPresetsPluginPresetNameRouteRouteWithChildren '/admin/plugin-presets/': typeof AdminPluginPresetsIndexRoute @@ -139,6 +148,7 @@ export interface FileRouteTypes { | '/admin/plugin-presets' | '/$extensionId/$' | '/admin/clusters' + | '/admin/exposed-services' | '/admin/' | '/admin/plugin-presets/$pluginPresetName' | '/admin/plugin-presets/' @@ -151,6 +161,7 @@ export interface FileRouteTypes { | '/org-admin' | '/$extensionId/$' | '/admin/clusters' + | '/admin/exposed-services' | '/admin' | '/admin/plugin-presets' | '/admin/plugin-presets/$pluginPresetName/plugin-instances' @@ -164,6 +175,7 @@ export interface FileRouteTypes { | '/admin/plugin-presets' | '/$extensionId/$' | '/admin/clusters' + | '/admin/exposed-services' | '/admin/' | '/admin/plugin-presets/$pluginPresetName' | '/admin/plugin-presets/' @@ -209,6 +221,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminIndexRouteImport parentRoute: typeof AdminRouteRoute } + '/admin/exposed-services': { + id: '/admin/exposed-services' + path: '/exposed-services' + fullPath: '/admin/exposed-services' + preLoaderRoute: typeof AdminExposedServicesRouteImport + parentRoute: typeof AdminRouteRoute + } '/admin/clusters': { id: '/admin/clusters' path: '/clusters' @@ -321,12 +340,14 @@ const AdminPluginPresetsRouteRouteWithChildren = interface AdminRouteRouteChildren { AdminPluginPresetsRouteRoute: typeof AdminPluginPresetsRouteRouteWithChildren AdminClustersRoute: typeof AdminClustersRoute + AdminExposedServicesRoute: typeof AdminExposedServicesRoute AdminIndexRoute: typeof AdminIndexRoute } const AdminRouteRouteChildren: AdminRouteRouteChildren = { AdminPluginPresetsRouteRoute: AdminPluginPresetsRouteRouteWithChildren, AdminClustersRoute: AdminClustersRoute, + AdminExposedServicesRoute: AdminExposedServicesRoute, AdminIndexRoute: AdminIndexRoute, } diff --git a/apps/greenhouse/src/routes/admin/exposed-services.tsx b/apps/greenhouse/src/routes/admin/exposed-services.tsx new file mode 100644 index 0000000000..c00bedb8a8 --- /dev/null +++ b/apps/greenhouse/src/routes/admin/exposed-services.tsx @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createFileRoute, redirect } from "@tanstack/react-router" +import { z } from "zod" + +import { User } from "../__root" +import { filterSearchParamsByPrefix } from "../../lib/helpers" +import { ExposedServices } from "../../components/admin/ExposedServices" +import { FILTER_IDS, SELECTED_FILTER_PREFIX } from "../../components/admin/constants" +import { extractFilterSettingsFromSearchParams, getFiltersForUrl } from "../../components/admin/utils" + +// A module level flag that resets on page refresh but persists during SPA navigation +let defaultFiltersApplied = false + +// Define validation schema for search parameters +const filterValueSchema = z.union([z.string(), z.array(z.string()), z.undefined()]) +const searchParamsSchema = z + .object({ + searchTerm: z.string().optional(), + }) + .catchall(filterValueSchema) + +export type ExposedServicesSearchParams = z.infer + +function validateExposedServicesSearch(search: Record): ExposedServicesSearchParams { + const filtered = filterSearchParamsByPrefix(search, Object.keys(searchParamsSchema.shape), [SELECTED_FILTER_PREFIX]) + return searchParamsSchema.parse(filtered) +} + +// Generate default filters based on user context +const getDefaultFilters = (user: User) => { + const defaultSupportGroupFilters = user.supportGroups.map((sg) => ({ + id: FILTER_IDS.SUPPORT_GROUP, + value: sg, + })) + return defaultSupportGroupFilters +} + +// Create the route with necessary validation and filter logic +export const Route = createFileRoute("/admin/exposed-services")({ + component: ExposedServices, + validateSearch: validateExposedServicesSearch, + beforeLoad: ({ context, search }) => { + // Skip if defaults were already applied this session + if (defaultFiltersApplied) { + return + } + + defaultFiltersApplied = true + + // Check if any filter is already applied + const hasAnyFilter = Object.keys(search).some((key) => key.startsWith(SELECTED_FILTER_PREFIX)) + const defaultFilters = getDefaultFilters(context.user) + + // If no filters in the URL but there are some default filters to apply, redirect with default filters + if (!hasAnyFilter && defaultFilters.length > 0) { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw redirect({ + to: "/admin/exposed-services", + search: { + ...search, + ...getFiltersForUrl({ selectedFilters: defaultFilters }), + }, + replace: true, + }) + } + }, + loaderDeps: (search) => ({ + ...search, + }), + loader: ({ deps: { search } }) => ({ + filterSettings: extractFilterSettingsFromSearchParams(search), + crumb: { + label: "Exposed Services", + icon: "home", + }, + }), +}) diff --git a/apps/greenhouse/src/routes/admin/plugin-presets/index.tsx b/apps/greenhouse/src/routes/admin/plugin-presets/index.tsx index 1eddd1c397..991539dee6 100644 --- a/apps/greenhouse/src/routes/admin/plugin-presets/index.tsx +++ b/apps/greenhouse/src/routes/admin/plugin-presets/index.tsx @@ -56,7 +56,10 @@ export const Route = createFileRoute("/admin/plugin-presets/")({ // eslint-disable-next-line @typescript-eslint/only-throw-error throw redirect({ to: "/admin/plugin-presets", - search: getFiltersForUrl({ selectedFilters: defaultFilters }), + search: { + ...search, + ...getFiltersForUrl({ selectedFilters: defaultFilters }), + }, replace: true, }) }
Monitor exposed services for all plugins
No exposed services