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], + }) + } + }} + /> + +