fix(frontend): stop MSK breaking the Console Home resource count #22

Merged
Zakaria merged 1 commits from fix/console-home-resource-count into main 2026-10-25 14:29:25 +00:00
7 changed files with 82 additions and 7 deletions
+5
View File
@@ -44,6 +44,11 @@ export interface CloudServiceDescriptor {
iconKey: string iconKey: string
group: ServiceGroup group: ServiceGroup
order: number 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' export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'
+12
View File
@@ -1403,6 +1403,18 @@ describe('service descriptors', () => {
expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'}) expect(secretsFor(bare)).toMatchObject({availability: 'coming_soon'})
expect(secretsFor(gcp)).toMatchObject({availability: 'available', route: 'secrets'}) 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', () => { describe('AppConfig nested routes', () => {
@@ -177,6 +177,7 @@ export class CloudProxyService {
iconKey: entry.iconKey, iconKey: entry.iconKey,
group: entry.group, group: entry.group,
order: entry.order, 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') const NOW = new Date('2026-10-23T10:30:15.000Z')
function descriptor(service: CloudServiceDescriptor['service'], displayName: string, availability: CloudServiceDescriptor['availability'] = 'available'): CloudServiceDescriptor { 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[] = []) { 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 status = statusQuery.data
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]) const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data])
// Availability is derived from adapter registration, so an available service // Only a service with a registered adapter has the generic list endpoint. Route
// always has the generic list endpoint. Route shape only decides where the // shape only decides where the card links: a legacy absolute-route page (AWS
// card links, not whether it can be counted -- a legacy absolute-route page // Secrets Manager) is still counted through its adapter, while MSK, available
// (AWS Secrets Manager) is still counted through its adapter. // through its own pre-SPI page, is not listable and is never counted.
const countable = useMemo( const countable = useMemo(
() => services.filter((service) => service.availability === 'available'), () => services.filter((service) => service.availability === 'available' && service.listable),
[services], [services],
) )
@@ -85,7 +85,7 @@ export function useCloudConsoleHomeData(cloud: CloudProvider) {
: undefined, : undefined,
meta: meta:
service.availability === 'available' service.availability === 'available'
? isLegacyPage ? isLegacyPage || !service.listable
? 'open service' ? 'open service'
: serviceMetaLabel(status, counts?.isLoading ?? false, 'resources') : serviceMetaLabel(status, counts?.isLoading ?? false, 'resources')
: 'coming soon', : 'coming soon',
+5
View File
@@ -57,6 +57,11 @@ export interface CloudServiceDescriptor {
iconKey: string iconKey: string
group: ServiceGroup group: ServiceGroup
order: number 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' export type RuntimeReachability = 'reachable' | 'unavailable' | 'coming_soon'