From eeadeef664bd664a399f25a8c58272f38969aa4b Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sun, 25 Oct 2026 10:09:35 -0400 Subject: [PATCH] fix(frontend): stop MSK breaking the Console Home resource count Console Home counted every available service through the generic resource list, but MSK and MSK Connect are available through their own pre-SPI pages with no adapter behind /resources, so each answered 501 and the Resources tile read "Resource load failed". Service descriptors now say whether the generic list serves them (listable, true when an adapter is registered), and Console Home counts only those; the MSK cards still link to their pages. --- packages/api/src/cloud-spi/types.ts | 5 ++ packages/api/src/routes/clouds.test.ts | 12 +++++ packages/api/src/service/CloudProxyService.ts | 1 + .../src/service/runtimeDiagnostics.test.ts | 2 +- .../useCloudConsoleHomeData.test.tsx | 52 +++++++++++++++++++ .../cloud-console/useCloudConsoleHomeData.ts | 12 ++--- packages/frontend/src/types/cloud.ts | 5 ++ 7 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.test.tsx diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 96e23f5..3518808 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -44,6 +44,11 @@ export interface CloudServiceDescriptor { iconKey: string group: ServiceGroup order: number + /** + * Whether the generic resource list serves it. False for a service still on its + * own pre-SPI page (MSK, MSK Connect): available, but not countable. + */ + listable: boolean } export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon' diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 0800aa6..73458ae 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -1403,6 +1403,18 @@ describe('service descriptors', () => { expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'}) expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'}) }) + + test('marks a service listable only when an adapter serves the generic list', async () => { + // MSK is available through its own pre-SPI page; Console Home must not count it + // through /resources, which has no adapter behind it and answers 501. + const body = await (await appWithRoutes([mockAdapter('aws')]).request('/api/clouds/aws/services')).json() as Array<{service: string; availability: string; listable: boolean}> + const of = (service: string) => body.find((descriptor) => descriptor.service === service) + + expect(of('storage')).toMatchObject({availability: 'available', listable: true}) + expect(of('msk')).toMatchObject({availability: 'available', listable: false}) + expect(of('msk-connect')).toMatchObject({availability: 'available', listable: false}) + expect(of('serverless')).toMatchObject({availability: 'coming_soon', listable: false}) + }) }) describe('AppConfig nested routes', () => { diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index c66a663..81cc3fe 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -177,6 +177,7 @@ export class CloudProxyService { iconKey: entry.iconKey, group: entry.group, order: entry.order, + listable: Boolean(adapter), } }) } diff --git a/packages/api/src/service/runtimeDiagnostics.test.ts b/packages/api/src/service/runtimeDiagnostics.test.ts index 732b67e..721b5c2 100644 --- a/packages/api/src/service/runtimeDiagnostics.test.ts +++ b/packages/api/src/service/runtimeDiagnostics.test.ts @@ -6,7 +6,7 @@ const STARTED_AT = new Date('2026-10-23T08:00:00.000Z') const NOW = new Date('2026-10-23T10:30:15.000Z') function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor { - return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1} + return {cloud: 'aws', service, displayName, availability, route: `/cloud-explorer/aws/${service}`, iconKey: service, group: 'Compute', order: 1, listable: availability === 'available'} } function proxy(status: Partial = {}, descriptors: CloudServiceDescriptor[] = []) { diff --git a/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.test.tsx b/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.test.tsx new file mode 100644 index 0000000..87d60d7 --- /dev/null +++ b/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.test.tsx @@ -0,0 +1,52 @@ +import type {ReactNode} from 'react' +import {QueryClient, QueryClientProvider} from '@tanstack/react-query' +import {renderHook, waitFor} from '@testing-library/react' +import {beforeEach, expect, test, vi} from 'vitest' +import {useCloudConsoleHomeData} from './useCloudConsoleHomeData' +import type {CloudServiceDescriptor} from '@/types/cloud' + +const api = vi.hoisted(() => ({ + listClouds: vi.fn(), + listCloudServices: vi.fn(), + getCloudStatus: vi.fn(), + listCloudResources: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => api) + +function descriptor(service: CloudServiceDescriptor['service'], route: string, listable: boolean): CloudServiceDescriptor { + return {cloud: 'aws', service, displayName: service, availability: 'available', route, iconKey: service, group: 'Storage', order: 1, listable} +} + +function wrapper({children}: {children: ReactNode}) { + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + return {children} +} + +beforeEach(() => { + vi.clearAllMocks() + api.listClouds.mockResolvedValue([]) + api.getCloudStatus.mockResolvedValue({cloud: 'aws', adapterRegistered: true, runtime: 'reachable', endpoint: 'http://floci:4566', checkedAt: '2026-10-25T10:00:00Z', error: null}) + api.listCloudServices.mockResolvedValue([ + descriptor('storage', 'storage', true), + descriptor('secrets', '/secretsmanager', true), + descriptor('msk', 'msk', false), + ]) + api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => { + if (service === 'msk') throw new Error('501 operation_not_supported') + return service === 'storage' ? [{id: 'a'}, {id: 'b'}] : [{id: 'c'}] + }) +}) + +test('counts only services the generic list serves, so a pre-SPI page cannot fail the total', async () => { + const {result} = renderHook(() => useCloudConsoleHomeData('aws'), {wrapper}) + + await waitFor(() => expect(result.current.resourceCount).toBe(3)) + expect(api.listCloudResources.mock.calls.map((call) => call[1]).sort()).toEqual(['secrets', 'storage']) + expect(result.current.resourceDetail).toBe('Normalized resources across available services') + expect(result.current.activeServices).toBe(3) + + const msk = result.current.serviceCards.find((card) => card.id === 'msk') + expect(msk).toMatchObject({route: '/cloud-explorer/aws/msk', meta: 'open service', count: undefined}) + expect(result.current.serviceCards.find((card) => card.id === 'secrets')).toMatchObject({route: '/secretsmanager', count: 1}) +}) diff --git a/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.ts b/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.ts index f506c2c..7f757ec 100644 --- a/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.ts +++ b/packages/frontend/src/features/cloud-console/useCloudConsoleHomeData.ts @@ -32,12 +32,12 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) { const status = statusQuery.data const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]) - // Availability is derived from adapter registration, so an available service - // always has the generic list endpoint. Route shape only decides where the - // card links, not whether it can be counted -- a legacy absolute-route page - // (AWS Secrets Manager) is still counted through its adapter. + // Only a service with a registered adapter has the generic list endpoint. Route + // shape only decides where the card links: a legacy absolute-route page (AWS + // Secrets Manager) is still counted through its adapter, while MSK, available + // through its own pre-SPI page, is not listable and is never counted. const countable = useMemo( - () => services.filter((service) => service.availability === 'available'), + () => services.filter((service) => service.availability === 'available' && service.listable), [services], ) @@ -85,7 +85,7 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) { : undefined, meta: service.availability === 'available' - ? isLegacyPage + ? isLegacyPage || !service.listable ? 'open service' : serviceMetaLabel(status, counts?.isLoading ?? false, 'resources') : 'coming soon', diff --git a/packages/frontend/src/types/cloud.ts b/packages/frontend/src/types/cloud.ts index 3f9c5e1..fdb098b 100644 --- a/packages/frontend/src/types/cloud.ts +++ b/packages/frontend/src/types/cloud.ts @@ -57,6 +57,11 @@ export interface CloudServiceDescriptor { iconKey: string group: ServiceGroup order: number + /** + * Whether the generic resource list serves it. False for a service still on its + * own pre-SPI page (MSK, MSK Connect): available, but not countable. + */ + listable: boolean } export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon' -- 2.52.0