fix(frontend): stop MSK breaking the Console Home resource count #22
@@ -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'
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -177,6 +177,7 @@ export class CloudProxyService {
|
||||
iconKey: entry.iconKey,
|
||||
group: entry.group,
|
||||
order: entry.order,
|
||||
listable: Boolean(adapter),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<CloudStatus> = {}, descriptors: CloudServiceDescriptor[] = []) {
|
||||
|
||||
@@ -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 <QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
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})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user