diff --git a/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Conditions.test.tsx b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Conditions.test.tsx new file mode 100644 index 0000000000..c390a96325 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Conditions.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 { Conditions } from "./Conditions" +import { Cluster } from "../../types/k8sTypes" + +describe("Conditions", () => { + it("should render conditions heading and readiness badges", () => { + const mockCluster: Cluster = { + apiVersion: "v1", + kind: "Cluster", + metadata: { name: "test-cluster", creationTimestamp: "" }, + spec: { + accessMode: "direct", + kubeConfig: { + maxTokenValidity: 72, + }, + }, + status: { + statusConditions: { + conditions: [ + { + type: "Ready", + status: "True", + lastTransitionTime: "2026-01-01T00:00:00Z", + }, + ], + }, + }, + } + + render() + + expect(screen.getByText("Conditions")).toBeInTheDocument() + expect(screen.getByText("Ready")).toBeInTheDocument() + }) +}) diff --git a/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Conditions.tsx b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Conditions.tsx new file mode 100644 index 0000000000..d7d0ae5b55 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Conditions.tsx @@ -0,0 +1,21 @@ +/* + * 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, ContentHeading } from "@cloudoperators/juno-ui-components" + +import { Cluster } from "../../types/k8sTypes" +import ReadinessConditions from "../../common/ReadinessConditions" + +type ConditionsProps = { + clusters: Cluster +} + +export const Conditions: React.FC = ({ clusters }) => ( + + Conditions + + +) diff --git a/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Details.test.tsx b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Details.test.tsx new file mode 100644 index 0000000000..fe260d6825 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Details.test.tsx @@ -0,0 +1,56 @@ +/* + * 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 { Details } from "./Details" +import { Cluster } from "../../types/k8sTypes" + +describe("Details", () => { + it("should render cluster details", () => { + const mockCluster: Cluster = { + apiVersion: "greenhouse.sap/v1alpha1", + kind: "Cluster", + metadata: { + name: "test-cluster", + creationTimestamp: "2026-01-01T00:00:00Z", + labels: { + "greenhouse.sap/owned-by": "test-team", + }, + annotations: { + "greenhouse.sap/cluster-connectivity": "test-oidc", + }, + }, + spec: { + accessMode: "direct", + kubeConfig: { + maxTokenValidity: 72, + }, + }, + status: { + kubernetesVersion: "v1.20.0", + nodes: { + ready: 1, + total: 1, + }, + statusConditions: { + conditions: [ + { + type: "Ready", + status: "True", + lastTransitionTime: "2026-01-01T00:00:00Z", + }, + ], + }, + }, + } + + render() + + expect(screen.getByText("Details")).toBeInTheDocument() + expect(screen.getByText("test-cluster")).toBeInTheDocument() + expect(screen.getByText("v1.20.0")).toBeInTheDocument() + }) +}) diff --git a/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Details.tsx b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Details.tsx new file mode 100644 index 0000000000..0afcf6238d --- /dev/null +++ b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/Details.tsx @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { + DataGrid, + DataGridRow, + DataGridHeadCell, + DataGridCell, + Pill, + Stack, + ContentHeading, +} from "@cloudoperators/juno-ui-components" + +import { formatAge } from "../../utils" +import YamlViewer from "../../common/YamlViewer" +import { Cluster } from "../../types/k8sTypes" +import { CONNECTIVITY_LABEL, NO_VALUE_DEFAULT, SUPPORT_GROUP_LABEL } from "../../constants" + +interface DetailsProps { + clusters: Cluster +} + +interface NodesStatus { + ready: number + total: number +} + +const NodeStatus: React.FC<{ nodes: NodesStatus }> = ({ nodes }) => { + const { ready, total } = nodes + const notReady = total - ready + + // If not ready nodes exist, make the text red + const notReadyColor = notReady > 0 ? "text-theme-danger" : "" + + return ( + + {total} nodes ({ready} ready, {notReady} not ready) + + ) +} + +export const Details: React.FC = ({ clusters }) => ( + + Details + + + Name + {clusters.metadata?.name ?? NO_VALUE_DEFAULT} + + + Age + {formatAge(clusters.metadata?.creationTimestamp || 0) ?? NO_VALUE_DEFAULT} + + + Version + {clusters.status?.kubernetesVersion ?? NO_VALUE_DEFAULT} + + + Connectivity + {clusters.metadata?.annotations?.[CONNECTIVITY_LABEL] ?? NO_VALUE_DEFAULT} + + + Support Group + {clusters.metadata?.labels?.[SUPPORT_GROUP_LABEL] ?? NO_VALUE_DEFAULT} + + {clusters.metadata?.labels && Object.keys(clusters.metadata.labels).length > 0 && ( + + Labels + + + {Object.entries(clusters.metadata.labels).map(([key, value], index) => ( + + ))} + + + + )} + + Annotations + + {clusters.metadata?.annotations && Object.keys(clusters.metadata.annotations).length > 0 ? ( + + ) : ( + "No Annotations" + )} + + + + Nodes + + + + + + +) diff --git a/apps/greenhouse/src/components/admin/ClusterDetail/Overview/PluginInstances.test.tsx b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/PluginInstances.test.tsx new file mode 100644 index 0000000000..6e3e9010f4 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/PluginInstances.test.tsx @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { act, Suspense } 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 { PluginInstances } from "./PluginInstances" +import { mockPlugins, MockPluginsResponse } from "../../__mocks__/plugins" + +const renderComponent = async (mockPromise: Promise) => { + const rootRoute = createRootRoute({ + component: () => , + }) + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/admin/clusters/$clusterName", + component: () => ( + + Loading...}> + + + + ), + }) + 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/clusters/test-cluster"], + }), + }) + return await act(async () => render()) +} + +describe("PluginInstances", () => { + it("should render plugin instances table", async () => { + await renderComponent(new Promise((resolve) => resolve(mockPlugins))) + + expect(screen.getByText("Plugin Instances")).toBeInTheDocument() + expect(screen.getByText("Plugin Name")).toBeInTheDocument() + expect(screen.getByText("Plugin Preset")).toBeInTheDocument() + expect(screen.getByText("Status")).toBeInTheDocument() + expect(screen.getByText("plugin-1")).toBeInTheDocument() + expect(screen.getByText("plugin-2")).toBeInTheDocument() + }, 20000) +}) diff --git a/apps/greenhouse/src/components/admin/ClusterDetail/Overview/PluginInstances.tsx b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/PluginInstances.tsx new file mode 100644 index 0000000000..2752e41604 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/PluginInstances.tsx @@ -0,0 +1,133 @@ +/* + * 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 { useSuspenseQuery } from "@tanstack/react-query" +import { useParams, useRouteContext, useNavigate } from "@tanstack/react-router" +import { + DataGrid, + DataGridRow, + DataGridHeadCell, + DataGridCell, + Icon, + Stack, + ContentHeading, + PopupMenu, + PopupMenuOptions, + PopupMenuItem, +} from "@cloudoperators/juno-ui-components" + +import { Plugin } from "../../types/k8sTypes" +import { NO_VALUE_DEFAULT } from "../../constants" +import { ErrorBoundary } from "../../common/ErrorBoundary" +import { LoadingDataRow } from "../../common/LoadingDataRow" +import { getErrorDataRowComponent } from "../../common/getErrorDataRow" +import { FETCH_PLUGINS_BY_CLUSTER_CACHE_KEY, fetchPluginsByCluster } from "../../api/plugins/fetchPluginsByCluster" + +const isPluginReady = (plugin: Plugin) => { + return plugin.status?.statusConditions?.conditions?.some((c) => c.type === "Ready" && c.status === "True") ?? false +} + +const COLUMN_SPAN = 5 + +const DataRows = ({ colSpan, plugins }: { colSpan: number; plugins: Plugin[] }) => { + const navigate = useNavigate({ from: "/admin/clusters/$clusterName" }) + + if (plugins.length === 0) { + return ( + + No plugin instances found for this cluster. + + ) + } + + return ( + <> + {plugins.map((plugin, index) => { + const ready = isPluginReady(plugin) + const pluginPresetName = plugin.metadata?.ownerReferences?.[0]?.name + const canNavigateToDetails = Boolean(pluginPresetName) + const navigateToDetails = () => { + if (!pluginPresetName) return + navigate({ + to: "/admin/plugin-presets/$pluginPresetName/plugin-instances/$pluginInstance", + params: { + pluginPresetName, + pluginInstance: plugin.metadata?.name || "", + }, + }) + } + + return ( + + + + + {plugin.metadata?.name || NO_VALUE_DEFAULT} + {pluginPresetName || NO_VALUE_DEFAULT} + {ready ? "Ready" : "Not Ready"} + + e.stopPropagation()}> + + + + + + + ) + })} + > + ) +} + +export const PluginInstances = () => { + const { clusterName } = useParams({ from: "/admin/clusters/$clusterName" }) + const { apiClient, user } = useRouteContext({ from: "/admin/clusters/$clusterName" }) + + const { data: plugins } = useSuspenseQuery({ + queryKey: [FETCH_PLUGINS_BY_CLUSTER_CACHE_KEY, user.organization, clusterName], + queryFn: () => fetchPluginsByCluster({ apiClient, namespace: user.organization, clusterName }), + }) + + const total = plugins?.length ?? 0 + const ready = plugins?.filter(isPluginReady).length ?? 0 + const notReady = total - ready + + return ( + + + Plugin Instances + + + + + {`${total} plugin instances`} + {`(${ready} ready, ${notReady} not ready)`} + + + + + + + + Plugin Name + Plugin Preset + Status + + + + + }> + + + + + + + ) +} diff --git a/apps/greenhouse/src/components/admin/ClusterDetail/Overview/index.tsx b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/index.tsx new file mode 100644 index 0000000000..ae00cc2912 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ClusterDetail/Overview/index.tsx @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { Container } from "@cloudoperators/juno-ui-components" +import { Details } from "./Details" +import { Cluster } from "../../types/k8sTypes" +import { PluginInstances } from "./PluginInstances" +import { Conditions } from "./Conditions" + +const Section = ({ children, ...rest }: React.HTMLAttributes) => ( + + {children} + +) + +export const Overview = ({ cluster }: { cluster: Cluster }) => ( + <> + + + + + + + + + + + + > +) diff --git a/apps/greenhouse/src/components/admin/ClusterDetail/index.test.tsx b/apps/greenhouse/src/components/admin/ClusterDetail/index.test.tsx new file mode 100644 index 0000000000..510a95613d --- /dev/null +++ b/apps/greenhouse/src/components/admin/ClusterDetail/index.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 { MessagesProvider } from "@cloudoperators/juno-messages-provider" +import { ClusterDetail } from "./index" +import { mockClusters } from "../__mocks__/clusters" + +const renderComponent = async (mockPromise: Promise) => { + const rootRoute = createRootRoute({ + component: () => , + }) + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/admin/clusters/$clusterName", + component: () => ( + + + + + + ), + }) + 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/clusters/cluster-1"], + }), + }) + return await act(async () => render()) +} + +describe("ClusterDetail", () => { + it("should render clusters detail with tabs", async () => { + const mockPreset = mockClusters.items[0] + await renderComponent(new Promise((resolve) => resolve(mockPreset))) + + expect(screen.getByRole("heading", { name: "cluster-1" })).toBeInTheDocument() + expect(await screen.findByText("Overview")).toBeInTheDocument() + expect(screen.getByText("YAML")).toBeInTheDocument() + }) +}) diff --git a/apps/greenhouse/src/components/admin/ClusterDetail/index.tsx b/apps/greenhouse/src/components/admin/ClusterDetail/index.tsx new file mode 100644 index 0000000000..5a36431034 --- /dev/null +++ b/apps/greenhouse/src/components/admin/ClusterDetail/index.tsx @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { useQuery } from "@tanstack/react-query" +import { useParams, useRouteContext } from "@tanstack/react-router" +import { Container, Tabs, TabList, Tab, TabPanel, Stack, ContentHeading } from "@cloudoperators/juno-ui-components" + +import { Overview } from "./Overview" +import YamlViewer from "../common/YamlViewer" +import { Cluster } from "../types/k8sTypes" +import { ErrorMessage } from "../common/ErrorBoundary/ErrorMessage" +import { fetchCluster, FETCH_CLUSTER_CACHE_KEY } from "../api/clusters/fetchCluster" + +const ClusterDetailContent = ({ cluster }: { cluster: Cluster }) => ( + + + + + + + + + + + + + + + + +) + +export const ClusterDetail = () => { + const { clusterName } = useParams({ from: "/admin/clusters/$clusterName" }) + const { apiClient, user } = useRouteContext({ from: "/admin/clusters/$clusterName" }) + + const { + data: cluster, + isLoading, + error, + } = useQuery({ + queryKey: [FETCH_CLUSTER_CACHE_KEY, user.organization, clusterName], + queryFn: () => fetchCluster({ apiClient, namespace: user.organization, clusterName }), + }) + + return ( + + + + {clusterName} + + + Cluster configuration and instance status + {isLoading && Loading...} + {!isLoading && error && } + {!isLoading && !error && cluster && } + + ) +} diff --git a/apps/greenhouse/src/components/admin/Clusters/ClustersFilters.tsx b/apps/greenhouse/src/components/admin/Clusters/ClustersFilters.tsx new file mode 100644 index 0000000000..7a8c441c3d --- /dev/null +++ b/apps/greenhouse/src/components/admin/Clusters/ClustersFilters.tsx @@ -0,0 +1,123 @@ +/* + * 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 { useQuery } from "@tanstack/react-query" +import { useLoaderData, useNavigate, useRouteContext } from "@tanstack/react-router" +import { Stack, InputGroup, Button, SearchInput } from "@cloudoperators/juno-ui-components/index" + +import { getFiltersForUrl } from "../utils" +import { SELECTED_FILTER_PREFIX } from "../constants" +import { FilterSelect } from "../common/FilterSelect" +import { SelectedFilters } from "../common/SelectedFilters" +import { FilterSettings, SelectedFilter } from "../common/types" +import { FETCH_CLUSTERS_FILTERS_CACHE_KEY, fetchClustersFilters } from "../api/clusters/fetchClustersFilters" + +export const ClustersFilters = () => { + const navigate = useNavigate() + const { apiClient, user } = useRouteContext({ from: "/admin/clusters" }) + const { filterSettings } = useLoaderData({ from: "/admin/clusters/" }) + const { + data: filters, + isLoading, + error, + } = useQuery({ + queryKey: [FETCH_CLUSTERS_FILTERS_CACHE_KEY, user.organization], + queryFn: () => + fetchClustersFilters({ + apiClient, + namespace: user.organization, + }), + }) + + const updateFilters = useCallback( + (updatedFilterSettings: FilterSettings) => { + navigate({ + to: "/admin/clusters", + 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 doesn't 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/Clusters/ClustersGrid/DataRows/index.tsx b/apps/greenhouse/src/components/admin/Clusters/ClustersGrid/DataRows/index.tsx new file mode 100644 index 0000000000..273186db32 --- /dev/null +++ b/apps/greenhouse/src/components/admin/Clusters/ClustersGrid/DataRows/index.tsx @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from "react" +import { + DataGridRow, + DataGridCell, + Icon, + PopupMenu, + PopupMenuOptions, + PopupMenuItem, +} from "@cloudoperators/juno-ui-components" +import { useSuspenseQuery } from "@tanstack/react-query" +import { useRouteContext, useSearch, useNavigate } from "@tanstack/react-router" +import { fetchClusters, FETCH_CLUSTERS_CACHE_KEY } from "../../../api/clusters/fetchClusters" +import { extractFilterSettingsFromSearchParams } from "../../../utils" +import { EmptyDataGridRow } from "../../../common/EmptyDataGridRow" +import { Cluster } from "../../../types/k8sTypes" +import { getReadyCondition, isReady } from "../../../utils" +import { + CLUSTER_TYPE_LABEL, + CONNECTIVITY_LABEL, + NO_VALUE_DEFAULT, + REGION_LABEL, + SUPPORT_GROUP_LABEL, +} from "../../../constants" + +interface DataRowsProps { + colSpan: number +} + +export const DataRows = ({ colSpan }: DataRowsProps) => { + const { apiClient, user } = useRouteContext({ from: "/admin/clusters/" }) + const search = useSearch({ from: "/admin/clusters/" }) + const navigate = useNavigate() + const filterSettings = extractFilterSettingsFromSearchParams(search) + + const { data: clusters } = useSuspenseQuery({ + queryKey: [FETCH_CLUSTERS_CACHE_KEY, user.organization, filterSettings], + queryFn: () => fetchClusters({ apiClient, namespace: user.organization, filterSettings }), + }) + + if (!clusters || clusters.length === 0) { + return No clusters found. + } + + const handleRowClick = (clusterName: string) => { + navigate({ + to: "/admin/clusters/$clusterName", + params: { clusterName: clusterName }, + }) + } + + return ( + <> + {clusters.map((preset: Cluster, index) => ( + handleRowClick(preset.metadata?.name || "")} + > + {/* Status */} + + + + {/* Name */} + {preset.metadata?.name || NO_VALUE_DEFAULT} + {/* Version */} + {preset.status?.kubernetesVersion || NO_VALUE_DEFAULT} + {/* Cluster Type */} + {preset.metadata?.labels?.[CLUSTER_TYPE_LABEL] || NO_VALUE_DEFAULT} + {/* Region */} + {preset.metadata?.labels?.[REGION_LABEL] || NO_VALUE_DEFAULT} + {/* Connectivity */} + {preset.metadata?.annotations?.[CONNECTIVITY_LABEL] || NO_VALUE_DEFAULT} + {/* Message */} + {getReadyCondition(preset)?.message || NO_VALUE_DEFAULT} + {/* Support Group */} + {preset.metadata?.labels?.[SUPPORT_GROUP_LABEL] || NO_VALUE_DEFAULT} + {/* Actions */} + + e.stopPropagation()}> + + handleRowClick(preset.metadata?.name || "")} /> + + + + + ))} + > + ) +} diff --git a/apps/greenhouse/src/components/admin/Clusters/ClustersGrid/index.test.tsx b/apps/greenhouse/src/components/admin/Clusters/ClustersGrid/index.test.tsx new file mode 100644 index 0000000000..72d720a87e --- /dev/null +++ b/apps/greenhouse/src/components/admin/Clusters/ClustersGrid/index.test.tsx @@ -0,0 +1,95 @@ +/* + * 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 { ClustersDataGrid } from "./index" +import { mockClusters, MockClusterResponse } from "../../__mocks__/clusters" + +const renderComponent = async (mockPromise: Promise) => { + const rootRoute = createRootRoute({ + component: () => , + }) + const testRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/admin/clusters/", + 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/clusters/"], + }), + }) + return await act(async () => render()) +} + +describe("ClustersDataGrid", () => { + it("should render plugin presets", async () => { + await renderComponent(new Promise((resolve) => resolve(mockClusters))) + + // Check for column headers + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getByText("Version")).toBeInTheDocument() + expect(screen.getByText("Cluster Type")).toBeInTheDocument() + expect(screen.getByText("Region")).toBeInTheDocument() + expect(screen.getByText("Connectivity")).toBeInTheDocument() + expect(screen.getByText("Message")).toBeInTheDocument() + expect(screen.getByText("Support Group")).toBeInTheDocument() + + // Check for data - verify all 5 presets are rendered + expect(screen.getByText("demo")).toBeInTheDocument() + expect(screen.getByText("demo-2")).toBeInTheDocument() + expect(screen.getByText("demo-3")).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/Clusters/ClustersGrid/index.tsx b/apps/greenhouse/src/components/admin/Clusters/ClustersGrid/index.tsx new file mode 100644 index 0000000000..c8ecc4192d --- /dev/null +++ b/apps/greenhouse/src/components/admin/Clusters/ClustersGrid/index.tsx @@ -0,0 +1,46 @@ +/* + * 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 { DataRows } from "./DataRows" +import { ErrorBoundary } from "../../common/ErrorBoundary" +import { LoadingDataRow } from "../../common/LoadingDataRow" +import { getErrorDataRowComponent } from "../../common/getErrorDataRow" +import { DataGrid, DataGridRow, DataGridHeadCell, Icon } from "@cloudoperators/juno-ui-components" + +const COLUMN_SPAN = 9 + +export const ClustersDataGrid = () => { + const { filterSettings } = useLoaderData({ from: "/admin/clusters/" }) + return ( + + + + + + Name + Version + Cluster Type + Region + Connectivity + Message + Support Group + + + + + }> + + + + + ) +} diff --git a/apps/greenhouse/src/components/admin/Clusters/index.tsx b/apps/greenhouse/src/components/admin/Clusters/index.tsx new file mode 100644 index 0000000000..7b703785ef --- /dev/null +++ b/apps/greenhouse/src/components/admin/Clusters/index.tsx @@ -0,0 +1,67 @@ +/* + * 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 } from "@cloudoperators/juno-ui-components" + +import { ClustersDataGrid } from "./ClustersGrid" +import { ClustersFilters } from "./ClustersFilters" +import { extractFilterSettingsFromSearchParams, isReady } from "../utils" +import { fetchClusters, FETCH_CLUSTERS_CACHE_KEY } from "../api/clusters/fetchClusters" + +export const Clusters = () => { + const [lastUpdatedAt, setLastUpdatedAt] = useState(Date.now()) + const isFetching = useIsFetching({ queryKey: [FETCH_CLUSTERS_CACHE_KEY] }) + const queryClient = useQueryClient() + const { apiClient, user } = useRouteContext({ from: "/admin/clusters/" }) + const search = useSearch({ from: "/admin/clusters/" }) + const filterSettings = extractFilterSettingsFromSearchParams(search) + + const { data: clusters } = useQuery({ + queryKey: [FETCH_CLUSTERS_CACHE_KEY, user.organization, filterSettings], + queryFn: () => fetchClusters({ apiClient, namespace: user.organization, filterSettings }), + }) + + const total = clusters?.length ?? 0 + const ready = clusters?.filter(isReady).length ?? 0 + const notReady = total - ready + + const handleRefresh = () => { + queryClient.invalidateQueries({ queryKey: [FETCH_CLUSTERS_CACHE_KEY] }) + setLastUpdatedAt(Date.now()) + } + + return ( + <> + + Clusters Overview + Manage and monitor Clusters + + + + + + {`${total} clusters`} + {`(${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__/clusters.ts b/apps/greenhouse/src/components/admin/__mocks__/clusters.ts new file mode 100644 index 0000000000..6c42046780 --- /dev/null +++ b/apps/greenhouse/src/components/admin/__mocks__/clusters.ts @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Cluster } from "../types/k8sTypes" + +export type MockClusterResponse = { + items: Cluster[] +} + +export const mockClusters: MockClusterResponse = { + items: [ + { + apiVersion: "greenhouse.sap/v1alpha1", + kind: "Cluster", + metadata: { + name: "demo", + namespace: "sci", + annotations: { + "greenhouse.sap/cluster-connectivity": "oidc", + "greenhouse.sap/last-applied-propagator": + '{"labelKeys":["cluster-type","shoot-grafter.cloudoperators.dev/careinstruction"]}', + }, + creationTimestamp: "2026-02-23T13:14:14Z", + labels: { + "cluster-type": "demo", + "greenhouse.sap/cluster": "demo", + "shoot-grafter.cloudoperators.dev/careinstruction": "garden-greenhouse", + }, + }, + spec: { + accessMode: "direct", + kubeConfig: { + maxTokenValidity: 72, + }, + }, + status: { + bearerTokenExpirationTimestamp: "2026-04-29T08:53:18Z", + kubernetesVersion: "v1.32.13", + nodes: { + ready: 2, + total: 2, + }, + statusConditions: { + conditions: [ + { + lastTransitionTime: "2026-04-22T17:04:33Z", + type: "Ready", + status: "True", + }, + ], + }, + }, + }, + { + apiVersion: "greenhouse.sap/v1alpha1", + kind: "Cluster", + metadata: { + name: "demo-2", + namespace: "sci", + annotations: { + "greenhouse.sap/cluster-connectivity": "oidc", + "greenhouse.sap/last-applied-propagator": + '{"labelKeys":["shoot-grafter.cloudoperators.dev/careinstruction"]}', + }, + creationTimestamp: "2026-02-23T13:14:15Z", + labels: { + "greenhouse.sap/cluster": "demo-2", + "shoot-grafter.cloudoperators.dev/careinstruction": "garden-greenhouse", + }, + }, + spec: { + accessMode: "direct", + kubeConfig: { + maxTokenValidity: 72, + }, + }, + status: { + bearerTokenExpirationTimestamp: "2026-04-29T08:43:18Z", + kubernetesVersion: "v1.33.10", + nodes: { + ready: 2, + total: 2, + }, + statusConditions: { + conditions: [ + { + lastTransitionTime: "2026-04-26T17:33:33Z", + type: "Ready", + status: "True", + }, + ], + }, + }, + }, + { + apiVersion: "greenhouse.sap/v1alpha1", + kind: "Cluster", + metadata: { + name: "demo-3", + namespace: "sci", + annotations: { + "greenhouse.sap/cluster-connectivity": "oidc", + "greenhouse.sap/last-applied-propagator": + '{"labelKeys":["greenhouse.sap/owned-by","metadata.greenhouse.sap/cluster-type","metadata.greenhouse.sap/region"]}', + }, + creationTimestamp: "2025-11-25T09:11:53Z", + labels: { + "greenhouse.sap/cluster": "obs-eu-de-1", + "greenhouse.sap/owned-by": "observability", + "greenhouse.sap/pluginpreset": "true", + "metadata.greenhouse.sap/cluster-type": "sci-k8s-obs", + "metadata.greenhouse.sap/region": "eu-de-1", + "migration-pending": "true", + }, + }, + spec: { + accessMode: "direct", + kubeConfig: { + maxTokenValidity: 72, + }, + }, + status: { + bearerTokenExpirationTimestamp: "2026-04-29T08:43:18Z", + kubernetesVersion: "v1.34.5", + nodes: { + ready: 4, + total: 4, + }, + statusConditions: { + conditions: [ + { + lastTransitionTime: "2026-04-26T04:49:19Z", + type: "Ready", + status: "True", + }, + ], + }, + }, + }, + ], +} diff --git a/apps/greenhouse/src/components/admin/__mocks__/plugins.ts b/apps/greenhouse/src/components/admin/__mocks__/plugins.ts index 000385377b..b8f5126ec3 100644 --- a/apps/greenhouse/src/components/admin/__mocks__/plugins.ts +++ b/apps/greenhouse/src/components/admin/__mocks__/plugins.ts @@ -17,6 +17,11 @@ export const mockPlugins: MockPluginsResponse = { labels: { "greenhouse.sap/pluginpreset": "test-preset", }, + ownerReferences: [ + { + name: "preset-1", + }, + ], }, spec: { clusterName: "cluster-1", @@ -42,6 +47,11 @@ export const mockPlugins: MockPluginsResponse = { labels: { "greenhouse.sap/pluginpreset": "test-preset", }, + ownerReferences: [ + { + name: "preset-1", + }, + ], }, spec: { clusterName: "cluster-2", diff --git a/apps/greenhouse/src/components/admin/api/clusters/fetchCluster.ts b/apps/greenhouse/src/components/admin/api/clusters/fetchCluster.ts new file mode 100644 index 0000000000..14ab80329e --- /dev/null +++ b/apps/greenhouse/src/components/admin/api/clusters/fetchCluster.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Cluster } from "../../types/k8sTypes" + +export const FETCH_CLUSTER_CACHE_KEY = "cluster" + +export const fetchCluster = async ({ + apiClient, + namespace, + clusterName, +}: { + apiClient: any + namespace: string + clusterName: string +}): Promise => apiClient.get(`/apis/greenhouse.sap/v1alpha1/namespaces/${namespace}/clusters/${clusterName}`) diff --git a/apps/greenhouse/src/components/admin/api/clusters/fetchClusters.ts b/apps/greenhouse/src/components/admin/api/clusters/fetchClusters.ts new file mode 100644 index 0000000000..86696ce2ed --- /dev/null +++ b/apps/greenhouse/src/components/admin/api/clusters/fetchClusters.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isReady } from "../../utils" +import { FilterSettings } from "../../common/types" +import { Cluster } from "../../types/k8sTypes" +import { CLUSTER_TYPE_LABEL, FILTER_IDS, REGION_LABEL, SUPPORT_GROUP_LABEL } from "../../constants" + +const applyFilterSettings = (clusters: Cluster[], filterSettings?: FilterSettings): Cluster[] => { + if (filterSettings?.selectedFilters) { + // filter by cluster type + const clusterTypeValues = filterSettings.selectedFilters + .filter((f) => f.id === FILTER_IDS.CLUSTER_TYPE) + .map((f) => f.value) + if (clusterTypeValues.length > 0) { + clusters = clusters.filter((preset) => { + const clusterType = preset.metadata?.labels?.[CLUSTER_TYPE_LABEL] + return clusterType && clusterTypeValues.includes(clusterType) + }) + } + + // filter by region + const regionValues = filterSettings.selectedFilters.filter((f) => f.id === FILTER_IDS.REGION).map((f) => f.value) + if (regionValues.length > 0) { + clusters = clusters.filter((preset) => { + const region = preset.metadata?.labels?.[REGION_LABEL] + return region && regionValues.includes(region) + }) + } + + // filter by support group + const supportGroupValues = filterSettings.selectedFilters + .filter((f) => f.id === FILTER_IDS.SUPPORT_GROUP) + .map((f) => f.value) + if (supportGroupValues.length > 0) { + clusters = clusters.filter((preset) => { + const supportGroup = preset.metadata?.labels?.[SUPPORT_GROUP_LABEL] + return supportGroup && supportGroupValues.includes(supportGroup) + }) + } + } + + // filter by search term + if (filterSettings?.searchTerm) { + const searchTerm = filterSettings.searchTerm.toLowerCase() + return clusters.filter((preset) => { + const presetName = preset.metadata?.name?.toLowerCase() || "" + return presetName.includes(searchTerm) + }) + } + + return clusters +} + +const applySorting = (clusters: Cluster[]): Cluster[] => { + return clusters.sort((a, b) => { + // First, sort by ready status (non-ready clusters first) + const aReady = isReady(a) + const bReady = isReady(b) + + if (aReady !== bReady) { + return aReady ? 1 : -1 + } + + // Then, sort alphabetically by name + const aName = a.metadata?.name?.toLowerCase() || "" + const bName = b.metadata?.name?.toLowerCase() || "" + + if (aName < bName) return -1 + if (aName > bName) return 1 + return 0 + }) +} + +export const FETCH_CLUSTERS_CACHE_KEY = "clusters" + +export const fetchClusters = async ({ + apiClient, + namespace, + filterSettings, +}: { + apiClient: any + namespace: string + filterSettings?: FilterSettings +}): Promise => { + const response = await apiClient.get(`/apis/greenhouse.sap/v1alpha1/namespaces/${namespace}/clusters`) + return Array.isArray(response?.items) ? applySorting(applyFilterSettings(response.items, filterSettings)) : [] +} diff --git a/apps/greenhouse/src/components/admin/api/clusters/fetchClustersFilters.ts b/apps/greenhouse/src/components/admin/api/clusters/fetchClustersFilters.ts new file mode 100644 index 0000000000..c0a0f78388 --- /dev/null +++ b/apps/greenhouse/src/components/admin/api/clusters/fetchClustersFilters.ts @@ -0,0 +1,68 @@ +/* + * 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 { Cluster } from "../../types/k8sTypes" +import { CLUSTER_TYPE_LABEL, FILTER_IDS, REGION_LABEL, SUPPORT_GROUP_LABEL } from "../../constants" + +const getClusterTypeValues = (presets: Cluster[]) => + Array.from( + new Set( + presets.map((preset) => { + return preset.metadata?.labels?.[CLUSTER_TYPE_LABEL] + }) + ) + ).filter((value): value is string => !!value) + +const getRegionValues = (presets: Cluster[]) => + Array.from( + new Set( + presets.map((preset) => { + return preset.metadata?.labels?.[REGION_LABEL] + }) + ) + ).filter((value): value is string => !!value) + +const getSupportGroupValues = (presets: Cluster[]) => + Array.from( + new Set( + presets.map((preset) => { + return preset.metadata?.labels?.[SUPPORT_GROUP_LABEL] + }) + ) + ).filter((value): value is string => !!value) + +const extractClusterFilters = (clusters: Cluster[]) => { + return [ + { + id: FILTER_IDS.CLUSTER_TYPE, + label: "Cluster Type", + values: getClusterTypeValues(clusters), + }, + { + id: FILTER_IDS.REGION, + label: "Region", + values: getRegionValues(clusters), + }, + { + id: FILTER_IDS.SUPPORT_GROUP, + label: "Support Group", + values: getSupportGroupValues(clusters), + }, + ] +} + +export const FETCH_CLUSTERS_FILTERS_CACHE_KEY = "clustersFilters" + +export const fetchClustersFilters = async ({ + apiClient, + namespace, +}: { + apiClient: any + namespace: string +}): Promise => { + const response = await apiClient.get(`/apis/greenhouse.sap/v1alpha1/namespaces/${namespace}/clusters`) + return Array.isArray(response?.items) ? extractClusterFilters(response.items) : [] +} diff --git a/apps/greenhouse/src/components/admin/api/plugins/fetchPluginsByCluster.ts b/apps/greenhouse/src/components/admin/api/plugins/fetchPluginsByCluster.ts new file mode 100644 index 0000000000..1163005a43 --- /dev/null +++ b/apps/greenhouse/src/components/admin/api/plugins/fetchPluginsByCluster.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Plugin } from "../../types/k8sTypes" + +export const FETCH_PLUGINS_BY_CLUSTER_CACHE_KEY = "pluginsByCluster" + +export const fetchPluginsByCluster = async ({ + apiClient, + namespace, + clusterName, +}: { + apiClient: any + namespace: string + clusterName: string +}): Promise => { + const encodedClusterLabel = `greenhouse.sap/cluster=${encodeURIComponent(clusterName)}` + const response = await apiClient.get(`/apis/greenhouse.sap/v1alpha1/namespaces/${namespace}/plugins`, { + params: { + labelSelector: encodedClusterLabel, + }, + }) + return Array.isArray(response?.items) ? response.items : [] +} diff --git a/apps/greenhouse/src/components/admin/constants.ts b/apps/greenhouse/src/components/admin/constants.ts index 2ef5492291..105d506e22 100644 --- a/apps/greenhouse/src/components/admin/constants.ts +++ b/apps/greenhouse/src/components/admin/constants.ts @@ -1,13 +1,20 @@ /* - * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors * SPDX-License-Identifier: Apache-2.0 */ export const SELECTED_FILTER_PREFIX = "f_" +export const NO_VALUE_DEFAULT = "--" + export const SUPPORT_GROUP_LABEL = "greenhouse.sap/owned-by" +export const REGION_LABEL = "metadata.greenhouse.sap/region" +export const CONNECTIVITY_LABEL = "greenhouse.sap/cluster-connectivity" +export const CLUSTER_TYPE_LABEL = "metadata.greenhouse.sap/cluster-type" export const FILTER_IDS = { + CLUSTER_TYPE: "clusterType", PLUGIN_PRESET_DEFINITION: "pluginPresetDefinition", + REGION: "region", 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..263f9cce8c 100644 --- a/apps/greenhouse/src/components/admin/types/k8sTypes.ts +++ b/apps/greenhouse/src/components/admin/types/k8sTypes.ts @@ -5,7 +5,8 @@ import type { components } from "./schema" -export type PluginPreset = components["schemas"]["PluginPreset"] +export type Cluster = components["schemas"]["Cluster"] export type Plugin = components["schemas"]["Plugin"] +export type PluginPreset = components["schemas"]["PluginPreset"] 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..bce39643d3 100644 --- a/apps/greenhouse/src/components/admin/types/schema.d.ts +++ b/apps/greenhouse/src/components/admin/types/schema.d.ts @@ -386,15 +386,15 @@ export interface components { * In CamelCase. * More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string - metadata?: { - name?: string + kind: string + metadata: { + name: string namespace?: string /** Format: uuid */ uid?: string resourceVersion?: string /** Format: date-time */ - creationTimestamp?: string + creationTimestamp: string /** Format: date-time */ deletionTimestamp?: string labels?: { @@ -403,6 +403,17 @@ export interface components { annotations?: { [key: string]: string } + finalizers?: string[] + managedFields?: { + apiVersion: string + fieldsType: string + fieldsV1?: object + manager: string + operation: string + time?: string + subresource?: string + }[] + generation?: number } /** @description ClusterSpec defines the desired state of the Cluster. */ spec?: { @@ -1036,6 +1047,12 @@ export interface components { annotations?: { [key: string]: string } + ownerReferences?: Array<{ + name: string + uid?: string + apiVersion?: string + kind?: string + }> } /** @description PluginSpec defines the desired state of Plugin */ spec?: { diff --git a/apps/greenhouse/src/components/admin/utils.ts b/apps/greenhouse/src/components/admin/utils.ts index cf751da413..b3f100ab7a 100644 --- a/apps/greenhouse/src/components/admin/utils.ts +++ b/apps/greenhouse/src/components/admin/utils.ts @@ -1,20 +1,20 @@ /* - * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors * SPDX-License-Identifier: Apache-2.0 */ import { PluginPresetSearchParams } from "../../routes/admin/plugin-presets" import { FilterSettings } from "./common/types" import { SELECTED_FILTER_PREFIX } from "./constants" -import { PluginPreset } from "./types/k8sTypes" +import { Cluster, PluginPreset } from "./types/k8sTypes" // Get the "Ready" condition from a PluginPreset -export const getReadyCondition = (preset: PluginPreset) => { +export const getReadyCondition = (preset: PluginPreset | Cluster) => { return preset.status?.statusConditions?.conditions?.find((condition) => condition.type === "Ready") } // Check if a PluginPreset is ready -export const isReady = (preset: PluginPreset) => { +export const isReady = (preset: PluginPreset | Cluster) => { const readyCondition = getReadyCondition(preset) return readyCondition?.type === "Ready" && readyCondition?.status === "True" } @@ -94,3 +94,27 @@ export const getFiltersForUrl = (filterSettings: FilterSettings) => { return result } + +export const formatAge = (jsDateAllowedInput: string | number | Date): string => { + const now = new Date() + const inputDate = new Date(jsDateAllowedInput) + const diffMs = Math.abs(now.getTime() - inputDate.getTime()) + + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) + + const parts: string[] = [] + + if (days > 0) { + parts.push(`${days} ${days === 1 ? "day" : "days"}`) + } + if (hours > 0) { + parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`) + } + if (minutes > 0) { + parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`) + } + + return parts.length > 0 ? parts.join(", ") : "0 minutes" +} diff --git a/apps/greenhouse/src/routeTree.gen.ts b/apps/greenhouse/src/routeTree.gen.ts index 10b2553b7c..90d9079176 100644 --- a/apps/greenhouse/src/routeTree.gen.ts +++ b/apps/greenhouse/src/routeTree.gen.ts @@ -13,12 +13,15 @@ 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 AdminClustersRouteImport } from './routes/admin/clusters' import { Route as ExtensionIdSplatRouteImport } from './routes/$extensionId.$' import { Route as AdminPluginPresetsRouteRouteImport } from './routes/admin/plugin-presets/route' +import { Route as AdminClustersRouteRouteImport } from './routes/admin/clusters/route' import { Route as AdminPluginPresetsIndexRouteImport } from './routes/admin/plugin-presets/index' +import { Route as AdminClustersIndexRouteImport } from './routes/admin/clusters/index' import { Route as AdminPluginPresetsPluginPresetNameRouteRouteImport } from './routes/admin/plugin-presets/$pluginPresetName/route' +import { Route as AdminClustersClusterNameRouteRouteImport } from './routes/admin/clusters/$clusterName/route' import { Route as AdminPluginPresetsPluginPresetNameIndexRouteImport } from './routes/admin/plugin-presets/$pluginPresetName/index' +import { Route as AdminClustersClusterNameIndexRouteImport } from './routes/admin/clusters/$clusterName/index' import { Route as AdminPluginPresetsPluginPresetNamePluginInstancesRouteRouteImport } from './routes/admin/plugin-presets/$pluginPresetName/plugin-instances/route' import { Route as AdminPluginPresetsPluginPresetNamePluginInstancesPluginInstanceRouteImport } from './routes/admin/plugin-presets/$pluginPresetName/plugin-instances/$pluginInstance' @@ -42,11 +45,6 @@ const AdminIndexRoute = AdminIndexRouteImport.update({ path: '/', getParentRoute: () => AdminRouteRoute, } as any) -const AdminClustersRoute = AdminClustersRouteImport.update({ - id: '/clusters', - path: '/clusters', - getParentRoute: () => AdminRouteRoute, -} as any) const ExtensionIdSplatRoute = ExtensionIdSplatRouteImport.update({ id: '/$extensionId/$', path: '/$extensionId/$', @@ -57,23 +55,45 @@ const AdminPluginPresetsRouteRoute = AdminPluginPresetsRouteRouteImport.update({ path: '/plugin-presets', getParentRoute: () => AdminRouteRoute, } as any) +const AdminClustersRouteRoute = AdminClustersRouteRouteImport.update({ + id: '/clusters', + path: '/clusters', + getParentRoute: () => AdminRouteRoute, +} as any) const AdminPluginPresetsIndexRoute = AdminPluginPresetsIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => AdminPluginPresetsRouteRoute, } as any) +const AdminClustersIndexRoute = AdminClustersIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminClustersRouteRoute, +} as any) const AdminPluginPresetsPluginPresetNameRouteRoute = AdminPluginPresetsPluginPresetNameRouteRouteImport.update({ id: '/$pluginPresetName', path: '/$pluginPresetName', getParentRoute: () => AdminPluginPresetsRouteRoute, } as any) +const AdminClustersClusterNameRouteRoute = + AdminClustersClusterNameRouteRouteImport.update({ + id: '/$clusterName', + path: '/$clusterName', + getParentRoute: () => AdminClustersRouteRoute, + } as any) const AdminPluginPresetsPluginPresetNameIndexRoute = AdminPluginPresetsPluginPresetNameIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => AdminPluginPresetsPluginPresetNameRouteRoute, } as any) +const AdminClustersClusterNameIndexRoute = + AdminClustersClusterNameIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AdminClustersClusterNameRouteRoute, + } as any) const AdminPluginPresetsPluginPresetNamePluginInstancesRouteRoute = AdminPluginPresetsPluginPresetNamePluginInstancesRouteRouteImport.update({ id: '/plugin-instances', @@ -94,13 +114,16 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/admin': typeof AdminRouteRouteWithChildren '/org-admin': typeof OrgAdminRoute + '/admin/clusters': typeof AdminClustersRouteRouteWithChildren '/admin/plugin-presets': typeof AdminPluginPresetsRouteRouteWithChildren '/$extensionId/$': typeof ExtensionIdSplatRoute - '/admin/clusters': typeof AdminClustersRoute '/admin/': typeof AdminIndexRoute + '/admin/clusters/$clusterName': typeof AdminClustersClusterNameRouteRouteWithChildren '/admin/plugin-presets/$pluginPresetName': typeof AdminPluginPresetsPluginPresetNameRouteRouteWithChildren + '/admin/clusters/': typeof AdminClustersIndexRoute '/admin/plugin-presets/': typeof AdminPluginPresetsIndexRoute '/admin/plugin-presets/$pluginPresetName/plugin-instances': typeof AdminPluginPresetsPluginPresetNamePluginInstancesRouteRouteWithChildren + '/admin/clusters/$clusterName/': typeof AdminClustersClusterNameIndexRoute '/admin/plugin-presets/$pluginPresetName/': typeof AdminPluginPresetsPluginPresetNameIndexRoute '/admin/plugin-presets/$pluginPresetName/plugin-instances/$pluginInstance': typeof AdminPluginPresetsPluginPresetNamePluginInstancesPluginInstanceRoute } @@ -108,10 +131,11 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/org-admin': typeof OrgAdminRoute '/$extensionId/$': typeof ExtensionIdSplatRoute - '/admin/clusters': typeof AdminClustersRoute '/admin': typeof AdminIndexRoute + '/admin/clusters': typeof AdminClustersIndexRoute '/admin/plugin-presets': typeof AdminPluginPresetsIndexRoute '/admin/plugin-presets/$pluginPresetName/plugin-instances': typeof AdminPluginPresetsPluginPresetNamePluginInstancesRouteRouteWithChildren + '/admin/clusters/$clusterName': typeof AdminClustersClusterNameIndexRoute '/admin/plugin-presets/$pluginPresetName': typeof AdminPluginPresetsPluginPresetNameIndexRoute '/admin/plugin-presets/$pluginPresetName/plugin-instances/$pluginInstance': typeof AdminPluginPresetsPluginPresetNamePluginInstancesPluginInstanceRoute } @@ -120,13 +144,16 @@ export interface FileRoutesById { '/': typeof IndexRoute '/admin': typeof AdminRouteRouteWithChildren '/org-admin': typeof OrgAdminRoute + '/admin/clusters': typeof AdminClustersRouteRouteWithChildren '/admin/plugin-presets': typeof AdminPluginPresetsRouteRouteWithChildren '/$extensionId/$': typeof ExtensionIdSplatRoute - '/admin/clusters': typeof AdminClustersRoute '/admin/': typeof AdminIndexRoute + '/admin/clusters/$clusterName': typeof AdminClustersClusterNameRouteRouteWithChildren '/admin/plugin-presets/$pluginPresetName': typeof AdminPluginPresetsPluginPresetNameRouteRouteWithChildren + '/admin/clusters/': typeof AdminClustersIndexRoute '/admin/plugin-presets/': typeof AdminPluginPresetsIndexRoute '/admin/plugin-presets/$pluginPresetName/plugin-instances': typeof AdminPluginPresetsPluginPresetNamePluginInstancesRouteRouteWithChildren + '/admin/clusters/$clusterName/': typeof AdminClustersClusterNameIndexRoute '/admin/plugin-presets/$pluginPresetName/': typeof AdminPluginPresetsPluginPresetNameIndexRoute '/admin/plugin-presets/$pluginPresetName/plugin-instances/$pluginInstance': typeof AdminPluginPresetsPluginPresetNamePluginInstancesPluginInstanceRoute } @@ -136,13 +163,16 @@ export interface FileRouteTypes { | '/' | '/admin' | '/org-admin' + | '/admin/clusters' | '/admin/plugin-presets' | '/$extensionId/$' - | '/admin/clusters' | '/admin/' + | '/admin/clusters/$clusterName' | '/admin/plugin-presets/$pluginPresetName' + | '/admin/clusters/' | '/admin/plugin-presets/' | '/admin/plugin-presets/$pluginPresetName/plugin-instances' + | '/admin/clusters/$clusterName/' | '/admin/plugin-presets/$pluginPresetName/' | '/admin/plugin-presets/$pluginPresetName/plugin-instances/$pluginInstance' fileRoutesByTo: FileRoutesByTo @@ -150,10 +180,11 @@ export interface FileRouteTypes { | '/' | '/org-admin' | '/$extensionId/$' - | '/admin/clusters' | '/admin' + | '/admin/clusters' | '/admin/plugin-presets' | '/admin/plugin-presets/$pluginPresetName/plugin-instances' + | '/admin/clusters/$clusterName' | '/admin/plugin-presets/$pluginPresetName' | '/admin/plugin-presets/$pluginPresetName/plugin-instances/$pluginInstance' id: @@ -161,13 +192,16 @@ export interface FileRouteTypes { | '/' | '/admin' | '/org-admin' + | '/admin/clusters' | '/admin/plugin-presets' | '/$extensionId/$' - | '/admin/clusters' | '/admin/' + | '/admin/clusters/$clusterName' | '/admin/plugin-presets/$pluginPresetName' + | '/admin/clusters/' | '/admin/plugin-presets/' | '/admin/plugin-presets/$pluginPresetName/plugin-instances' + | '/admin/clusters/$clusterName/' | '/admin/plugin-presets/$pluginPresetName/' | '/admin/plugin-presets/$pluginPresetName/plugin-instances/$pluginInstance' fileRoutesById: FileRoutesById @@ -209,13 +243,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminIndexRouteImport parentRoute: typeof AdminRouteRoute } - '/admin/clusters': { - id: '/admin/clusters' - path: '/clusters' - fullPath: '/admin/clusters' - preLoaderRoute: typeof AdminClustersRouteImport - parentRoute: typeof AdminRouteRoute - } '/$extensionId/$': { id: '/$extensionId/$' path: '/$extensionId/$' @@ -230,6 +257,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminPluginPresetsRouteRouteImport parentRoute: typeof AdminRouteRoute } + '/admin/clusters': { + id: '/admin/clusters' + path: '/clusters' + fullPath: '/admin/clusters' + preLoaderRoute: typeof AdminClustersRouteRouteImport + parentRoute: typeof AdminRouteRoute + } '/admin/plugin-presets/': { id: '/admin/plugin-presets/' path: '/' @@ -237,6 +271,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminPluginPresetsIndexRouteImport parentRoute: typeof AdminPluginPresetsRouteRoute } + '/admin/clusters/': { + id: '/admin/clusters/' + path: '/' + fullPath: '/admin/clusters/' + preLoaderRoute: typeof AdminClustersIndexRouteImport + parentRoute: typeof AdminClustersRouteRoute + } '/admin/plugin-presets/$pluginPresetName': { id: '/admin/plugin-presets/$pluginPresetName' path: '/$pluginPresetName' @@ -244,6 +285,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminPluginPresetsPluginPresetNameRouteRouteImport parentRoute: typeof AdminPluginPresetsRouteRoute } + '/admin/clusters/$clusterName': { + id: '/admin/clusters/$clusterName' + path: '/$clusterName' + fullPath: '/admin/clusters/$clusterName' + preLoaderRoute: typeof AdminClustersClusterNameRouteRouteImport + parentRoute: typeof AdminClustersRouteRoute + } '/admin/plugin-presets/$pluginPresetName/': { id: '/admin/plugin-presets/$pluginPresetName/' path: '/' @@ -251,6 +299,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminPluginPresetsPluginPresetNameIndexRouteImport parentRoute: typeof AdminPluginPresetsPluginPresetNameRouteRoute } + '/admin/clusters/$clusterName/': { + id: '/admin/clusters/$clusterName/' + path: '/' + fullPath: '/admin/clusters/$clusterName/' + preLoaderRoute: typeof AdminClustersClusterNameIndexRouteImport + parentRoute: typeof AdminClustersClusterNameRouteRoute + } '/admin/plugin-presets/$pluginPresetName/plugin-instances': { id: '/admin/plugin-presets/$pluginPresetName/plugin-instances' path: '/plugin-instances' @@ -268,6 +323,34 @@ declare module '@tanstack/react-router' { } } +interface AdminClustersClusterNameRouteRouteChildren { + AdminClustersClusterNameIndexRoute: typeof AdminClustersClusterNameIndexRoute +} + +const AdminClustersClusterNameRouteRouteChildren: AdminClustersClusterNameRouteRouteChildren = + { + AdminClustersClusterNameIndexRoute: AdminClustersClusterNameIndexRoute, + } + +const AdminClustersClusterNameRouteRouteWithChildren = + AdminClustersClusterNameRouteRoute._addFileChildren( + AdminClustersClusterNameRouteRouteChildren, + ) + +interface AdminClustersRouteRouteChildren { + AdminClustersClusterNameRouteRoute: typeof AdminClustersClusterNameRouteRouteWithChildren + AdminClustersIndexRoute: typeof AdminClustersIndexRoute +} + +const AdminClustersRouteRouteChildren: AdminClustersRouteRouteChildren = { + AdminClustersClusterNameRouteRoute: + AdminClustersClusterNameRouteRouteWithChildren, + AdminClustersIndexRoute: AdminClustersIndexRoute, +} + +const AdminClustersRouteRouteWithChildren = + AdminClustersRouteRoute._addFileChildren(AdminClustersRouteRouteChildren) + interface AdminPluginPresetsPluginPresetNamePluginInstancesRouteRouteChildren { AdminPluginPresetsPluginPresetNamePluginInstancesPluginInstanceRoute: typeof AdminPluginPresetsPluginPresetNamePluginInstancesPluginInstanceRoute } @@ -319,14 +402,14 @@ const AdminPluginPresetsRouteRouteWithChildren = ) interface AdminRouteRouteChildren { + AdminClustersRouteRoute: typeof AdminClustersRouteRouteWithChildren AdminPluginPresetsRouteRoute: typeof AdminPluginPresetsRouteRouteWithChildren - AdminClustersRoute: typeof AdminClustersRoute AdminIndexRoute: typeof AdminIndexRoute } const AdminRouteRouteChildren: AdminRouteRouteChildren = { + AdminClustersRouteRoute: AdminClustersRouteRouteWithChildren, AdminPluginPresetsRouteRoute: AdminPluginPresetsRouteRouteWithChildren, - AdminClustersRoute: AdminClustersRoute, AdminIndexRoute: AdminIndexRoute, } diff --git a/apps/greenhouse/src/routes/admin/clusters.tsx b/apps/greenhouse/src/routes/admin/clusters.tsx deleted file mode 100644 index 4128c33fed..0000000000 --- a/apps/greenhouse/src/routes/admin/clusters.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useEffect } from "react" -import { createFileRoute, useRouteContext } from "@tanstack/react-router" -import { Container, ContentHeading, Stack } from "@cloudoperators/juno-ui-components" -import { useAuth } from "../../components/AuthProvider" -import { useGlobalsApiEndpoint } from "../../components/StoreProvider" -import { ClustersAppWithoutShadowDOM } from "../../components/core-apps/org-admin/components/clusters/App" -import { useActions } from "@cloudoperators/juno-messages-provider" - -export const Route = createFileRoute("/admin/clusters")({ - component: RouteComponent, - loader: () => ({ - crumb: { - label: "Clusters", - icon: "home", - }, - }), -}) - -function RouteComponent() { - const { data: authData } = useAuth() - const { addMessage, removeMessage } = useActions() - const apiEndpoint = useGlobalsApiEndpoint() - const { user } = useRouteContext({ from: "/admin/clusters" }) - const token = authData?.JWT - const namespace = user.organization - - useEffect(() => { - const messageId = addMessage({ - variant: "warning", - text: "This view is from the legacy interface and will be updated with a modern design and improved functionality.", - dismissible: false, - }) - return () => { - if (messageId) { - removeMessage(messageId) - } - } - }, [addMessage, removeMessage]) - - return ( - - - Clusters Overview - Manage and monitor clusters - - {/* @ts-expect-error TS(2339): Property 'data' does not exist on type 'unknown'. */} - - - ) -} diff --git a/apps/greenhouse/src/routes/admin/clusters/$clusterName/index.tsx b/apps/greenhouse/src/routes/admin/clusters/$clusterName/index.tsx new file mode 100644 index 0000000000..ab990b6077 --- /dev/null +++ b/apps/greenhouse/src/routes/admin/clusters/$clusterName/index.tsx @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createFileRoute } from "@tanstack/react-router" +import { ClusterDetail } from "../../../../components/admin/ClusterDetail" + +export const Route = createFileRoute("/admin/clusters/$clusterName/")({ + component: ClusterDetail, +}) diff --git a/apps/greenhouse/src/routes/admin/clusters/$clusterName/route.tsx b/apps/greenhouse/src/routes/admin/clusters/$clusterName/route.tsx new file mode 100644 index 0000000000..490819fcb3 --- /dev/null +++ b/apps/greenhouse/src/routes/admin/clusters/$clusterName/route.tsx @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createFileRoute, Outlet } from "@tanstack/react-router" + +export const Route = createFileRoute("/admin/clusters/$clusterName")({ + loader: ({ params }) => ({ + crumb: { + label: params.clusterName, + }, + }), + component: Outlet, +}) diff --git a/apps/greenhouse/src/routes/admin/clusters/index.tsx b/apps/greenhouse/src/routes/admin/clusters/index.tsx new file mode 100644 index 0000000000..92877b0d29 --- /dev/null +++ b/apps/greenhouse/src/routes/admin/clusters/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 { z } from "zod" +import { createFileRoute, redirect } from "@tanstack/react-router" + +import { User } from "../../__root" +import { Clusters } from "../../../components/admin/Clusters" +import { filterSearchParamsByPrefix } from "../../../lib/helpers" +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 + +const filterValueSchema = z.union([z.string(), z.array(z.string()), z.undefined()]) + +const searchParamsSchema = z + .object({ + searchTerm: z.string().optional(), + }) + .catchall(filterValueSchema) + +export type ClusterSearchParams = z.infer + +function validateClustersSearch(search: Record): ClusterSearchParams { + const filtered = filterSearchParamsByPrefix(search, Object.keys(searchParamsSchema.shape), [SELECTED_FILTER_PREFIX]) + return searchParamsSchema.parse(filtered) +} + +const getDefaultFilters = (user: User) => { + const defaultSupportGroupFilters = user.supportGroups.map((sg) => ({ + id: FILTER_IDS.SUPPORT_GROUP, + value: sg, + })) + return defaultSupportGroupFilters +} + +export const Route = createFileRoute("/admin/clusters/")({ + component: Clusters, + validateSearch: validateClustersSearch, + 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/clusters", + search: getFiltersForUrl({ selectedFilters: defaultFilters }), + replace: true, + }) + } + }, + loaderDeps: (search) => ({ + ...search, + }), + loader: ({ deps: { search } }) => ({ + filterSettings: extractFilterSettingsFromSearchParams(search), + }), +}) diff --git a/apps/greenhouse/src/routes/admin/clusters/route.tsx b/apps/greenhouse/src/routes/admin/clusters/route.tsx new file mode 100644 index 0000000000..30f7c4e253 --- /dev/null +++ b/apps/greenhouse/src/routes/admin/clusters/route.tsx @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and Juno contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createFileRoute, Outlet } from "@tanstack/react-router" + +export const Route = createFileRoute("/admin/clusters")({ + loader: () => ({ + crumb: { + label: "Clusters", + icon: "home", + }, + }), + component: Outlet, +})
Cluster configuration and instance status
Loading...
Manage and monitor Clusters
Manage and monitor clusters