diff --git a/frontend/src/components/crd/CustomResourceDefinition.stories.tsx b/frontend/src/components/crd/CustomResourceDefinition.stories.tsx index 755fb5b2532..6aa7ec1df1c 100644 --- a/frontend/src/components/crd/CustomResourceDefinition.stories.tsx +++ b/frontend/src/components/crd/CustomResourceDefinition.stories.tsx @@ -39,6 +39,10 @@ export default { 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions', () => HttpResponse.error() ), + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/mydefinition.phonyresources.io', + () => HttpResponse.error() + ), http.get( 'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions', () => diff --git a/frontend/src/components/crd/CustomResourceDetails.stories.tsx b/frontend/src/components/crd/CustomResourceDetails.stories.tsx index 65abdffb813..25342bcd1a5 100644 --- a/frontend/src/components/crd/CustomResourceDetails.stories.tsx +++ b/frontend/src/components/crd/CustomResourceDetails.stories.tsx @@ -32,6 +32,18 @@ export default { 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions', () => HttpResponse.error() ), + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/mydefinition.phonyresources.io', + () => HttpResponse.error() + ), + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/error.crd.io', + () => HttpResponse.error() + ), + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/loadingcrd', + () => HttpResponse.error() + ), http.get( 'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions', () => HttpResponse.json({}) diff --git a/frontend/src/components/crd/CustomResourceList.stories.tsx b/frontend/src/components/crd/CustomResourceList.stories.tsx index ace231046aa..ae50273e12d 100644 --- a/frontend/src/components/crd/CustomResourceList.stories.tsx +++ b/frontend/src/components/crd/CustomResourceList.stories.tsx @@ -46,6 +46,10 @@ export default { 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions', () => HttpResponse.error() ), + http.get( + 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/mydefinition.phonyresources.io', + () => HttpResponse.error() + ), http.get( 'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions', () => diff --git a/frontend/src/components/gateway/ClassDetails.stories.tsx b/frontend/src/components/gateway/ClassDetails.stories.tsx index 36028821822..cdbabe58d9b 100644 --- a/frontend/src/components/gateway/ClassDetails.stories.tsx +++ b/frontend/src/components/gateway/ClassDetails.stories.tsx @@ -46,7 +46,7 @@ export default { () => HttpResponse.error() ), http.get( - 'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/gatewayclasses', + 'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/gatewayclasses/default-gateway-class', () => HttpResponse.error() ), http.get('http://localhost:4466/api/v1/namespaces/default/events', () => diff --git a/frontend/src/components/gateway/GRPCRouteDetails.stories.tsx b/frontend/src/components/gateway/GRPCRouteDetails.stories.tsx index 5aaf4479243..d94f9c4f540 100644 --- a/frontend/src/components/gateway/GRPCRouteDetails.stories.tsx +++ b/frontend/src/components/gateway/GRPCRouteDetails.stories.tsx @@ -44,6 +44,10 @@ export default { http.get('http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/grpcroutes', () => HttpResponse.error() ), + http.get( + 'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/grpcroutes/default-grpcroute', + () => HttpResponse.error() + ), http.get('http://localhost:4466/apis/gateway.networking.k8s.io/v1/grpcroutes', () => HttpResponse.error() ), diff --git a/frontend/src/components/gateway/GatewayDetails.stories.tsx b/frontend/src/components/gateway/GatewayDetails.stories.tsx index e87121c1c73..9ef3330eb2c 100644 --- a/frontend/src/components/gateway/GatewayDetails.stories.tsx +++ b/frontend/src/components/gateway/GatewayDetails.stories.tsx @@ -44,6 +44,10 @@ export default { http.get('http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/gateways', () => HttpResponse.error() ), + http.get( + 'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/gateways/default-gateway', + () => HttpResponse.error() + ), http.get('http://localhost:4466/api/v1/namespaces/default/events', () => HttpResponse.json({ kind: 'EventList', diff --git a/frontend/src/components/gateway/HTTPRouteDetails.stories.tsx b/frontend/src/components/gateway/HTTPRouteDetails.stories.tsx index a43045329db..10adc1db0a3 100644 --- a/frontend/src/components/gateway/HTTPRouteDetails.stories.tsx +++ b/frontend/src/components/gateway/HTTPRouteDetails.stories.tsx @@ -44,6 +44,10 @@ export default { http.get('http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/httproutes', () => HttpResponse.error() ), + http.get( + 'http://localhost:4466/apis/gateway.networking.k8s.io/v1beta1/httproutes/default-httproute', + () => HttpResponse.error() + ), http.get('http://localhost:4466/api/v1/namespaces/default/events', () => HttpResponse.json({ kind: 'EventList', diff --git a/frontend/src/components/ingress/Details.stories.tsx b/frontend/src/components/ingress/Details.stories.tsx index 7c45029b117..69a6fecb7d7 100644 --- a/frontend/src/components/ingress/Details.stories.tsx +++ b/frontend/src/components/ingress/Details.stories.tsx @@ -43,6 +43,9 @@ export default { http.get('http://localhost:4466/apis/extensions/v1beta1/ingresses', () => HttpResponse.error() ), + http.get('http://localhost:4466/apis/extensions/v1beta1/ingresses/my-ingress', () => + HttpResponse.error() + ), http.get('http://localhost:4466/api/v1/namespaces/default/events', () => HttpResponse.json({ kind: 'EventList', diff --git a/frontend/src/lib/k8s/api/v2/hooks.test.tsx b/frontend/src/lib/k8s/api/v2/hooks.test.tsx new file mode 100644 index 00000000000..17f99a79493 --- /dev/null +++ b/frontend/src/lib/k8s/api/v2/hooks.test.tsx @@ -0,0 +1,277 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, type MockedFunction, vi } from 'vitest'; +import { ApiError } from './ApiError'; +import { clusterFetch } from './fetch'; +import { useEndpoints } from './hooks'; +import type { KubeObjectEndpoint } from './KubeObjectEndpoint'; + +vi.mock('./fetch', () => ({ + clusterFetch: vi.fn(), +})); + +const mockClusterFetch = clusterFetch as MockedFunction; + +const mockJsonResponse = (data: unknown) => + ({ + ok: true, + status: 200, + json: () => Promise.resolve(data), + } as Response); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }, + }, + }); + + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +describe('useEndpoints', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns a single endpoint without probing', () => { + const endpoint: KubeObjectEndpoint = { version: 'v1', resource: 'pods' }; + + const { result } = renderHook(() => useEndpoints([endpoint], 'cluster-a', 'default', 'pod-a'), { + wrapper: createWrapper(), + }); + + expect(result.current.endpoint).toEqual(endpoint); + expect(result.current.error).toBeNull(); + expect(mockClusterFetch).not.toHaveBeenCalled(); + }); + + it('uses name-based GET probing in endpoint priority order', async () => { + const endpoints: KubeObjectEndpoint[] = [ + { group: 'extensions', version: 'v1beta1', resource: 'ingresses' }, + { group: 'networking.k8s.io', version: 'v1', resource: 'ingresses' }, + ]; + + mockClusterFetch.mockImplementation(url => { + if (url === 'apis/extensions/v1beta1/namespaces/default/ingresses/demo') { + return Promise.reject(new ApiError('Not Found', { status: 404 })); + } + if (url === 'apis/networking.k8s.io/v1/namespaces/default/ingresses/demo') { + return Promise.resolve(mockJsonResponse({})); + } + + return Promise.reject(new Error(`Unexpected URL: ${String(url)}`)); + }); + + const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default', 'demo'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.endpoint).toEqual(endpoints[1]); + }); + + const calledUrls = mockClusterFetch.mock.calls.map(([url]) => String(url)); + expect(calledUrls).toEqual([ + 'apis/extensions/v1beta1/namespaces/default/ingresses/demo', + 'apis/networking.k8s.io/v1/namespaces/default/ingresses/demo', + ]); + expect(calledUrls).not.toContain('apis/extensions/v1beta1/namespaces/default/ingresses'); + }); + + it('handles cluster-scoped GET-by-name probing without namespace', async () => { + const endpoints: KubeObjectEndpoint[] = [ + { + group: 'gateway.networking.k8s.io', + version: 'v1', + resource: 'gatewayclasses', + }, + ]; + mockClusterFetch.mockImplementation(url => { + if (url === 'apis/gateway.networking.k8s.io/v1/gatewayclasses/test-gc') { + return Promise.resolve( + mockJsonResponse({ + apiVersion: 'gateway.networking.k8s.io/v1', + kind: 'GatewayClass', + metadata: { name: 'test-gc' }, + }) + ); + } + return Promise.reject(new Error(`Unexpected URL: ${String(url)}`)); + }); + + const { result } = renderHook( + () => useEndpoints(endpoints, 'cluster-a', undefined, 'test-gc'), + { + wrapper: createWrapper(), + } + ); + + await waitFor(() => { + expect(result.current.endpoint).toEqual(endpoints[0]); + expect(result.current.error).toBeNull(); + }); + }); + + it('continues probing after forbidden and resolves next endpoint', async () => { + const endpoints: KubeObjectEndpoint[] = [ + { group: 'extensions', version: 'v1beta1', resource: 'ingresses' }, + { group: 'networking.k8s.io', version: 'v1', resource: 'ingresses' }, + ]; + + mockClusterFetch.mockImplementation(url => { + if (url === 'apis/extensions/v1beta1/namespaces/default/ingresses/demo') { + return Promise.reject(new ApiError('Forbidden', { status: 403 })); + } + if (url === 'apis/networking.k8s.io/v1/namespaces/default/ingresses/demo') { + return Promise.resolve(mockJsonResponse({})); + } + + return Promise.reject(new Error(`Unexpected URL: ${String(url)}`)); + }); + + const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default', 'demo'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.endpoint).toEqual(endpoints[1]); + }); + }); + + it('returns error when all get probes fail', async () => { + const endpoints: KubeObjectEndpoint[] = [ + { group: 'apps', version: 'v1', resource: 'deployments' }, + { group: 'extensions', version: 'v1beta1', resource: 'deployments' }, + ]; + + mockClusterFetch.mockImplementation(url => { + if (url === 'apis/apps/v1/namespaces/default/deployments/demo') { + return Promise.reject(new ApiError('Not Found', { status: 404 })); + } + if (url === 'apis/extensions/v1beta1/namespaces/default/deployments/demo') { + return Promise.reject(new ApiError('Forbidden', { status: 403 })); + } + + return Promise.reject(new Error(`Unexpected URL: ${String(url)}`)); + }); + + const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default', 'demo'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.error).toBeTruthy(); + }); + expect(result.current.endpoint).toBeUndefined(); + }); + + it('keeps collection probing behavior when name is not provided', async () => { + const endpoints: KubeObjectEndpoint[] = [ + { group: 'batch', version: 'v1', resource: 'jobs' }, + { group: 'batch', version: 'v1beta1', resource: 'jobs' }, + ]; + + mockClusterFetch.mockImplementation(url => { + if (url === 'apis/batch/v1/namespaces/default/jobs') { + return Promise.reject(new ApiError('Forbidden', { status: 403 })); + } + if (url === 'apis/batch/v1beta1/namespaces/default/jobs') { + return Promise.resolve(mockJsonResponse({})); + } + + return Promise.reject(new Error(`Unexpected URL: ${String(url)}`)); + }); + + const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.endpoint).toEqual(endpoints[1]); + }); + + const calledUrls = mockClusterFetch.mock.calls.map(([url]) => String(url)); + expect(calledUrls).toEqual([ + 'apis/batch/v1/namespaces/default/jobs', + 'apis/batch/v1beta1/namespaces/default/jobs', + ]); + }); + + it('returns error when all list probes fail', async () => { + const endpoints: KubeObjectEndpoint[] = [ + { group: 'batch', version: 'v1', resource: 'jobs' }, + { group: 'batch', version: 'v1beta1', resource: 'jobs' }, + ]; + + mockClusterFetch.mockImplementation(url => { + if (url === 'apis/batch/v1/namespaces/default/jobs') { + return Promise.reject(new ApiError('Not Found', { status: 404 })); + } + if (url === 'apis/batch/v1beta1/namespaces/default/jobs') { + return Promise.reject(new ApiError('Forbidden', { status: 403 })); + } + + return Promise.reject(new Error(`Unexpected URL: ${String(url)}`)); + }); + + const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.error).toBeTruthy(); + }); + expect(result.current.endpoint).toBeUndefined(); + }); + + it('handles non-ok response in list probing', async () => { + const endpoints: KubeObjectEndpoint[] = [ + { group: 'batch', version: 'v1', resource: 'jobs' }, + { group: 'batch', version: 'v1beta1', resource: 'jobs' }, + ]; + + mockClusterFetch.mockImplementation(url => { + if (url === 'apis/batch/v1/namespaces/default/jobs') { + return Promise.reject(new ApiError('Forbidden', { status: 403 })); + } + if (url === 'apis/batch/v1beta1/namespaces/default/jobs') { + return Promise.resolve(mockJsonResponse({})); + } + + return Promise.reject(new Error(`Unexpected URL: ${String(url)}`)); + }); + + const { result } = renderHook(() => useEndpoints(endpoints, 'cluster-a', 'default'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.endpoint).toEqual(endpoints[1]); + }); + }); +}); diff --git a/frontend/src/lib/k8s/api/v2/hooks.ts b/frontend/src/lib/k8s/api/v2/hooks.ts index d13666eae65..d2c93f40ba0 100644 --- a/frontend/src/lib/k8s/api/v2/hooks.ts +++ b/frontend/src/lib/k8s/api/v2/hooks.ts @@ -18,8 +18,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo } from 'react'; import { getCluster } from '../../../cluster'; import type { QueryParameters } from '../../api/v1/queryParameters'; -import type { ApiError } from '../../api/v2/ApiError'; import type { KubeObject, KubeObjectInterface } from '../../KubeObject'; +import type { ApiError } from './ApiError'; import { clusterFetch } from './fetch'; import type { KubeListUpdateEvent } from './KubeList'; import { KubeObjectEndpoint } from './KubeObjectEndpoint'; @@ -118,7 +118,9 @@ export function useKubeObject({ type Instance = K; const { endpoint, error: endpointError } = useEndpoints( kubeObjectClass.apiEndpoint.apiInfo, - cluster + cluster, + namespace, + name ); const cleanedUpQueryParams = Object.fromEntries( @@ -214,25 +216,35 @@ export function useKubeObject({ } /** - * Test different endpoints to see which one is working. + * Probes the provided endpoints and returns the first one that works. * - * @params endpoints - List of possible endpoints - * @returns Endpoint that works + * @param endpoints - List of possible endpoints + * @param cluster - Target cluster name + * @param namespace - Optional namespace scope + * @param name - Resource name. When provided, uses GET-by-name probing + * @returns The first endpoint that responds with an OK status * - * @throws Error + * @throws {ApiError} * When no endpoints are working */ const getWorkingEndpoint = async ( endpoints: KubeObjectEndpoint[], cluster: string, - namespace?: string + namespace?: string, + name?: string ) => { const promises = endpoints.map(endpoint => { - return clusterFetch(KubeObjectEndpoint.toUrl(endpoint, namespace), { + const resourceUrl = KubeObjectEndpoint.toUrl(endpoint, namespace); + // If a name is provided, we probe for that specific resource. + // Otherwise we probe for the list of resources. + const url = name ? makeUrl([resourceUrl, name]) : resourceUrl; + + return clusterFetch(url, { method: 'GET', cluster: cluster ?? getCluster() ?? '', }).then(() => endpoint); }); + return Promise.any(promises).catch((aggregateError: AggregateError) => { // when no endpoint is available, throw an error throw aggregateError.errors[0]; @@ -240,20 +252,32 @@ const getWorkingEndpoint = async ( }; /** - * Checks and returns an endpoint that works from the list + * Returns a working endpoint for the given resource. + * + * It tries to find a working endpoint by probing the provided list. * - * @params endpoints - List of possible endpoints + * @param endpoints - List of possible endpoints + * @param cluster - Cluster name + * @param namespace - Optional namespace scope + * @param name - Resource name. When provided, uses GET-by-name probing */ export const useEndpoints = ( endpoints: KubeObjectEndpoint[], cluster: string, - namespace?: string + namespace?: string, + name?: string ) => { + const endpointsKey = useMemo( + () => endpoints.map(ep => `${ep.group ?? ''}/${ep.version}/${ep.resource}`), + [endpoints] + ); + const { data: endpoint, error } = useQuery({ enabled: endpoints.length > 1, - queryKey: ['endpoints', endpoints], - queryFn: () => getWorkingEndpoint(endpoints, cluster!, namespace), + queryKey: ['endpoints', cluster, namespace, name ?? '', endpointsKey], + queryFn: () => getWorkingEndpoint(endpoints, cluster!, namespace, name), }); + if (endpoints.length === 1) return { endpoint: endpoints[0], error: null }; return { endpoint, error }; diff --git a/frontend/src/lib/k8s/api/v2/multiplexer.test.ts b/frontend/src/lib/k8s/api/v2/multiplexer.test.ts index 6f712788695..86bcb7257b9 100644 --- a/frontend/src/lib/k8s/api/v2/multiplexer.test.ts +++ b/frontend/src/lib/k8s/api/v2/multiplexer.test.ts @@ -89,6 +89,7 @@ describe('WebSocket Multiplexer', () => { WebSocketManager.listeners.clear(); WebSocketManager.completedPaths.clear(); WebSocketManager.activeSubscriptions.clear(); + WebSocketManager.pendingUnsubscribes.forEach(clearTimeout); WebSocketManager.pendingUnsubscribes.clear(); });