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'