From 2f0874bb9c4f1e4f8e9fc49872733533eb5e62df Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 16 Jan 2025 10:38:15 +0100 Subject: [PATCH] feat: dispatch resources count (#10455) Signed-off-by: Philippe Martin --- packages/api/src/kubernetes-resource-count.ts | 23 +++++ packages/main/src/plugin/index.ts | 5 + .../context-resource-registry.spec.ts | 13 ++- .../kubernetes/context-resource-registry.ts | 20 ++-- .../contexts-manager-experimental.spec.ts | 93 ++++++++++++++++++- .../contexts-manager-experimental.ts | 30 +++++- .../contexts-states-dispatcher.spec.ts | 22 +++-- .../kubernetes/contexts-states-dispatcher.ts | 10 ++ .../plugin/kubernetes/kubernetes-client.ts | 8 ++ .../kubernetes/resource-informer.spec.ts | 64 ++++++++++++- .../plugin/kubernetes/resource-informer.ts | 26 ++++-- packages/preload/src/index.ts | 5 + ...netes-resources-count-experimental.spec.ts | 90 ++++++++++++++++++ ...s-resources-count-non-experimental.spec.ts | 75 +++++++++++++++ .../src/stores/kubernetes-resources-count.ts | 62 +++++++++++++ 15 files changed, 516 insertions(+), 30 deletions(-) create mode 100644 packages/api/src/kubernetes-resource-count.ts create mode 100644 packages/renderer/src/stores/kubernetes-resources-count-experimental.spec.ts create mode 100644 packages/renderer/src/stores/kubernetes-resources-count-non-experimental.spec.ts create mode 100644 packages/renderer/src/stores/kubernetes-resources-count.ts diff --git a/packages/api/src/kubernetes-resource-count.ts b/packages/api/src/kubernetes-resource-count.ts new file mode 100644 index 0000000000000..712b165cfa865 --- /dev/null +++ b/packages/api/src/kubernetes-resource-count.ts @@ -0,0 +1,23 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface ResourceCount { + contextName: string; + resourceName: string; + count: number; +} diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index 24284a2551c8b..53479b8bb41a4 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -93,6 +93,7 @@ import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js'; import type { ContextPermission } from '/@api/kubernetes-contexts-permissions.js'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js'; import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model.js'; +import type { ResourceCount } from '/@api/kubernetes-resource-count.js'; import type { ManifestCreateOptions, ManifestInspectInfo, ManifestPushOptions } from '/@api/manifest-info.js'; import type { NetworkInspectInfo } from '/@api/network-info.js'; import type { NotificationCard, NotificationCardOptions } from '/@api/notification.js'; @@ -2602,6 +2603,10 @@ export class PluginSystem { return kubernetesClient.getContextsPermissions(); }); + this.ipcHandle('kubernetes:getResourcesCount', async (_listener): Promise => { + return kubernetesClient.getResourcesCount(); + }); + const kubernetesExecCallbackMap = new Map< number, { onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void } diff --git a/packages/main/src/plugin/kubernetes/context-resource-registry.spec.ts b/packages/main/src/plugin/kubernetes/context-resource-registry.spec.ts index af6edfa5b52a8..52aed7b99b009 100644 --- a/packages/main/src/plugin/kubernetes/context-resource-registry.spec.ts +++ b/packages/main/src/plugin/kubernetes/context-resource-registry.spec.ts @@ -27,5 +27,16 @@ test('ContextResourceRegistry', () => { expect(registry.get('context1', 'resource1')).toEqual('value1'); registry.set('context1', 'resource2', 'value2'); - expect(registry.getAll()).toEqual(['value1', 'value2']); + expect(registry.getAll()).toEqual([ + { + contextName: 'context1', + resourceName: 'resource1', + value: 'value1', + }, + { + contextName: 'context1', + resourceName: 'resource2', + value: 'value2', + }, + ]); }); diff --git a/packages/main/src/plugin/kubernetes/context-resource-registry.ts b/packages/main/src/plugin/kubernetes/context-resource-registry.ts index 7fc407adedf18..ea0926ebb5132 100644 --- a/packages/main/src/plugin/kubernetes/context-resource-registry.ts +++ b/packages/main/src/plugin/kubernetes/context-resource-registry.ts @@ -16,6 +16,12 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ +export interface Details { + contextName: string; + resourceName: string; + value: T; +} + // ContextResourceRegistry stores objects of type T for contexts and resources export class ContextResourceRegistry { #registry: Map> = new Map(); @@ -31,11 +37,13 @@ export class ContextResourceRegistry { return this.#registry.get(context)?.get(resource); } - getAll(): T[] { - const result: T[] = []; - for (const contexts of this.#registry.values()) { - result.push(...contexts.values()); - } - return result; + getAll(): Details[] { + return Array.from(this.#registry.entries()).flatMap(([contextName, resources]) => { + return Array.from(resources.entries()).map(([resourceName, value]) => ({ + contextName, + resourceName, + value, + })); + }); } } diff --git a/packages/main/src/plugin/kubernetes/contexts-manager-experimental.spec.ts b/packages/main/src/plugin/kubernetes/contexts-manager-experimental.spec.ts index d35c0ca5f62e1..d7f106a08abef 100644 --- a/packages/main/src/plugin/kubernetes/contexts-manager-experimental.spec.ts +++ b/packages/main/src/plugin/kubernetes/contexts-manager-experimental.spec.ts @@ -16,7 +16,7 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import type { Cluster, KubernetesObject } from '@kubernetes/client-node'; +import type { Cluster, KubernetesObject, ObjectCache } from '@kubernetes/client-node'; import { KubeConfig } from '@kubernetes/client-node'; import { beforeEach, describe, expect, test, vi } from 'vitest'; @@ -27,7 +27,7 @@ import { ContextsManagerExperimental } from './contexts-manager-experimental.js' import { KubeConfigSingleContext } from './kubeconfig-single-context.js'; import type { ResourceFactory } from './resource-factory.js'; import { ResourceFactoryBase } from './resource-factory.js'; -import type { ResourceInformer } from './resource-informer.js'; +import type { CacheUpdatedEvent, ResourceInformer } from './resource-informer.js'; const onCacheUpdatedMock = vi.fn(); const onOfflineMock = vi.fn(); @@ -288,7 +288,94 @@ describe('HealthChecker pass and PermissionsChecker resturns a value', async () }), ); await manager.update(kc); - expect(startMock).toHaveBeenCalledTimes(2); // on resource1 for each context + expect(startMock).toHaveBeenCalledTimes(2); // on resource1 for each context (resource2 does not have informer declared) + }); + + describe('informer is started', async () => { + let kcSingle1: KubeConfigSingleContext; + let kcSingle2: KubeConfigSingleContext; + beforeEach(async () => { + kcSingle1 = new KubeConfigSingleContext(kc, context1); + kcSingle2 = new KubeConfigSingleContext(kc, context2); + let call = 0; + onreachableMock.mockImplementation(f => { + call++; + f({ + kubeConfig: call === 1 ? kcSingle1 : kcSingle2, + contextName: call === 1 ? 'context1' : 'context2', + checking: false, + reachable: call === 1, + } as ContextHealthState); + }); + onPermissionResultMock.mockImplementation(f => + f({ + kubeConfig: call === 1 ? kcSingle1 : kcSingle2, + resources: ['resource1', 'resource2'], + permitted: true, + }), + ); + }); + + test('cache updated with a change on resource count', async () => { + onCacheUpdatedMock.mockImplementation(f => { + f({ + kubeconfig: kcSingle1, + resourceName: 'resource1', + countChanged: true, + } as CacheUpdatedEvent); + }); + const onResourceUpdatedCB = vi.fn(); + const onResourceCountUpdatedCB = vi.fn(); + manager.onResourceUpdated(onResourceUpdatedCB); + manager.onResourceCountUpdated(onResourceCountUpdatedCB); + await manager.update(kc); + // called twice: on resource1 for each context + expect(startMock).toHaveBeenCalledTimes(2); + expect(onResourceUpdatedCB).toHaveBeenCalledTimes(2); + expect(onResourceCountUpdatedCB).toHaveBeenCalledTimes(2); + }); + + test('cache updated without a change on resource count', async () => { + onCacheUpdatedMock.mockImplementation(f => { + f({ + kubeconfig: kcSingle1, + resourceName: 'resource1', + countChanged: false, + } as CacheUpdatedEvent); + }); + const onResourceUpdatedCB = vi.fn(); + const onResourceCountUpdatedCB = vi.fn(); + manager.onResourceUpdated(onResourceUpdatedCB); + manager.onResourceCountUpdated(onResourceCountUpdatedCB); + await manager.update(kc); + // called twice: on resource1 for each context + expect(startMock).toHaveBeenCalledTimes(2); + expect(onResourceUpdatedCB).toHaveBeenCalledTimes(2); + expect(onResourceCountUpdatedCB).not.toHaveBeenCalled(); + }); + + test('getResourcesCount', async () => { + const listMock = vi.fn(); + startMock.mockReturnValue({ + list: listMock, + get: vi.fn(), + } as ObjectCache); + listMock.mockReturnValue([{}, {}]); + await manager.update(kc); + const counts = manager.getResourcesCount(); + expect(counts).toEqual([ + { + contextName: 'context1', + resourceName: 'resource1', + count: 2, + }, + { + contextName: 'context2', + resourceName: 'resource1', + count: 2, + }, + ]); + }); }); }); diff --git a/packages/main/src/plugin/kubernetes/contexts-manager-experimental.ts b/packages/main/src/plugin/kubernetes/contexts-manager-experimental.ts index 73232b60f51b4..4dbda08dac14b 100644 --- a/packages/main/src/plugin/kubernetes/contexts-manager-experimental.ts +++ b/packages/main/src/plugin/kubernetes/contexts-manager-experimental.ts @@ -19,6 +19,7 @@ import type { KubeConfig, KubernetesObject, ObjectCache } from '@kubernetes/client-node'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js'; +import type { ResourceCount } from '/@api/kubernetes-resource-count.js'; import type { Event } from '../events/emitter.js'; import { Emitter } from '../events/emitter.js'; @@ -62,6 +63,12 @@ export class ContextsManagerExperimental { #onContextDelete = new Emitter(); onContextDelete: Event = this.#onContextDelete.event; + #onResourceUpdated = new Emitter<{ contextName: string; resourceName: string }>(); + onResourceUpdated: Event<{ contextName: string; resourceName: string }> = this.#onResourceUpdated.event; + + #onResourceCountUpdated = new Emitter<{ contextName: string; resourceName: string }>(); + onResourceCountUpdated: Event<{ contextName: string; resourceName: string }> = this.#onResourceCountUpdated.event; + constructor() { this.#resourceFactoryHandler = new ResourceFactoryHandler(); for (const resourceFactory of this.getResourceFactories()) { @@ -123,8 +130,17 @@ export class ContextsManagerExperimental { } const informer = factory.informer.createInformer(event.kubeConfig); this.#informers.set(contextName, resource, informer); - informer.onCacheUpdated((_e: CacheUpdatedEvent) => { - /* send event to dispatcher */ + informer.onCacheUpdated((e: CacheUpdatedEvent) => { + this.#onResourceUpdated.fire({ + contextName: e.kubeconfig.getKubeConfig().currentContext, + resourceName: e.resourceName, + }); + if (e.countChanged) { + this.#onResourceCountUpdated.fire({ + contextName: e.kubeconfig.getKubeConfig().currentContext, + resourceName: e.resourceName, + }); + } }); informer.onOffline((_e: OfflineEvent) => { /* send event to dispatcher */ @@ -180,6 +196,14 @@ export class ContextsManagerExperimental { return result; } + getResourcesCount(): ResourceCount[] { + return this.#objectCaches.getAll().map(informer => ({ + contextName: informer.contextName, + resourceName: informer.resourceName, + count: informer.value.list().length, + })); + } + getContextsGeneralState(): Map { return new Map(); } @@ -232,7 +256,7 @@ export class ContextsManagerExperimental { // disposeAllInformers disposes all informers and removes them from registry private disposeAllInformers(): void { for (const informer of this.#informers.getAll()) { - informer.dispose(); + informer.value.dispose(); } } } diff --git a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts index 500f9088164f9..9b4f4b7f9a113 100644 --- a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts +++ b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts @@ -34,6 +34,7 @@ test('ContextsStatesDispatcher should call updateHealthStates when onContextHeal onContextDelete: vi.fn(), getHealthCheckersStates: vi.fn(), getPermissions: vi.fn(), + onResourceCountUpdated: vi.fn(), } as unknown as ContextsManagerExperimental; const apiSender: ApiSenderType = { send: vi.fn(), @@ -61,6 +62,7 @@ test('ContextsStatesDispatcher should call updatePermissions when onContextPermi onContextDelete: vi.fn(), getHealthCheckersStates: vi.fn(), getPermissions: vi.fn(), + onResourceCountUpdated: vi.fn(), } as unknown as ContextsManagerExperimental; const apiSender: ApiSenderType = { send: vi.fn(), @@ -87,6 +89,7 @@ test('ContextsStatesDispatcher should call updateHealthStates and updatePermissi onContextDelete: onContextDeleteMock, getHealthCheckersStates: vi.fn(), getPermissions: vi.fn(), + onResourceCountUpdated: vi.fn(), } as unknown as ContextsManagerExperimental; const apiSender: ApiSenderType = { send: vi.fn(), @@ -239,18 +242,21 @@ test('getContextsPermissions should return the values as an array', () => { }); test('updatePermissions should call apiSender.send with kubernetes-contexts-permissions', () => { - const manager: ContextsManagerExperimental = { - onContextHealthStateChange: vi.fn(), - onContextPermissionResult: vi.fn(), - onContextDelete: vi.fn(), - getHealthCheckersStates: vi.fn(), - getPermissions: vi.fn(), - } as unknown as ContextsManagerExperimental; + const manager: ContextsManagerExperimental = {} as ContextsManagerExperimental; const apiSender: ApiSenderType = { send: vi.fn(), } as unknown as ApiSenderType; const dispatcher = new ContextsStatesDispatcher(manager, apiSender); - vi.spyOn(dispatcher, 'getContextsPermissions').mockReturnValue([]); dispatcher.updatePermissions(); expect(vi.mocked(apiSender.send)).toHaveBeenCalledWith('kubernetes-contexts-permissions'); }); + +test('updateResourcesCount should call apiSender.send with kubernetes-resources-count', () => { + const manager: ContextsManagerExperimental = {} as ContextsManagerExperimental; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + dispatcher.updateResourcesCount(); + expect(vi.mocked(apiSender.send)).toHaveBeenCalledWith('kubernetes-resources-count'); +}); diff --git a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts index 968fecf3bf055..4c8c871680a46 100644 --- a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts +++ b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts @@ -18,6 +18,7 @@ import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js'; import type { ContextPermission } from '/@api/kubernetes-contexts-permissions.js'; +import type { ResourceCount } from '/@api/kubernetes-resource-count.js'; import type { ApiSenderType } from '../api.js'; import type { ContextHealthState } from './context-health-checker.js'; @@ -38,6 +39,7 @@ export class ContextsStatesDispatcher { this.updateHealthStates(); this.updatePermissions(); }); + this.manager.onResourceCountUpdated(() => this.updateResourcesCount()); } updateHealthStates(): void { @@ -70,4 +72,12 @@ export class ContextsStatesDispatcher { })); }); } + + updateResourcesCount(): void { + this.apiSender.send(`kubernetes-resources-count`); + } + + getResourcesCount(): ResourceCount[] { + return this.manager.getResourcesCount(); + } } diff --git a/packages/main/src/plugin/kubernetes/kubernetes-client.ts b/packages/main/src/plugin/kubernetes/kubernetes-client.ts index 1035dccc459c1..37971f474711d 100644 --- a/packages/main/src/plugin/kubernetes/kubernetes-client.ts +++ b/packages/main/src/plugin/kubernetes/kubernetes-client.ts @@ -75,6 +75,7 @@ import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js'; import type { ContextPermission } from '/@api/kubernetes-contexts-permissions.js'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js'; import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model.js'; +import type { ResourceCount } from '/@api/kubernetes-resource-count.js'; import type { V1Route } from '/@api/openshift-types.js'; import type { ApiSenderType } from '../api.js'; @@ -1691,4 +1692,11 @@ export class KubernetesClient { } return this.contextsStatesDispatcher.getContextsPermissions(); } + + public getResourcesCount(): ResourceCount[] { + if (!this.contextsStatesDispatcher) { + throw new Error('contextsStatesDispatcher is undefined. This should not happen in Kubernetes experimental'); + } + return this.contextsStatesDispatcher.getResourcesCount(); + } } diff --git a/packages/main/src/plugin/kubernetes/resource-informer.spec.ts b/packages/main/src/plugin/kubernetes/resource-informer.spec.ts index 34f5eec4da75e..ab8454c499e9e 100644 --- a/packages/main/src/plugin/kubernetes/resource-informer.spec.ts +++ b/packages/main/src/plugin/kubernetes/resource-informer.spec.ts @@ -25,7 +25,7 @@ import type { User, V1ObjectMeta, } from '@kubernetes/client-node'; -import { ERROR, KubeConfig } from '@kubernetes/client-node'; +import { DELETE, ERROR, KubeConfig, UPDATE } from '@kubernetes/client-node'; import { expect, test, vi } from 'vitest'; import { KubeConfigSingleContext } from './kubeconfig-single-context.js'; @@ -96,7 +96,7 @@ test('ResourceInformer should eventually return the list of resources', async () }); }); -test('ResourceInformer should fire onCacheUpdated event', async () => { +test('ResourceInformer should fire onCacheUpdated event with countChanged to true when informer is started an resources exist', async () => { const kc = new KubeConfig(); kc.loadFromOptions(kcWith2contexts); const listFn = vi.fn(); @@ -108,7 +108,65 @@ test('ResourceInformer should fire onCacheUpdated event', async () => { informer.onCacheUpdated(onCacheUpdatedCB); informer.start(); await vi.waitFor(() => { - expect(onCacheUpdatedCB).toHaveBeenCalledWith({ kubeconfig, resourceName: 'myresource' }); + expect(onCacheUpdatedCB).toHaveBeenCalledWith({ kubeconfig, resourceName: 'myresource', countChanged: true }); + }); +}); + +test('ResourceInformer should fire onCacheUpdated event with countChanged to true when resources are deleted', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const items = [ + { metadata: { name: 'res1', namespace: 'ns1' } }, + { metadata: { name: 'res2', namespace: 'ns1' } }, + ] as MyResource[]; + listFn.mockResolvedValue({ items: items }); + const informer = new TestResourceInformer(kubeconfig, '/a/path', listFn, 'myresource'); + const getListWatchOnMock = vi.fn(); + vi.spyOn(informer, 'getListWatch').mockReturnValue({ + on: getListWatchOnMock, + start: vi.fn().mockResolvedValue({}), + } as unknown as ListWatch); + getListWatchOnMock.mockImplementation((event: string, f: (obj: MyResource) => void) => { + if (event === DELETE) { + f(items[0]!); + } + }); + const onCacheUpdatedCB = vi.fn(); + informer.onCacheUpdated(onCacheUpdatedCB); + informer.start(); + await vi.waitFor(() => { + expect(onCacheUpdatedCB).toHaveBeenCalledWith({ kubeconfig, resourceName: 'myresource', countChanged: true }); + }); +}); + +test('ResourceInformer should fire onCacheUpdated event with countChanged to false when resources are updated', async () => { + const kc = new KubeConfig(); + kc.loadFromOptions(kcWith2contexts); + const listFn = vi.fn(); + const kubeconfig = new KubeConfigSingleContext(kc, contexts[0]!); + const items = [ + { metadata: { name: 'res1', namespace: 'ns1' } }, + { metadata: { name: 'res2', namespace: 'ns1' } }, + ] as MyResource[]; + listFn.mockResolvedValue({ items: items }); + const informer = new TestResourceInformer(kubeconfig, '/a/path', listFn, 'myresource'); + const getListWatchOnMock = vi.fn(); + vi.spyOn(informer, 'getListWatch').mockReturnValue({ + on: getListWatchOnMock, + start: vi.fn().mockResolvedValue({}), + } as unknown as ListWatch); + getListWatchOnMock.mockImplementation((event: string, f: (obj: MyResource) => void) => { + if (event === UPDATE) { + f({ metadata: { ...items[0]!.metadata, resourceVersion: '2' } }); + } + }); + const onCacheUpdatedCB = vi.fn(); + informer.onCacheUpdated(onCacheUpdatedCB); + informer.start(); + await vi.waitFor(() => { + expect(onCacheUpdatedCB).toHaveBeenCalledWith({ kubeconfig, resourceName: 'myresource', countChanged: false }); }); }); diff --git a/packages/main/src/plugin/kubernetes/resource-informer.ts b/packages/main/src/plugin/kubernetes/resource-informer.ts index 327f5225b7413..0d460c917aa12 100644 --- a/packages/main/src/plugin/kubernetes/resource-informer.ts +++ b/packages/main/src/plugin/kubernetes/resource-informer.ts @@ -17,7 +17,7 @@ ***********************************************************************/ import type { Informer, KubernetesObject, ListPromise, ObjectCache } from '@kubernetes/client-node'; -import { CHANGE, ERROR, ListWatch, Watch } from '@kubernetes/client-node'; +import { ADD, DELETE, ERROR, ListWatch, UPDATE, Watch } from '@kubernetes/client-node'; import type { Disposable } from '@podman-desktop/api'; import type { Event } from '../events/emitter.js'; @@ -29,8 +29,9 @@ interface BaseEvent { resourceName: string; } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface CacheUpdatedEvent extends BaseEvent {} +export interface CacheUpdatedEvent extends BaseEvent { + countChanged: boolean; +} export interface OfflineEvent extends BaseEvent { offline: boolean; @@ -66,12 +67,25 @@ export class ResourceInformer implements Disposable const internalInformer = this.getListWatch(this.#path, this.#listFn); this.#informer = internalInformer; - // We may replace the unique CHANGE handler - // by the ADD/UPDATE/DELETE handlers to get more fine-grained information on changes - this.#informer.on(CHANGE, (_obj: T) => { + this.#informer.on(UPDATE, (_obj: T) => { + this.#onCacheUpdated.fire({ + kubeconfig: this.#kubeConfig, + resourceName: this.#resourceName, + countChanged: false, + }); + }); + this.#informer.on(ADD, (_obj: T) => { + this.#onCacheUpdated.fire({ + kubeconfig: this.#kubeConfig, + resourceName: this.#resourceName, + countChanged: true, + }); + }); + this.#informer.on(DELETE, (_obj: T) => { this.#onCacheUpdated.fire({ kubeconfig: this.#kubeConfig, resourceName: this.#resourceName, + countChanged: true, }); }); // This is issued when there is an error diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 5e386bb8654ba..3903412c467c7 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -71,6 +71,7 @@ import type { ContextHealth } from '/@api/kubernetes-contexts-healths'; import type { ContextPermission } from '/@api/kubernetes-contexts-permissions'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states'; import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model'; +import type { ResourceCount } from '/@api/kubernetes-resource-count'; import type { ManifestCreateOptions, ManifestInspectInfo, ManifestPushOptions } from '/@api/manifest-info'; import type { NetworkInspectInfo } from '/@api/network-info'; import type { NotificationCard, NotificationCardOptions } from '/@api/notification'; @@ -1881,6 +1882,10 @@ export function initExposure(): void { return ipcInvoke('kubernetes:getContextsPermissions'); }); + contextBridge.exposeInMainWorld('kubernetesGetResourcesCount', async (): Promise => { + return ipcInvoke('kubernetes:getResourcesCount'); + }); + contextBridge.exposeInMainWorld('kubernetesGetClusters', async (): Promise => { return ipcInvoke('kubernetes-client:getClusters'); }); diff --git a/packages/renderer/src/stores/kubernetes-resources-count-experimental.spec.ts b/packages/renderer/src/stores/kubernetes-resources-count-experimental.spec.ts new file mode 100644 index 0000000000000..1a66e5c9210fe --- /dev/null +++ b/packages/renderer/src/stores/kubernetes-resources-count-experimental.spec.ts @@ -0,0 +1,90 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { get } from 'svelte/store'; +import { beforeAll, expect, test, vi } from 'vitest'; + +import type { ResourceCount } from '/@api/kubernetes-resource-count'; + +import { kubernetesResourcesCount, kubernetesResourcesCountStore } from './kubernetes-resources-count'; + +const callbacks = new Map Promise>(); +const eventEmitter = { + receive: (message: string, callback: () => Promise) => { + callbacks.set(message, callback); + }, +}; + +beforeAll(() => { + Object.defineProperty(global, 'window', { + value: { + kubernetesGetResourcesCount: vi.fn(), + getConfigurationValue: vi.fn(), + addEventListener: eventEmitter.receive, + events: { + receive: eventEmitter.receive, + }, + }, + }); +}); + +test('kubernetesResourcesCount in experimental states mode', async () => { + vi.mocked(window.getConfigurationValue).mockResolvedValue(true); + + const initialValues: ResourceCount[] = []; + const nextValues: ResourceCount[] = [ + { + contextName: 'context1', + resourceName: 'pods', + count: 1, + }, + { + contextName: 'context2', + resourceName: 'deployments', + count: 2, + }, + ]; + vi.mocked(window.kubernetesGetResourcesCount).mockResolvedValue(initialValues); + + kubernetesResourcesCountStore.setup(); + + // send 'extensions-already-started' event + const callbackExtensionsStarted = callbacks.get('extensions-already-started'); + expect(callbackExtensionsStarted).toBeDefined(); + await callbackExtensionsStarted!(); + + await vi.waitFor(() => { + const currentValue = get(kubernetesResourcesCount); + expect(currentValue).toEqual(initialValues); + }, 500); + + // data has been updated in the backend + vi.mocked(window.kubernetesGetResourcesCount).mockResolvedValue(nextValues); + + // send an event indicating the data is updated + const event = 'kubernetes-resources-count'; + const callback = callbacks.get(event); + expect(callback).toBeDefined(); + await callback!(); + + await vi.waitFor(() => { + // check received data is updated + const currentValue = get(kubernetesResourcesCount); + expect(currentValue).toEqual(nextValues); + }, 500); +}); diff --git a/packages/renderer/src/stores/kubernetes-resources-count-non-experimental.spec.ts b/packages/renderer/src/stores/kubernetes-resources-count-non-experimental.spec.ts new file mode 100644 index 0000000000000..122834b391a3e --- /dev/null +++ b/packages/renderer/src/stores/kubernetes-resources-count-non-experimental.spec.ts @@ -0,0 +1,75 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { get } from 'svelte/store'; +import { beforeAll, expect, test, vi } from 'vitest'; + +import type { ResourceCount } from '/@api/kubernetes-resource-count'; + +import { kubernetesResourcesCount, kubernetesResourcesCountStore } from './kubernetes-resources-count'; + +const callbacks = new Map Promise>(); +const eventEmitter = { + receive: (message: string, callback: () => Promise) => { + callbacks.set(message, callback); + }, +}; + +beforeAll(() => { + Object.defineProperty(global, 'window', { + value: { + kubernetesGetResourcesCount: vi.fn(), + getConfigurationValue: vi.fn(), + addEventListener: eventEmitter.receive, + events: { + receive: eventEmitter.receive, + }, + }, + }); +}); + +test('kubernetesResourcesCount in non experimental states mode', async () => { + vi.mocked(window.getConfigurationValue).mockResolvedValue(false); + + const initialValues: ResourceCount[] = [ + { + contextName: 'context1', + resourceName: 'pods', + count: 1, + }, + { + contextName: 'context2', + resourceName: 'deployments', + count: 2, + }, + ]; + vi.mocked(window.kubernetesGetResourcesCount).mockResolvedValue(initialValues); + + kubernetesResourcesCountStore.setup(); + + // send 'extensions-already-started' event + const callbackExtensionsStarted = callbacks.get('extensions-already-started'); + expect(callbackExtensionsStarted).toBeDefined(); + await callbackExtensionsStarted!(); + + // values are never fetched + await new Promise(resolve => setTimeout(resolve, 500)); + const currentValue = get(kubernetesResourcesCount); + expect(currentValue).toEqual([]); + expect(vi.mocked(window.kubernetesGetResourcesCount)).not.toHaveBeenCalled(); +}); diff --git a/packages/renderer/src/stores/kubernetes-resources-count.ts b/packages/renderer/src/stores/kubernetes-resources-count.ts new file mode 100644 index 0000000000000..0d20acabd993a --- /dev/null +++ b/packages/renderer/src/stores/kubernetes-resources-count.ts @@ -0,0 +1,62 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { type Writable, writable } from 'svelte/store'; + +import type { ResourceCount } from '/@api/kubernetes-resource-count'; + +import { EventStore } from './event-store'; + +const windowEvents = ['kubernetes-resources-count', 'extension-stopped', 'extensions-started']; +const windowListeners = ['extensions-already-started']; + +let experimentalStates: boolean | undefined = undefined; +let readyToUpdate = false; + +export async function checkForUpdate(eventName: string): Promise { + // check for update only in experimental states mode + if (experimentalStates === undefined) { + experimentalStates = (await window.getConfigurationValue('kubernetes.statesExperimental')) ?? false; + } + if (experimentalStates === false) { + return false; + } + + if ('extensions-already-started' === eventName) { + readyToUpdate = true; + } + // do not fetch until extensions are all started + return readyToUpdate; +} + +export const kubernetesResourcesCount: Writable = writable([]); + +// use helper here as window methods are initialized after the store in tests +const listResourcesCount = (): Promise => { + return window.kubernetesGetResourcesCount(); +}; + +export const kubernetesResourcesCountStore = new EventStore( + 'kubernetes resources count', + kubernetesResourcesCount, + checkForUpdate, + windowEvents, + windowListeners, + listResourcesCount, +); +kubernetesResourcesCountStore.setup();