diff --git a/shell/components/SideNav.vue b/shell/components/SideNav.vue index 1f5f45ff87a..c3e4f5ce100 100644 --- a/shell/components/SideNav.vue +++ b/shell/components/SideNav.vue @@ -13,7 +13,7 @@ import { import { sortBy } from '@shell/utils/sort'; import { ucFirst } from '@shell/utils/string'; -import { HCI, UI, SCHEMA } from '@shell/config/types'; +import { HCI, UI, SCHEMA, COUNT } from '@shell/config/types'; import { HARVESTER_NAME as HARVESTER } from '@shell/config/features'; import { NAME as EXPLORER } from '@shell/config/product/explorer'; import { TYPE_MODES } from '@shell/store/type-map'; @@ -54,6 +54,17 @@ export default { } }, + /** + * When new resource types appear in (or are removed from) the count data, + * it means a CRD was added or removed. Re-build the nav so the new group + * shows up under "More Resources" without requiring a page refresh. + */ + countTypes(a, b) { + if ( !sameContents(a, b) ) { + this.queueUpdate(); + } + }, + allNavLinksIds(a, b) { if ( !sameContents(a, b) ) { this.queueUpdate(); @@ -177,6 +188,33 @@ export default { return this.$store.getters[`${ product.inStore }/all`](SCHEMA).map((s) => s.id).sort(); }, + /** + * Returns the sorted list of resource type IDs that currently have + * resources in the COUNT data. + * Watching this allows the nav to react when a CRD type transitions from + * hidden to visible (count goes from 0 to >0) or vice-versa so it appears + * under "More Resources" without a page refresh. + */ + countTypes() { + const managementReady = this.managementReady; + const product = this.currentProduct; + + if ( !managementReady || !product ) { + return []; + } + + const counts = this.$store.getters[`${ product.inStore }/all`](COUNT)?.[0]?.counts || {}; + + return Object.entries(counts) + .filter(([, entry]) => { + const n = entry?.summary?.count; + + return typeof n === 'number' && n > 0; + }) + .map(([type]) => type) + .sort(); + }, + namespaces() { return this.$store.getters['activeNamespaceCache']; }, diff --git a/shell/components/__tests__/SideNav.test.ts b/shell/components/__tests__/SideNav.test.ts new file mode 100644 index 00000000000..5d9fd4b8ac2 --- /dev/null +++ b/shell/components/__tests__/SideNav.test.ts @@ -0,0 +1,85 @@ +import SideNav from '@shell/components/SideNav.vue'; +import { COUNT } from '@shell/config/types'; + +describe('component: SideNav', () => { + describe('countTypes', () => { + const countTypes = (SideNav as any).computed.countTypes; + const watchCountTypes = (SideNav as any).watch.countTypes; + + const getStore = (counts: Record) => ({ + getters: { + 'cluster/all': (type: string) => { + if (type === COUNT) { + return [{ counts }]; + } + + return []; + }, + }, + }); + + it('returns an empty list when management is not ready', () => { + const types = countTypes.call({ + managementReady: false, + currentProduct: { inStore: 'cluster' }, + $store: getStore({ 'my.io.crd': { summary: { count: 5 } } }), + }); + + expect(types).toStrictEqual([]); + }); + + it('returns only types with a positive count, sorted by type id', () => { + const types = countTypes.call({ + managementReady: true, + currentProduct: { inStore: 'cluster' }, + $store: getStore({ + 'z.io.hidden': { summary: { count: 0 } }, + 'a.io.visible': { summary: { count: 3 } }, + 'c.io.not-visible': { summary: { count: -1 } }, + 'b.io.visible': { summary: { count: 1 } }, + 'd.io.bad-type': { summary: {} }, + }), + }); + + expect(types).toStrictEqual(['a.io.visible', 'b.io.visible']); + }); + + it('returns an empty list when count data is missing', () => { + const types = countTypes.call({ + managementReady: true, + currentProduct: { inStore: 'cluster' }, + $store: { + getters: { + 'cluster/all': (type: string) => { + if (type === COUNT) { + return []; + } + + return []; + }, + }, + }, + }); + + expect(types).toStrictEqual([]); + }); + + it('queues a nav refresh when visible count types change', () => { + const queueUpdate = jest.fn(); + const logSideNavDebug = jest.fn(); + + watchCountTypes.call({ queueUpdate, logSideNavDebug }, ['a.io.visible'], ['a.io.visible', 'b.io.visible']); + + expect(queueUpdate).toHaveBeenCalledWith(); + }); + + it('does not queue a nav refresh when visible count types are unchanged', () => { + const queueUpdate = jest.fn(); + const logSideNavDebug = jest.fn(); + + watchCountTypes.call({ queueUpdate, logSideNavDebug }, ['a.io.visible'], ['a.io.visible']); + + expect(queueUpdate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/shell/plugins/steve/__tests__/subscribe.spec.ts b/shell/plugins/steve/__tests__/subscribe.spec.ts index bab7b5e6735..6ad74bd3da7 100644 --- a/shell/plugins/steve/__tests__/subscribe.spec.ts +++ b/shell/plugins/steve/__tests__/subscribe.spec.ts @@ -113,6 +113,80 @@ describe('steve: subscribe', () => { }); }); + describe('ws.resource.create', () => { + const makeCtx = (overrides: Record = {}) => ({ + state: { debugSocket: false, queue: [] }, + getters: { + storeName: 'cluster', + normalizeType: (t: string) => t, + typeEntry: () => ({ revision: 0 }), + havePage: () => null, + haveNamespace: () => null, + ...overrides.getters, + }, + rootGetters: { + isAllNamespaces: true, + ...overrides.rootGetters, + }, + dispatch: jest.fn(), + commit: jest.fn(), + ...overrides, + }); + + it('notifies the worker about a new schema via updateSchema and still queues a load', () => { + const postMessage = jest.fn(); + const ctx = makeCtx(); + + // Simulate a worker being registered for this store + (actions['ws.resource.create'] as any).call( + { $workers: { cluster: { postMessage } } }, + ctx, + { data: { type: 'schema', id: 'my.io.crd' }, revision: '1' } + ); + + expect(postMessage).toHaveBeenCalledWith({ updateSchema: { type: 'schema', id: 'my.io.crd' } }); + expect(ctx.state.queue).toStrictEqual([ + { + action: 'dispatch', event: 'load', body: { type: 'schema', id: 'my.io.crd' } + }, + ]); + }); + + it('does not notify the worker for non-schema resource creates, but still queues a load', () => { + const postMessage = jest.fn(); + const ctx = makeCtx(); + + (actions['ws.resource.create'] as any).call( + { $workers: { cluster: { postMessage } } }, + ctx, + { data: { type: 'pod', id: 'my-pod' }, revision: '1' } + ); + + expect(postMessage).not.toHaveBeenCalled(); + expect(ctx.state.queue).toStrictEqual([ + { + action: 'dispatch', event: 'load', body: { type: 'pod', id: 'my-pod' } + }, + ]); + }); + + it('still queues a load when no worker is registered', () => { + const ctx = makeCtx(); + + (actions['ws.resource.create'] as any).call( + { $workers: {} }, + ctx, + { data: { type: 'schema', id: 'my.io.crd' }, revision: '1' } + ); + + expect(ctx.state.queue).toStrictEqual([ + { + action: 'dispatch', event: 'load', body: { type: 'schema', id: 'my.io.crd' } + }, + ]); + }); + }); + describe('fetchPageResources', () => { const dispatch = jest.fn(); const getters = { diff --git a/shell/plugins/steve/subscribe.js b/shell/plugins/steve/subscribe.js index 3f8c4a10710..a31ee2cb751 100644 --- a/shell/plugins/steve/subscribe.js +++ b/shell/plugins/steve/subscribe.js @@ -1385,6 +1385,19 @@ const defaultActions = { const data = msg.data; const type = data?.type; + // Notify the web worker about the new schema so its hash map stays in sync. + // This mirrors the handling in ws.resource.remove and prevents spurious + // "changed" notifications for schemas the worker has never seen before. + // Note: we deliberately do NOT return early here – we also call queueChange + // below so that the schema is added to the store immediately. + if (type === SCHEMA) { + const worker = (this.$workers || {})[ctx.getters.storeName]; + + if (worker) { + worker.postMessage({ updateSchema: data }); + } + } + const havePage = ctx.getters['havePage'](type); if (havePage) {