fix(frontend): stop MSK breaking the Console Home resource count #22
@@ -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'
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user