mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-11-15 13:26:15 +08:00
fix: show resource counts for all side navigation items (#1196)
* fix: show resource counts for all side navigation items
The side navigation looks up the count of a nav item by its name, but the
counts are keyed by the K8s resource type. Many Harvester nav items are
named differently than the resource they list (e.g. harvesterhci.io.volume
lists persistentvolumeclaim), so no count was shown for them. Some list
views also show only a subset of their resource type, so the plain count
would not match the list.
Override the count getter of the harvester store:
- Resolve the nav item to the resource type it lists, using the same
fallback to the configureType resource as schemaFor. This fixes Hosts,
Alertmanager Configs, Cluster Flows, Cluster Outputs, Flows, Outputs
and Secrets.
- Count only the subset shown by the list for Volumes, VM Networks,
VM Backups, VM Snapshots, Volume Snapshots, Cloud Configuration
Templates and Storage. The matches are counted per namespace once per
store list using sharedComputed.
- Like the list views, do not count resources in system namespaces when
"All Namespaces" is selected.
Steve's counts endpoint only returns the total per resource type, so the
filtered counts are computed from the store list and are only available
once that list is loaded. Until then these nav items show no count, unless
there are no resources of that type at all (then 0). The dashboard loads
PVCs and networks, the VM list PVCs and backups, the other resources are
loaded when their list is opened.
Related to: https://github.com/harvester/harvester/issues/11984
Signed-off-by: Volker Theile <vtheile@suse.com>
* refactor: get the side navigation counts from the server
Stop loading full resource lists and filtering them in the browser to
get the side navigation counts.
- Nav items listing all resources of a type use the counts endpoint,
through the resource type of the nav item.
- VM backups, VM snapshots, volume snapshots, storage classes, cloud
config templates and VM networks request their subset from
/v1/harvester with a page size of 1. These requests run again when the
counts endpoint reports other numbers for the type, and when the
namespace filter changes.
- Namespaces count the already loaded namespaces that are neither system
nor Fleet managed.
- Volumes have no count: the list hides volumes that are only marked by
annotations, which the API cannot filter on.
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
* refactor: keep the side navigation count helpers in the store getters
Move them out of their own file, next to the count getter that uses them.
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
* fix: make the side navigation count requests more robust
- Ignore a late response of the previous cluster. Resetting the request
ids when loading a cluster let it overwrite the count of the new one.
- Keep the watchers when a cluster left while it was loading finishes
loading after the new one.
- Show no count instead of 0 on Harvester v1.3, which ignores the query
and sends all resources without a count.
- Hide the storage network by its generated name. Networks created
before Harvester v1.4 do not have the hash label, and an upgrade does
not add it.
- Stop refreshing after leaving the product, cancel pending refreshes
when the watchers stop, and cope with the store module being
unregistered when logging out.
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
* fix: count only the settings the Settings page shows
The nav item showed the total of all Setting resources, but the page only
shows the allowed ones that exist in the cluster and are supported by its
version.
Move these rules into a visibleSettings getter that both the page and the
count use, so that they always agree. SettingList no longer needs its
feature flag check.
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
* test: remove a Settings page test that was committed twice
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
* fix: show the number of volumes in the side navigation
The Volumes list hides golden image volumes and the temporary volumes CDI
uses to populate a volume, so the total of the counts endpoint would be
wrong.
Golden image volumes are only marked by an annotation, which Harvester's
API cannot filter on, but each one belongs to a DataVolume owned by its VM
image. So request the volumes that are not owned by another volume, and
the DataVolumes owned by a VM image, and show the difference.
Every nav item with a list now has a number, so remove NO_COUNT.
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
---------
Signed-off-by: Volker Theile <vtheile@suse.com>
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
Co-authored-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
(cherry picked from commit 784c22e27e)
This commit is contained in:
@@ -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<string, string> = {
|
||||
[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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
[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<string, number>) => ({
|
||||
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<string, any>();
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string[]> = {};
|
||||
|
||||
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');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: <nav item> })`.
|
||||
// Many Harvester nav items are named differently than the K8s resource they
|
||||
// list (e.g. `harvesterhci.io.volume` lists `persistentvolumeclaim`, see
|
||||
// `configureType(..., { resource })` in config/harvester-cluster.js). Some
|
||||
// list views additionally show only a subset of that resource type.
|
||||
//
|
||||
// All counts come from the server: Steve's counts endpoint for nav items
|
||||
// listing all resources of a type, and a filtered request with a page size of 1
|
||||
// for the nav items in SERVER_FILTERED_COUNTS. Resource lists are not loaded
|
||||
// for the counts: only Namespaces and Settings count their list, which is always
|
||||
// loaded with the cluster.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Nav items whose list view shows only a subset of the underlying resource
|
||||
* type, with the query parameters of that subset. Each query must match the
|
||||
* filter of the list view, so keep them in sync with the files in list/.
|
||||
*
|
||||
* Steve's counts endpoint only knows the total per resource type, so these
|
||||
* counts are requested from Harvester's API (`/v1/harvester`) with the query
|
||||
* and a page size of 1: the `count` of the response is the number of matches
|
||||
* (see `fetchFilteredCounts` in actions.ts).
|
||||
*
|
||||
* The queries rely on the filtering of Harvester's API, which does not use
|
||||
* Steve's SQL cache: `filter=a.b=c` matches when the field contains the value,
|
||||
* `filter=a.b!=` when the field is not set, and a list field matches when any
|
||||
* of its items matches. Rewrite them when Harvester's API moves to the SQL
|
||||
* cache: it only filters on indexed fields, and it ignores `labelSelector`
|
||||
* without an error, so these counts would silently become the totals.
|
||||
*/
|
||||
export const SERVER_FILTERED_COUNTS: Record<string, Record<string, string>> = {
|
||||
// VM backups and VM snapshots are both VirtualMachineBackup resources
|
||||
[HCI.BACKUP]: { filter: `spec.type!=${ BACKUP_TYPE.SNAPSHOT }` },
|
||||
[HCI.VM_SNAPSHOT]: { filter: `spec.type=${ BACKUP_TYPE.SNAPSHOT }` },
|
||||
// VM backups and snapshots also create VolumeSnapshots, owned by the VirtualMachineBackup
|
||||
[HCI.SNAPSHOT]: { filter: 'metadata.ownerReferences.kind=PersistentVolumeClaim' },
|
||||
// Every VM image creates an internal backing image StorageClass
|
||||
[HCI.STORAGE]: { filter: 'parameters.backingImage!=' },
|
||||
// Only ConfigMaps labeled as cloud-init template
|
||||
[HCI.CLOUD_TEMPLATE]: { labelSelector: HCI_ANNOTATIONS.CLOUD_INIT },
|
||||
// Hides the storage network: Harvester creates it with this generated name, and its annotation cannot be filtered on
|
||||
[HCI.NETWORK_ATTACHMENT]: { filter: 'metadata.generateName!=storagenetwork-' },
|
||||
// Hides the temporary volumes CDI uses to populate a volume, which are owned by that volume
|
||||
[HCI.VOLUME]: { filter: 'metadata.ownerReferences.kind!=PersistentVolumeClaim' },
|
||||
// Not a nav item: the golden image volumes that the list of Volumes also hides, see `volumeCount`
|
||||
[DATA_VOLUME]: { filter: 'metadata.ownerReferences.kind=VirtualMachineImage' },
|
||||
};
|
||||
|
||||
/**
|
||||
* Nav items whose list view lists the resources of all namespaces whatever the
|
||||
* namespace filter is (`:ignore-filter="true"` or `:namespaced="false"` on
|
||||
* `ResourceTable`). Keep in sync with the files in list/.
|
||||
*/
|
||||
export const IGNORE_NAMESPACE_FILTER = new Set<string>([
|
||||
HCI.NETWORK_ATTACHMENT,
|
||||
HCI.CLUSTER_FLOW,
|
||||
HCI.CLUSTER_OUTPUT,
|
||||
HCI.FLOW,
|
||||
HCI.OUTPUT,
|
||||
HCI.ADD_ONS,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The namespaces to count for a namespaced resource type, or `null` to count
|
||||
* all of them. Mirrors the namespace filter of `ResourceTable`: even with
|
||||
* "All Namespaces" selected, the Harvester product (`hideSystemResources`)
|
||||
* does not list resources in system namespaces, e.g. the PVCs of
|
||||
* cattle-monitoring-system.
|
||||
*/
|
||||
export function countedNamespaces(rootGetters: any): string[] | null {
|
||||
if (rootGetters.isAllNamespaces && !rootGetters.currentProduct?.hideSystemResources) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `namespaces()` computes the active namespaces on demand, so a count is available right
|
||||
// away instead of only once something else has triggered that computation.
|
||||
// An empty result is a valid selection (e.g. a project without namespaces): keep the
|
||||
// empty array so that nothing is counted instead of the cluster-wide total.
|
||||
return Object.keys(rootGetters.namespaces());
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the count of a nav item depends on the namespace filter.
|
||||
*/
|
||||
export function isFilteredByNamespace(name: string, schema: any): boolean {
|
||||
return !!schema?.attributes?.namespaced && !IGNORE_NAMESPACE_FILTER.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The request for the count of a nav item in SERVER_FILTERED_COUNTS:
|
||||
* - `{ url }` to request it,
|
||||
* - `{ count: 0 }` if nothing can match (no namespace selected),
|
||||
* - `null` if the resource type is unknown or cannot be listed.
|
||||
*/
|
||||
export function filteredCountRequest(getters: any, rootGetters: any, name: string): { url?: string, count?: number } | null {
|
||||
const schema = getters.schemaFor(name);
|
||||
|
||||
if (!schema?.links?.collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params: Record<string, string> = { ...SERVER_FILTERED_COUNTS[name], pagesize: '1' };
|
||||
|
||||
if (isFilteredByNamespace(name, schema)) {
|
||||
const namespaces = countedNamespaces(rootGetters);
|
||||
|
||||
if (namespaces?.length === 0) {
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
if (namespaces) {
|
||||
params.projectsornamespaces = namespaces.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
return { url: addParams(getters.urlFor(schema.id), params) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of all resources of `resourceType`, taken from Steve's counts
|
||||
* endpoint. It is loaded with the cluster, so the count is available right
|
||||
* away. `null` if the resource type is unknown.
|
||||
*/
|
||||
function unfilteredCount(getters: any, resourceType: string, namespaces: string[] | null): number | null {
|
||||
const count = getters.all(COUNT)?.[0]?.counts?.[resourceType];
|
||||
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!namespaces) {
|
||||
return count.summary?.count || 0;
|
||||
}
|
||||
|
||||
return namespaces.reduce((sum, namespace) => sum + (count.namespaces?.[namespace]?.count || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of the Namespaces list. With `hideSystemResources` it hides system and
|
||||
* Fleet managed namespaces (`list/namespace.vue` of the shell), which the
|
||||
* counts endpoint cannot tell apart. The namespaces are always loaded with the
|
||||
* cluster (namespace filter), so the count comes from them. `null` while they
|
||||
* are not loaded.
|
||||
*/
|
||||
function namespaceCount(getters: any): number | null {
|
||||
if (!getters.haveAll(NAMESPACE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getters.all(NAMESPACE).filter((namespace: any) => !namespace.isSystem && !namespace.isFleetManaged).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of the Volumes list, which also hides golden image volumes. These are
|
||||
* only marked by an annotation, which Harvester's API cannot filter on, but each
|
||||
* one belongs to a DataVolume owned by its VM image. So they are counted through
|
||||
* these DataVolumes and subtracted. `null` until both numbers are known.
|
||||
*/
|
||||
function volumeCount(state: any): number | null {
|
||||
const volumes = state.filteredCounts?.[HCI.VOLUME] ?? null;
|
||||
const goldenImageVolumes = state.filteredCounts?.[DATA_VOLUME] ?? null;
|
||||
|
||||
if (volumes === null || goldenImageVolumes === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A DataVolume exists briefly before its volume
|
||||
return Math.max(volumes - goldenImageVolumes, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count of the Settings page, which shows only some of the settings (see the
|
||||
* `visibleSettings` getter). `null` while the settings are not loaded.
|
||||
*/
|
||||
function settingCount(getters: any): number | null {
|
||||
return getters.haveAll(HCI.SETTING) ? getters.visibleSettings.length : null;
|
||||
}
|
||||
|
||||
function resourceCount(state: any, rootGetters: any, getters: any, typeObj: any): number | null {
|
||||
const type = typeObj?.name;
|
||||
|
||||
if (!type) {
|
||||
throw new Error(`Resource type required to calc count: ${ JSON.stringify(typeObj) }`);
|
||||
}
|
||||
|
||||
if (type === HCI.VOLUME) {
|
||||
return volumeCount(state);
|
||||
}
|
||||
|
||||
// Requested by `fetchFilteredCounts` (actions.ts), `null` until the response arrives
|
||||
if (SERVER_FILTERED_COUNTS[type]) {
|
||||
return state.filteredCounts?.[type] ?? null;
|
||||
}
|
||||
|
||||
if (type === NAMESPACE && rootGetters.currentProduct?.hideSystemResources) {
|
||||
return namespaceCount(getters);
|
||||
}
|
||||
|
||||
if (type === HCI.SETTING) {
|
||||
return settingCount(getters);
|
||||
}
|
||||
|
||||
// schemaFor falls back to the resource of `configureType`, so this resolves
|
||||
// the nav item to the K8s resource type it lists
|
||||
const schema = getters.schemaFor(type);
|
||||
const resourceType = schema?.id || type;
|
||||
const namespaces = isFilteredByNamespace(type, schema) ? countedNamespaces(rootGetters) : null;
|
||||
|
||||
return unfilteredCount(getters, resourceType, namespaces);
|
||||
}
|
||||
|
||||
export default {
|
||||
namespaceFilterOptions: (state: any, getters: any, rootState: any, rootGetters: any) => ({
|
||||
@@ -125,6 +345,26 @@ export default {
|
||||
return projectsInAllClusters.filter((project: any) => project.spec.clusterName === clusterId && project.nameDisplay !== 'System');
|
||||
},
|
||||
|
||||
/**
|
||||
* The settings shown by the Settings page: the allowed ones that exist in the
|
||||
* cluster and are supported by its version. The page and the count of its nav
|
||||
* item both use this, so that they always agree.
|
||||
*/
|
||||
visibleSettings(state: any, getters: any, rootState: any, rootGetters: any) {
|
||||
const settings = [
|
||||
...(rootGetters.isSingleProduct ? rootGetters['management/all'](MANAGEMENT.SETTING) : []),
|
||||
...getters.all(HCI.SETTING),
|
||||
];
|
||||
const settingsById = Object.fromEntries(settings.map((setting: any) => [setting.id, setting]));
|
||||
const allowed = rootGetters.isStandaloneHarvester ? { ...HCI_ALLOWED_SETTINGS, ...HCI_SINGLE_CLUSTER_ALLOWED_SETTING } : HCI_ALLOWED_SETTINGS;
|
||||
|
||||
return Object.entries(allowed)
|
||||
.filter(([id, config]: any) => settingsById[id] && (!config.featureFlag || rootGetters['harvester-common/getFeatureEnabled'](config.featureFlag)))
|
||||
.map(([id, config]: any) => ({
|
||||
id, config, data: settingsById[id]
|
||||
}));
|
||||
},
|
||||
|
||||
// Few harvester resources name and REAL resource are different. E.g. HCI.NETWORK_ATTACHMENT page resource is NETWORK_ATTACHMENT.
|
||||
// Check in config/harvester-cluster.js for more details.
|
||||
// We need to look up the schema by resource name, and fallback to find using real resource name
|
||||
@@ -145,4 +385,19 @@ export default {
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
// Overrides the `count` getter of the dashboard store, which looks up the
|
||||
// count by the nav item name only. See "Side navigation counts" above.
|
||||
count: (state: any, getters: any, rootState: any, rootGetters: any) => (typeObj: any) => {
|
||||
// Like the `count` getter of the dashboard store: a nav item configured with `subTypes`
|
||||
// shows the sum of the counts of these types, e.g. the Migration Overview
|
||||
// (`forklift-create`) shows the number of Migration Plans.
|
||||
const subTypes = rootGetters['type-map/optionsFor']?.(typeObj?.name)?.subTypes || [];
|
||||
|
||||
if (subTypes.length) {
|
||||
return subTypes.reduce((acc: number, type: string) => acc + resourceCount(state, rootGetters, getters, { name: type }), 0);
|
||||
}
|
||||
|
||||
return resourceCount(state, rootGetters, getters, typeObj);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,6 +9,15 @@ import actions from './actions';
|
||||
|
||||
const harvesterFactory = (): CoreStoreSpecifics => {
|
||||
const steveFactory = SteveFactory(null, null);
|
||||
const steveState = steveFactory.state;
|
||||
|
||||
steveFactory.state = () => ({
|
||||
...steveState(),
|
||||
// Side navigation counts requested by `fetchFilteredCounts`, see getters.ts
|
||||
filteredCounts: {},
|
||||
filteredCountRequests: {},
|
||||
filteredCountWatchers: [],
|
||||
});
|
||||
|
||||
steveFactory.getters = {
|
||||
...steveFactory.getters,
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
export default {};
|
||||
//@ts-nocheck
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
// Side navigation counts requested by `fetchFilteredCounts`, see getters.ts
|
||||
export default {
|
||||
filteredCountRequested(state: any, name: string) {
|
||||
state.filteredCountRequests[name] = (state.filteredCountRequests[name] || 0) + 1;
|
||||
},
|
||||
|
||||
setFilteredCount(state: any, { name, count }: any) {
|
||||
state.filteredCounts = { ...state.filteredCounts, [name]: count };
|
||||
},
|
||||
|
||||
// Keeps `filteredCountRequests`, so that a response of the previous cluster is still outdated
|
||||
resetFilteredCounts(state: any) {
|
||||
state.filteredCounts = {};
|
||||
},
|
||||
|
||||
setFilteredCountWatchers(state: any, unwatchers: (() => void)[]) {
|
||||
state.filteredCountWatchers = markRaw(unwatchers);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user