fix(frontend): stop MSK breaking the Console Home resource count
CI / checks (pull_request) Successful in 26m59s

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.
This commit is contained in:
Zakaria
2026-10-25 10:09:35 -04:00
parent 9f1ea32183
commit eeadeef664
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'