diff --git a/pkg/harvester/components/SettingList.vue b/pkg/harvester/components/SettingList.vue index 85f10a78..e3f5bf6d 100644 --- a/pkg/harvester/components/SettingList.vue +++ b/pkg/harvester/components/SettingList.vue @@ -141,10 +141,6 @@ export default { filterCategorySettings() { return this.settings.filter((s) => { - if (!this.getFeatureEnabled(s.featureFlag)) { - return false; - } - if (this.category !== 'advanced') { return (CATEGORY[this.category] || []).find((item) => item === s.id); } else if (this.category === 'advanced') { @@ -155,10 +151,6 @@ export default { }) || []; }, - getFeatureEnabled(id) { - return id ? this.$store.getters['harvester-common/getFeatureEnabled'](id) : true; - }, - getSettingOption(id) { return HCI_ALLOWED_SETTINGS.find((setting) => setting.id === id); }, diff --git a/pkg/harvester/list/__tests__/harvesterhci.io.setting.test.js b/pkg/harvester/list/__tests__/harvesterhci.io.setting.test.js new file mode 100644 index 00000000..b66f322c --- /dev/null +++ b/pkg/harvester/list/__tests__/harvesterhci.io.setting.test.js @@ -0,0 +1,65 @@ +import { MANAGEMENT } from '@shell/config/types'; +import SettingsPage from '../harvesterhci.io.setting.vue'; +import { HCI } from '../../types'; +import { HCI_SETTING } from '../../config/settings'; + +describe('list: harvesterhci.io.setting', () => { + async function fetchPage({ visibleSettings = [], isSingleProduct = false } = {}) { + const dispatch = jest.fn(() => Promise.resolve([])); + const page = { + initSettings: [], + $store: { + dispatch, + getters: { + isSingleProduct, + currentProduct: { inStore: 'harvester' }, + 'harvester/visibleSettings': visibleSettings, + } + } + }; + + await SettingsPage.fetch.call(page); + + return { page, dispatch }; + } + + const visible = (id, config = {}) => ({ + id, config, data: { id } + }); + + it('should list the visible settings sorted by name, a setting with an alias under its alias', async() => { + const { page } = await fetchPage({ visibleSettings: [visible('z-setting'), visible(HCI_SETTING.UI_PL, { alias: 'branding' })] }); + + expect(page.initSettings.map(({ id }) => id)).toStrictEqual(['branding', 'z-setting']); + }); + + it('should keep the config and the resource of a setting', async() => { + const { page } = await fetchPage({ visibleSettings: [visible('a-setting', { kind: 'json', canReset: true })] }); + + expect(page.initSettings[0]).toMatchObject({ + id: 'a-setting', kind: 'json', canReset: true, data: { id: 'a-setting' }, hide: true, canHide: true, hasActions: true + }); + }); + + it('should only allow actions on the server version when it is read only', async() => { + const { page } = await fetchPage({ + visibleSettings: [ + visible('a-setting', { readOnly: true }), + visible(HCI_SETTING.SERVER_VERSION, { readOnly: true }), + ] + }); + + expect(page.initSettings.map(({ hasActions }) => hasActions)).toStrictEqual([false, true]); + }); + + it('should load the settings of the management store only in a single product', async() => { + const { dispatch: multiCluster } = await fetchPage(); + const { dispatch: singleProduct } = await fetchPage({ isSingleProduct: true }); + + expect(multiCluster.mock.calls).toStrictEqual([['harvester/findAll', { type: HCI.SETTING }]]); + expect(singleProduct.mock.calls).toStrictEqual([ + ['harvester/findAll', { type: HCI.SETTING }], + ['management/findAll', { type: MANAGEMENT.SETTING }], + ]); + }); +}); diff --git a/pkg/harvester/list/harvesterhci.io.setting.vue b/pkg/harvester/list/harvesterhci.io.setting.vue index 4452ebcd..175e9d54 100644 --- a/pkg/harvester/list/harvesterhci.io.setting.vue +++ b/pkg/harvester/list/harvesterhci.io.setting.vue @@ -7,7 +7,7 @@ import { allHash } from '@shell/utils/promise'; import Tabbed from '@shell/components/Tabbed/index.vue'; import Tab from '@shell/components/Tabbed/Tab.vue'; import Settings from '@pkg/harvester/components/SettingList.vue'; -import { HCI_ALLOWED_SETTINGS, HCI_SINGLE_CLUSTER_ALLOWED_SETTING, HCI_SETTING } from '../config/settings'; +import { HCI_SETTING } from '../config/settings'; import { HCI } from '../types'; export default { @@ -29,47 +29,19 @@ export default { hash.settings = this.$store.dispatch('management/findAll', { type: MANAGEMENT.SETTING }); } - const rows = await allHash(hash); + await allHash(hash); - let allRows = []; - - allRows.push(...rows.harvesterSettings); - - if (isSingleProduct) { - allRows = [...rows.settings, ...allRows]; - } - - // Map settings from array to object keyed by id - const settingsMap = allRows.reduce((res, s) => { - res[s.id] = s; - - return res; - }, {}); - - const initSettings = []; - let SETTINGS = HCI_ALLOWED_SETTINGS; - - if (this.isStandaloneHarvester) { - SETTINGS = { - ...SETTINGS, - ...HCI_SINGLE_CLUSTER_ALLOWED_SETTING, - }; - } - - Object.keys(SETTINGS).forEach((setting) => { - if (!settingsMap[setting]) { - return; - } - const realSetting = SETTINGS[setting]?.alias || setting; + const initSettings = this.$store.getters[`${ inStore }/visibleSettings`].map(({ id, config, data }) => { const s = { - ...SETTINGS[setting], - id: realSetting, - data: settingsMap[setting], + ...config, + id: config.alias || id, + data, }; s.hide = s.canHide = (s.kind === 'json' || s.kind === 'multiline' || s.customFormatter === 'json' || s.data.customFormatter === 'json'); s.hasActions = s.id === HCI_SETTING.SERVER_VERSION ? true : !s.readOnly; - initSettings.push(s); + + return s; }); this.initSettings = initSettings.sort((a, b) => { @@ -90,7 +62,6 @@ export default { computed: { ...mapGetters({ t: 'i18n/t' }), - ...mapGetters(['isStandaloneHarvester']), settings() { return this.initSettings.map((setting) => { diff --git a/pkg/harvester/store/harvester-store/__tests__/actions.test.ts b/pkg/harvester/store/harvester-store/__tests__/actions.test.ts new file mode 100644 index 00000000..ebdd607f --- /dev/null +++ b/pkg/harvester/store/harvester-store/__tests__/actions.test.ts @@ -0,0 +1,421 @@ +import { + CONFIG_MAP, COUNT, NETWORK_ATTACHMENT, PVC, STORAGE_CLASS +} from '@shell/config/types'; +import harvesterStore from '../index'; +import harvesterActions from '../actions'; +import harvesterMutations from '../mutations'; +import { SERVER_FILTERED_COUNTS, filteredCountRequest } from '../getters'; +import { HCI, DATA_VOLUME, VOLUME_SNAPSHOT } from '../../../types'; +import { PRODUCT_NAME } from '../../../config/harvester'; + +const BASE = 'https://harvester/v1/harvester'; + +// Nav item -> resource type, like `configureType(..., { resource })` in config/harvester-cluster.js +const RESOURCE_MAP: Record = { + [HCI.BACKUP]: HCI.BACKUP, + [HCI.VM_SNAPSHOT]: HCI.BACKUP, + [HCI.SNAPSHOT]: VOLUME_SNAPSHOT, + [HCI.STORAGE]: STORAGE_CLASS, + [HCI.CLOUD_TEMPLATE]: CONFIG_MAP, + [HCI.NETWORK_ATTACHMENT]: NETWORK_ATTACHMENT, + [HCI.VOLUME]: PVC, + [DATA_VOLUME]: DATA_VOLUME, +}; + +const CLUSTER_SCOPED = [STORAGE_CLASS]; + +const schemaFor = (name: string) => { + const id = RESOURCE_MAP[name]; + + return id ? { + id, attributes: { namespaced: !CLUSTER_SCOPED.includes(id) }, links: { collection: `${ BASE }/${ id }s` } + } : null; +}; + +function createGetters({ counts = {} }: any = {}) { + return { + schemaFor, + urlFor: (type: string) => `${ BASE }/${ type }s`, + all: (type: string) => (type === COUNT ? [{ counts }] : []), + }; +} + +function createRootGetters({ + isAllNamespaces = true, hideSystemResources = true, namespaces = { default: true, test: true }, clusterId = 'local' +}: any = {}) { + return { + isAllNamespaces, + currentProduct: { hideSystemResources, inStore: PRODUCT_NAME }, + namespaces: () => namespaces, + clusterId, + }; +} + +function createState() { + return { + filteredCounts: {}, filteredCountRequests: {}, filteredCountWatchers: [] + }; +} + +/** + * A minimal store context for the actions: `commit` runs the real mutations, + * `request` resolves with `responses[url]` (or rejects for an Error). + */ +function createContext({ responses = {}, getters = createGetters(), rootGetters = createRootGetters() }: any = {}) { + const state: any = createState(); + const commit = jest.fn((type: string, payload: any) => (harvesterMutations as any)[type](state, payload)); + const request = jest.fn(({ url }: any) => { + const res = Object.entries(responses).find(([part]) => url.includes(part))?.[1]; + + return res instanceof Error ? Promise.reject(res) : Promise.resolve(res || {}); + }); + const dispatch = jest.fn((action: string, payload: any) => { + if (action === 'request') { + return request(payload); + } + + return (harvesterActions as any)[action](context, payload); + }); + const context: any = { + state, commit, dispatch, getters, rootGetters + }; + + return { context, state, request }; +} + +describe('harvester store: side navigation counts', () => { + it('should have the state of the counts in the store', () => { + expect((harvesterStore.specifics as any).state()).toMatchObject({ + filteredCounts: {}, filteredCountRequests: {}, filteredCountWatchers: [] + }); + }); + + it('should start the counts when the cluster is loaded', async() => { + const dispatch = jest.fn((action: string) => Promise.resolve(action === 'management/findAll' ? [{ metadata: { name: 'local' } }] : { id: 'local' })); + const context: any = { + state: {}, + commit: jest.fn(), + dispatch, + getters: { schemaFor: () => null, filterNamespace: () => [] }, + rootGetters: { + 'management/schemaFor': () => null, + 'harvester-common/getFeatureEnabled': () => true, + 'prefs/get': () => ({}), + }, + rootState: { managementReady: true }, + }; + + await (harvesterActions as any).loadCluster(context, { id: 'local' }); + + expect(dispatch).toHaveBeenCalledWith('watchFilteredCounts', { clusterId: 'local' }); + }); + + describe('filteredCountRequest', () => { + it('should request the filtered list with a page size of 1', () => { + const req = filteredCountRequest(createGetters(), createRootGetters({ hideSystemResources: false }), HCI.BACKUP); + + expect(req).toStrictEqual({ url: `${ BASE }/${ HCI.BACKUP }s?filter=spec.type!%3Dsnapshot&pagesize=1` }); + }); + + it.each(Object.keys(SERVER_FILTERED_COUNTS))('should build a request for %s', (name) => { + const url = filteredCountRequest(createGetters(), createRootGetters(), name)?.url || ''; + + expect(url.startsWith(`${ BASE }/${ RESOURCE_MAP[name] }s?`)).toBe(true); + expect(url).toContain('pagesize=1'); + }); + + it('should only request the selected namespaces', () => { + const req = filteredCountRequest(createGetters(), createRootGetters({ isAllNamespaces: false, namespaces: { a: true, b: true } }), HCI.VM_SNAPSHOT); + + expect(req?.url).toContain('projectsornamespaces=a%2Cb'); + }); + + it('should leave out system namespaces with "All Namespaces" when system resources are hidden', () => { + const req = filteredCountRequest(createGetters(), createRootGetters({ namespaces: { default: true } }), HCI.CLOUD_TEMPLATE); + + expect(req?.url).toContain('projectsornamespaces=default'); + }); + + it('should not filter by namespace with "All Namespaces" when system resources are shown', () => { + const req = filteredCountRequest(createGetters(), createRootGetters({ hideSystemResources: false }), HCI.SNAPSHOT); + + expect(req?.url).not.toContain('projectsornamespaces'); + }); + + it.each([HCI.STORAGE, HCI.NETWORK_ATTACHMENT])('should ignore the namespace filter for %s', (name) => { + const req = filteredCountRequest(createGetters(), createRootGetters({ isAllNamespaces: false, namespaces: { a: true } }), name); + + expect(req?.url).not.toContain('projectsornamespaces'); + }); + + it('should not send a request for an empty namespace selection', () => { + const req = filteredCountRequest(createGetters(), createRootGetters({ isAllNamespaces: false, namespaces: {} }), HCI.BACKUP); + + expect(req).toStrictEqual({ count: 0 }); + }); + + it('should request the volumes without the temporary volumes of CDI', () => { + const req = filteredCountRequest(createGetters(), createRootGetters({ hideSystemResources: false }), HCI.VOLUME); + + expect(req).toStrictEqual({ url: `${ BASE }/${ PVC }s?filter=metadata.ownerReferences.kind!%3DPersistentVolumeClaim&pagesize=1` }); + }); + + it('should request the golden image volumes through the DataVolumes owned by a VM image, in the selected namespaces', () => { + const req = filteredCountRequest(createGetters(), createRootGetters({ isAllNamespaces: false, namespaces: { a: true } }), DATA_VOLUME); + + expect(req).toStrictEqual({ url: `${ BASE }/${ DATA_VOLUME }s?filter=metadata.ownerReferences.kind%3DVirtualMachineImage&pagesize=1&projectsornamespaces=a` }); + }); + + it('should not send a request for an unknown resource type', () => { + expect(filteredCountRequest(createGetters(), createRootGetters(), 'unknown')).toBeNull(); + }); + }); + + describe('fetchFilteredCounts', () => { + it('should save the count of each response', async() => { + const { context, state, request } = createContext({ responses: { 'spec.type!%3D': { count: 9 }, 'spec.type%3D': { count: 2 } } }); + + await harvesterActions.fetchFilteredCounts(context as any, [HCI.BACKUP, HCI.VM_SNAPSHOT]); + + expect(request).toHaveBeenCalledTimes(2); + expect(state.filteredCounts).toStrictEqual({ [HCI.BACKUP]: 9, [HCI.VM_SNAPSHOT]: 2 }); + }); + + it('should request all nav items by default', async() => { + const { context, state, request } = createContext(); + + await harvesterActions.fetchFilteredCounts(context as any); + + expect(request).toHaveBeenCalledTimes(Object.keys(SERVER_FILTERED_COUNTS).length); + expect(Object.keys(state.filteredCounts)).toStrictEqual(Object.keys(SERVER_FILTERED_COUNTS)); + }); + + it('should save 0 when the response has no count, as Steve leaves it out when nothing matches', async() => { + const { context, state } = createContext({ responses: { [STORAGE_CLASS]: { data: [] } } }); + + await harvesterActions.fetchFilteredCounts(context as any, [HCI.STORAGE]); + + expect(state.filteredCounts).toStrictEqual({ [HCI.STORAGE]: 0 }); + }); + + it('should save no count when the response has resources but no count, as Harvester v1.3 ignores the query', async() => { + const { context, state } = createContext({ responses: { [STORAGE_CLASS]: { data: [{}, {}] } } }); + + await harvesterActions.fetchFilteredCounts(context as any, [HCI.STORAGE]); + + expect(state.filteredCounts).toStrictEqual({ [HCI.STORAGE]: null }); + }); + + it('should save no count when the request fails', async() => { + const { context, state } = createContext({ responses: { [CONFIG_MAP]: new Error('500') } }); + + await harvesterActions.fetchFilteredCounts(context as any, [HCI.CLOUD_TEMPLATE]); + + expect(state.filteredCounts).toStrictEqual({ [HCI.CLOUD_TEMPLATE]: null }); + }); + + it('should save 0 without a request for an empty namespace selection', async() => { + const { context, state, request } = createContext({ rootGetters: createRootGetters({ isAllNamespaces: false, namespaces: {} }) }); + + await harvesterActions.fetchFilteredCounts(context as any, [HCI.BACKUP]); + + expect(request).not.toHaveBeenCalled(); + expect(state.filteredCounts).toStrictEqual({ [HCI.BACKUP]: 0 }); + }); + + it('should ignore the response of an outdated request', async() => { + const { context, state } = createContext(); + let resolveFirst: any; + + context.dispatch = jest.fn() + .mockImplementationOnce(() => new Promise((resolve) => { + resolveFirst = resolve; + })) + .mockImplementationOnce(() => Promise.resolve({ count: 5 })); + + const first = harvesterActions.fetchFilteredCounts(context as any, [HCI.BACKUP]); + + await harvesterActions.fetchFilteredCounts(context as any, [HCI.BACKUP]); + resolveFirst({ count: 3 }); + await first; + + expect(state.filteredCounts).toStrictEqual({ [HCI.BACKUP]: 5 }); + }); + + it('should ignore the response of a request of the previous cluster', async() => { + const { context, state } = createContext(); + let resolveFirst: any; + + context.dispatch = jest.fn() + .mockImplementationOnce(() => new Promise((resolve) => { + resolveFirst = resolve; + })) + .mockImplementationOnce(() => Promise.resolve({ count: 5 })); + + const first = harvesterActions.fetchFilteredCounts(context as any, [HCI.BACKUP]); + + context.commit('resetFilteredCounts'); + await harvesterActions.fetchFilteredCounts(context as any, [HCI.BACKUP]); + resolveFirst({ count: 3 }); + await first; + + expect(state.filteredCounts).toStrictEqual({ [HCI.BACKUP]: 5 }); + }); + }); + + describe('watchFilteredCounts', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * Runs `watchFilteredCounts` with a fake `store.watch`, which records the + * watch callbacks by the value of their getter. + */ + function watch(contextArgs: any = {}) { + const { context, state, request } = createContext(contextArgs); + const watchers: { getter: () => any, cb: () => void }[] = []; + const unwatch = jest.fn(); + const store = { + watch: jest.fn((getter: () => any, cb: () => void) => { + watchers.push({ getter, cb }); + + return unwatch; + }) + }; + const done = harvesterActions.watchFilteredCounts.call(store, context, { clusterId: 'local' }); + + return { + context, state, request, watchers, unwatch, done + }; + } + + it('should request all counts right away', async() => { + const { request, done } = watch(); + + await done; + + expect(request).toHaveBeenCalledTimes(Object.keys(SERVER_FILTERED_COUNTS).length); + }); + + it('should watch the counts endpoint per resource type, and the namespace filter', async() => { + const { watchers, done } = watch({ getters: createGetters({ counts: { [STORAGE_CLASS]: { summary: { count: 4 } } } }) }); + + await done; + + // VirtualMachineBackup, VolumeSnapshot, StorageClass, ConfigMap, NAD, PVC, DataVolume + the namespace filter + expect(watchers).toHaveLength(8); + expect(watchers.map(({ getter }) => getter())).toContain(JSON.stringify({ summary: { count: 4 } })); + }); + + it('should request the counts of a resource type again, once, when its numbers change', async() => { + const { watchers, request, done } = watch(); + + await done; + request.mockClear(); + + // The first watcher is the one of VirtualMachineBackup, the type of the first nav items + const backupWatcher = watchers[0]; + + backupWatcher.cb(); + backupWatcher.cb(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + + // VM backups and VM snapshots, both VirtualMachineBackup resources + expect(request).toHaveBeenCalledTimes(2); + }); + + it('should request the namespaced counts again when the namespace filter changes', async() => { + const { watchers, request, done } = watch(); + + await done; + request.mockClear(); + watchers[watchers.length - 1].cb(); + await Promise.resolve(); + + const urls = request.mock.calls.map(([{ url }]: any) => url); + + expect(urls).toHaveLength(6); + expect(urls.some((url: string) => url.includes(STORAGE_CLASS) || url.includes(NETWORK_ATTACHMENT))).toBe(false); + }); + + it('should not request anything once another cluster is shown', async() => { + const rootGetters = createRootGetters(); + const { watchers, request, done } = watch({ rootGetters }); + + await done; + request.mockClear(); + rootGetters.clusterId = 'c-other'; + watchers[watchers.length - 1].cb(); + await Promise.resolve(); + + expect(request).not.toHaveBeenCalled(); + }); + + it('should not request anything once another product is shown', async() => { + const rootGetters = createRootGetters(); + const { watchers, request, done } = watch({ rootGetters }); + + await done; + request.mockClear(); + rootGetters.currentProduct = { inStore: 'cluster' }; + watchers[watchers.length - 1].cb(); + await Promise.resolve(); + + expect(request).not.toHaveBeenCalled(); + }); + + it('should not request anything for a change seen just before the watchers were stopped', async() => { + const { context, watchers, request, done } = watch(); + + await done; + request.mockClear(); + watchers[0].cb(); + await harvesterActions.watchFilteredCounts.call({ watch: jest.fn(() => jest.fn()) }, context, { clusterId: 'local' }); + request.mockClear(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + + expect(request).not.toHaveBeenCalled(); + }); + + it('should cope with the store module being unregistered when logging out', async() => { + const getters: any = createGetters(); + const { watchers, done } = watch({ getters }); + + await done; + delete getters.all; + + expect(watchers[0].getter()).toBe('null'); + }); + + it('should keep the watchers when a cluster left while it was loading finishes loading', async() => { + const { context, unwatch, request, done } = watch(); + + await done; + request.mockClear(); + + const store = { watch: jest.fn() }; + + await harvesterActions.watchFilteredCounts.call(store, context, { clusterId: 'c-left' }); + + expect(store.watch).not.toHaveBeenCalled(); + expect(unwatch).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + }); + + it('should stop the watchers of the previous cluster', async() => { + const { context, unwatch, done } = watch(); + + await done; + await harvesterActions.watchFilteredCounts.call({ watch: jest.fn(() => jest.fn()) }, context, { clusterId: 'local' }); + + expect(unwatch).toHaveBeenCalledTimes(8); + }); + }); +}); diff --git a/pkg/harvester/store/harvester-store/__tests__/getters.test.ts b/pkg/harvester/store/harvester-store/__tests__/getters.test.ts new file mode 100644 index 00000000..45244f5e --- /dev/null +++ b/pkg/harvester/store/harvester-store/__tests__/getters.test.ts @@ -0,0 +1,290 @@ +import { + CONFIG_MAP, COUNT, NAMESPACE, NETWORK_ATTACHMENT, NODE, PVC, STORAGE_CLASS +} from '@shell/config/types'; +import harvesterGetters from '../getters'; +import { HCI, DATA_VOLUME, VOLUME_SNAPSHOT } from '../../../types'; +import { HCI_SETTING } from '../../../config/settings'; + +// Nav item -> resource type, like `configureType(..., { resource })` in config/harvester-cluster.js +const RESOURCE_MAP: Record = { + [HCI.HOST]: NODE, + [HCI.VOLUME]: PVC, + [HCI.NETWORK_ATTACHMENT]: NETWORK_ATTACHMENT, + [HCI.VM_SNAPSHOT]: HCI.BACKUP, + [HCI.SNAPSHOT]: VOLUME_SNAPSHOT, + [HCI.CLOUD_TEMPLATE]: CONFIG_MAP, + [HCI.STORAGE]: STORAGE_CLASS, +}; + +const CLUSTER_SCOPED = [NODE, STORAGE_CLASS, NAMESPACE]; + +const counts = (byNamespace: Record) => ({ + summary: { count: Object.values(byNamespace).reduce((sum, count) => sum + count, 0) }, + namespaces: Object.fromEntries(Object.entries(byNamespace).map(([ns, count]) => [ns, { count }])), +}); + +/** + * Creates the `count` getter with a minimal store. `counts` is the result of + * Steve's counts endpoint, `filteredCounts` the counts requested by + * `fetchFilteredCounts` and `lists` the loaded store lists. + */ +function createCount({ + counts = {}, filteredCounts = {}, lists = {}, visibleSettings = [], isAllNamespaces = true, hideSystemResources = false, namespaces = {} +}: any = {}) { + const schemas = new Map(); + + // Schemas of the resource types used below. `count` only filters by namespace if the + // schema marks the type as namespaced, so a namespaced type without a schema here would be + // counted in all namespaces. RESOURCE_MAP contains the resource types of nav items with a + // different name, the others are nav items named like the resource type they list. + [...Object.values(RESOURCE_MAP), HCI.BACKUP, HCI.SCHEDULE_VM_BACKUP, HCI.VM, HCI.FLOW, NAMESPACE].forEach((id) => { + schemas.set(id, { id, attributes: { namespaced: !CLUSTER_SCOPED.includes(id) } }); + }); + + const state = { types: { schema: { map: schemas } }, filteredCounts }; + const rootGetters = { + isAllNamespaces, + namespaces: () => namespaces, + // Empty on purpose: this cache is not necessarily filled yet when the side navigation is + // rendered, e.g. right after loading the dashboard. `count` must use `namespaces()`; + // reading this cache would make the tests that select namespaces fail. + activeNamespaceCache: {}, + currentProduct: { hideSystemResources }, + 'type-map/optionsFor': (type: string) => (RESOURCE_MAP[type] ? { resource: RESOURCE_MAP[type] } : {}), + }; + const getters: any = { + normalizeType: (type: string) => type, + haveAll: jest.fn((type: string) => !!lists[type]), + all: jest.fn((type: string) => (type === COUNT ? [{ counts }] : lists[type] || [])), + visibleSettings, + }; + + getters.schemaFor = harvesterGetters.schemaFor(state, getters, {}, rootGetters); + + const count = harvesterGetters.count(state, getters, {}, rootGetters); + + return (name: string) => count({ name }); +} + +describe('harvester store: count', () => { + describe('nav items listing all resources of a type', () => { + it('should look up the count of the underlying resource type', () => { + const count = createCount({ counts: { [NODE]: counts({ '': 3 }) } }); + + expect(count(HCI.HOST)).toBe(3); + }); + + it('should only count the selected namespaces', () => { + const count = createCount({ + counts: { [HCI.VM]: counts({ default: 4, test: 2 }) }, + isAllNamespaces: false, + namespaces: { test: true }, + }); + + expect(count(HCI.VM)).toBe(2); + }); + + it('should not count system namespaces with "All Namespaces" when system resources are hidden', () => { + const count = createCount({ + counts: { [HCI.VM]: counts({ default: 4, 'cattle-monitoring-system': 2 }) }, + hideSystemResources: true, + namespaces: { default: true }, + }); + + expect(count(HCI.VM)).toBe(4); + }); + + it('should count nothing for an empty namespace selection, e.g. a project without namespaces', () => { + const count = createCount({ + counts: { [HCI.VM]: counts({ default: 4, test: 2 }) }, + isAllNamespaces: false, + namespaces: {}, + }); + + expect(count(HCI.VM)).toBe(0); + }); + + it('should count all namespaces for lists that ignore the namespace filter', () => { + const count = createCount({ + counts: { [HCI.FLOW]: counts({ a: 2, b: 1 }) }, + isAllNamespaces: false, + namespaces: { a: true }, + }); + + expect(count(HCI.FLOW)).toBe(3); + }); + + it('should ignore the namespace filter for cluster-scoped resources', () => { + const count = createCount({ + counts: { [NODE]: counts({ '': 3 }) }, + isAllNamespaces: false, + namespaces: { test: true }, + }); + + expect(count(HCI.HOST)).toBe(3); + }); + + it('should count all VM Schedules, whatever their type', () => { + const count = createCount({ counts: { [HCI.SCHEDULE_VM_BACKUP]: counts({ default: 2 }) } }); + + expect(count(HCI.SCHEDULE_VM_BACKUP)).toBe(2); + }); + + it('should return null for nav items without counts', () => { + expect(createCount()(HCI.DASHBOARD)).toBeNull(); + }); + }); + + describe('nav items listing a subset of a resource type', () => { + it('should show the count requested from the server', () => { + const count = createCount({ + counts: { [HCI.BACKUP]: counts({ default: 3 }) }, + filteredCounts: { [HCI.BACKUP]: 1, [HCI.VM_SNAPSHOT]: 2 }, + }); + + expect(count(HCI.BACKUP)).toBe(1); + expect(count(HCI.VM_SNAPSHOT)).toBe(2); + }); + + it.each([ + HCI.BACKUP, HCI.VM_SNAPSHOT, HCI.SNAPSHOT, HCI.STORAGE, HCI.CLOUD_TEMPLATE, HCI.NETWORK_ATTACHMENT + ])('should not use the total of the counts endpoint for %s before the server count arrives', (name) => { + const count = createCount({ counts: Object.fromEntries(Object.values(RESOURCE_MAP).concat(HCI.BACKUP).map((type) => [type, counts({ default: 3 })])) }); + + expect(count(name)).toBeNull(); + }); + + it('should show 0 when the server count is 0', () => { + expect(createCount({ filteredCounts: { [HCI.STORAGE]: 0 } })(HCI.STORAGE)).toBe(0); + }); + }); + + describe('volumes', () => { + it('should subtract the golden image volumes from the requested volumes', () => { + const count = createCount({ + counts: { [PVC]: counts({ default: 99 }) }, + filteredCounts: { [HCI.VOLUME]: 19, [DATA_VOLUME]: 2 }, + }); + + expect(count(HCI.VOLUME)).toBe(17); + }); + + it('should show 0 when there are no volumes', () => { + expect(createCount({ filteredCounts: { [HCI.VOLUME]: 0, [DATA_VOLUME]: 0 } })(HCI.VOLUME)).toBe(0); + }); + + it.each([ + [{}], + [{ [HCI.VOLUME]: 19 }], + [{ [DATA_VOLUME]: 2 }], + [{ [HCI.VOLUME]: null, [DATA_VOLUME]: 2 }], + [{ [HCI.VOLUME]: 19, [DATA_VOLUME]: null }], + ])('should not show a count until both numbers are known: %j', (filteredCounts) => { + expect(createCount({ filteredCounts })(HCI.VOLUME)).toBeNull(); + }); + + it('should not go below 0, as a DataVolume exists briefly before its volume', () => { + expect(createCount({ filteredCounts: { [HCI.VOLUME]: 0, [DATA_VOLUME]: 1 } })(HCI.VOLUME)).toBe(0); + }); + }); + + describe('settings', () => { + it('should count the settings shown by the page, not all the settings', () => { + const count = createCount({ + counts: { [HCI.SETTING]: counts({ '': 5 }) }, + lists: { [HCI.SETTING]: [{}] }, + visibleSettings: [{}, {}], + }); + + expect(count(HCI.SETTING)).toBe(2); + }); + + it('should return null until the settings are loaded', () => { + expect(createCount({ visibleSettings: [{}] })(HCI.SETTING)).toBeNull(); + }); + }); + + describe('namespaces', () => { + const namespace = (isSystem = false, isFleetManaged = false) => ({ isSystem, isFleetManaged }); + + it('should not count system and Fleet managed namespaces when system resources are hidden', () => { + const count = createCount({ + counts: { [NAMESPACE]: counts({ '': 4 }) }, + lists: { [NAMESPACE]: [namespace(), namespace(), namespace(true), namespace(false, true)] }, + hideSystemResources: true, + }); + + expect(count(NAMESPACE)).toBe(2); + }); + + it('should return null until the namespaces are loaded when system resources are hidden', () => { + const count = createCount({ counts: { [NAMESPACE]: counts({ '': 4 }) }, hideSystemResources: true }); + + expect(count(NAMESPACE)).toBeNull(); + }); + + it('should count all namespaces when system resources are shown', () => { + const count = createCount({ counts: { [NAMESPACE]: counts({ '': 4 }) } }); + + expect(count(NAMESPACE)).toBe(4); + }); + }); +}); + +describe('harvester store: visibleSettings', () => { + const { BACKUP_TARGET, MAX_HOTPLUG_RATIO, UI_PL } = HCI_SETTING; + const setting = (id: string, source = 'harvester') => ({ id, source }); + + function visibleSettings({ + harvester = [] as string[], management = [] as string[], isSingleProduct = false, isStandaloneHarvester = false, enabledFeatures = [] as string[] + } = {}) { + const rootGetters = { + isSingleProduct, + isStandaloneHarvester, + 'management/all': () => management.map((id) => setting(id, 'management')), + 'harvester-common/getFeatureEnabled': (feature: string) => enabledFeatures.includes(feature), + }; + + return harvesterGetters.visibleSettings({}, { all: () => harvester.map((id) => setting(id)) }, {}, rootGetters); + } + + const ids = (args: any) => visibleSettings(args).map(({ id }: any) => id); + + it('should only show the allowed settings', () => { + expect(ids({ harvester: [BACKUP_TARGET, 'api-ui-version'] })).toStrictEqual([BACKUP_TARGET]); + }); + + it('should return the config and the resource of a setting', () => { + const [shown] = visibleSettings({ harvester: [BACKUP_TARGET] }); + + expect(shown.data).toStrictEqual(setting(BACKUP_TARGET)); + expect(shown.config.kind).toBe('json'); + }); + + it('should not show allowed settings that do not exist in the cluster', () => { + expect(ids({ harvester: [] })).toStrictEqual([]); + }); + + it('should only show settings with a feature flag when the version supports it', () => { + expect(ids({ harvester: [MAX_HOTPLUG_RATIO] })).toStrictEqual([]); + expect(ids({ harvester: [MAX_HOTPLUG_RATIO], enabledFeatures: ['cpuMemoryHotplug'] })).toStrictEqual([MAX_HOTPLUG_RATIO]); + }); + + it('should only show the single cluster settings on a standalone Harvester', () => { + expect(ids({ harvester: [UI_PL] })).toStrictEqual([]); + expect(ids({ harvester: [UI_PL], isStandaloneHarvester: true })).toStrictEqual([UI_PL]); + }); + + it('should only use the settings of the management store in a single product', () => { + expect(ids({ management: [BACKUP_TARGET] })).toStrictEqual([]); + expect(ids({ management: [BACKUP_TARGET], isSingleProduct: true })).toStrictEqual([BACKUP_TARGET]); + }); + + it('should prefer the Harvester setting over the management setting with the same id', () => { + const [shown] = visibleSettings({ + harvester: [BACKUP_TARGET], management: [BACKUP_TARGET], isSingleProduct: true + }); + + expect(shown.data.source).toBe('harvester'); + }); +}); diff --git a/pkg/harvester/store/harvester-store/actions.ts b/pkg/harvester/store/harvester-store/actions.ts index 0379607a..cf606db8 100644 --- a/pkg/harvester/store/harvester-store/actions.ts +++ b/pkg/harvester/store/harvester-store/actions.ts @@ -5,7 +5,12 @@ import { COUNT, NAMESPACE, MANAGEMENT } from '@shell/config/types'; import { allHash } from '@shell/utils/promise'; import { DEV, NAMESPACE_FILTERS } from '@shell/store/prefs'; import { createNamespaceFilterKeyWithId } from '@shell/utils/namespace-filter'; +import debounce from 'lodash/debounce'; import { HCI } from '../../types'; +import { PRODUCT_NAME } from '../../config/harvester'; +import { + SERVER_FILTERED_COUNTS, countedNamespaces, filteredCountRequest, isFilteredByNamespace +} from './getters'; export default { async loadCluster({ @@ -131,6 +136,8 @@ export default { getters }, { root: true }); + dispatch('watchFilteredCounts', { clusterId: id }); + // Solve compatibility with Rancher v2.6.x, fell remove these codes after not support v2.6.x const definition = { def: false, @@ -166,4 +173,95 @@ export default { }, { root: true }); } }, + + /** + * Requests the side navigation counts of the nav items in SERVER_FILTERED_COUNTS + * (all of them by default), see getters.ts. Responses of an outdated request + * of a nav item (e.g. before a namespace filter change, or of the previous + * cluster) are ignored. + */ + async fetchFilteredCounts({ + state, commit, dispatch, getters, rootGetters + }: any, names: string[] = Object.keys(SERVER_FILTERED_COUNTS)) { + await Promise.all(names.map(async(name: string) => { + commit('filteredCountRequested', name); + + const requestId = state.filteredCountRequests[name]; + const request = filteredCountRequest(getters, rootGetters, name); + let count = request?.count ?? null; + + if (request?.url) { + try { + const res = await dispatch('request', { url: request.url }); + + // Steve leaves out `count` when nothing matches. Harvester v1.3 ignores the query and + // sends all resources without a `count`, which says nothing about the subset. + count = res?.count ?? (res?.data?.length ? null : 0); + } catch (e) { + count = null; + } + } + + if (state.filteredCountRequests[name] === requestId) { + commit('setFilteredCount', { name, count }); + } + })); + }, + + /** + * Requests the counts of SERVER_FILTERED_COUNTS, and requests them again when + * they may have changed: when the counts endpoint reports other numbers for + * their resource type (it is kept up to date through the websocket), and when + * the namespace filter changes. A change that leaves these numbers as they are + * (e.g. adding the cloud-init template label to an existing ConfigMap) shows up + * with the next change of that resource type. + */ + watchFilteredCounts({ + state, commit, dispatch, getters, rootGetters + }: any, { clusterId }: any) { + // The `loadCluster` of a cluster left while it was loading can finish after the one of the new cluster + if (rootGetters.clusterId !== clusterId) { + return; + } + + state.filteredCountWatchers.forEach((unwatch: () => void) => unwatch()); + commit('resetFilteredCounts'); + + // The watchers stay registered after leaving the cluster or the product, until the next `loadCluster` + const refresh = (names: string[]) => { + if (rootGetters.clusterId === clusterId && rootGetters.currentProduct?.inStore === PRODUCT_NAME) { + dispatch('fetchFilteredCounts', names); + } + }; + const names = Object.keys(SERVER_FILTERED_COUNTS); + const namesByType: Record = {}; + + names.forEach((name) => { + const type = getters.schemaFor(name)?.id; + + if (type) { + namesByType[type] = [...(namesByType[type] || []), name]; + } + }); + + const unwatchers = Object.entries(namesByType).map(([type, namesOfType]) => { + const refreshType = debounce(() => refresh(namesOfType), 1000); + // `all` is gone once the store module is unregistered when logging out + const unwatch = this.watch(() => JSON.stringify(getters.all?.(COUNT)?.[0]?.counts?.[type] || null), refreshType); + + return () => { + unwatch(); + refreshType.cancel(); + }; + }); + + unwatchers.push(this.watch( + () => JSON.stringify(countedNamespaces(rootGetters)), + () => refresh(names.filter((name) => isFilteredByNamespace(name, getters.schemaFor(name)))) + )); + + commit('setFilteredCountWatchers', unwatchers); + + return dispatch('fetchFilteredCounts'); + }, }; diff --git a/pkg/harvester/store/harvester-store/getters.ts b/pkg/harvester/store/harvester-store/getters.ts index ccaf5768..daa77bf8 100644 --- a/pkg/harvester/store/harvester-store/getters.ts +++ b/pkg/harvester/store/harvester-store/getters.ts @@ -4,9 +4,229 @@ import { NAMESPACE_FILTER_ALL as ALL, NAMESPACE_FILTER_ALL_ORPHANS as ALL_ORPHANS, } from '@shell/utils/namespace-filter'; -import { MANAGEMENT } from '@shell/config/types'; +import { COUNT, MANAGEMENT, NAMESPACE } from '@shell/config/types'; import { sortBy } from '@shell/utils/sort'; import { filterBy } from '@shell/utils/array'; +import { addParams } from '@shell/utils/url'; +import { HCI, DATA_VOLUME } from '../../types'; +import { BACKUP_TYPE } from '../../config/types'; +import { HCI as HCI_ANNOTATIONS } from '../../config/labels-annotations'; +import { HCI_ALLOWED_SETTINGS, HCI_SINGLE_CLUSTER_ALLOWED_SETTING } from '../../config/settings'; + +// --------------------------------------------------------------------------- +// Side navigation counts +// +// The side navigation (`Type.vue`, `type-map/getTree`) shows the number of +// resources of a nav item by calling `harvester/count({ name: