From 7da6e4a48972e27a0eab4ae06580a7a57f492cb7 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 16 Dec 2024 15:35:36 +0100 Subject: [PATCH] feat: experimental health frontend (#10349) * feat: display health in frontend Signed-off-by: Philippe Martin * refactor: get experimentalStates config value Signed-off-by: Philippe Martin * test: unit tests Signed-off-by: Philippe Martin * fix: use vi.mocked Signed-off-by: Philippe Martin * fix: do not wast window to any Signed-off-by: Philippe Martin * refactor: use EventStore Signed-off-by: Philippe Martin * fix: compute outside of template Signed-off-by: Philippe Martin --- .../api/src/kubernetes-contexts-healths.ts | 23 ++++ packages/main/src/plugin/index.ts | 5 + .../contexts-states-dispatcher.spec.ts | 68 ++++++++- .../kubernetes/contexts-states-dispatcher.ts | 22 ++- .../plugin/kubernetes/kubernetes-client.ts | 10 +- packages/preload/src/index.ts | 5 + ...erencesKubernetesContextsRendering.spec.ts | 129 ++++++++++++------ ...ferencesKubernetesContextsRendering.svelte | 61 ++++++++- .../stores/kubernetes-context-health.spec.ts | 94 +++++++++++++ .../src/stores/kubernetes-context-health.ts | 52 +++++++ 10 files changed, 418 insertions(+), 51 deletions(-) create mode 100644 packages/api/src/kubernetes-contexts-healths.ts create mode 100644 packages/renderer/src/stores/kubernetes-context-health.spec.ts create mode 100644 packages/renderer/src/stores/kubernetes-context-health.ts diff --git a/packages/api/src/kubernetes-contexts-healths.ts b/packages/api/src/kubernetes-contexts-healths.ts new file mode 100644 index 000000000..ea785eaee --- /dev/null +++ b/packages/api/src/kubernetes-contexts-healths.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 ContextHealth { + contextName: string; + checking: boolean; + reachable: boolean; +} diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index d951cb98a..d431593e8 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -87,6 +87,7 @@ import type { ImageInfo } from '/@api/image-info.js'; import type { ImageInspectInfo } from '/@api/image-inspect-info.js'; import type { ImageSearchOptions, ImageSearchResult, ImageTagsListOptions } from '/@api/image-registry.js'; import type { KubeContext } from '/@api/kubernetes-context.js'; +import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js'; import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model.js'; import type { ManifestCreateOptions, ManifestInspectInfo, ManifestPushOptions } from '/@api/manifest-info.js'; @@ -2584,6 +2585,10 @@ export class PluginSystem { }, ); + this.ipcHandle('kubernetes:getContextsHealths', async (_listener): Promise => { + return kubernetesClient.getContextsHealths(); + }); + const kubernetesExecCallbackMap = new Map< number, { onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void } 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 018b36222..7773d9c56 100644 --- a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts +++ b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts @@ -18,8 +18,11 @@ import { expect, test, vi } from 'vitest'; +import type { ApiSenderType } from '../api.js'; +import type { ContextHealthState } from './context-health-checker.js'; import type { ContextsManagerExperimental } from './contexts-manager-experimental.js'; import { ContextsStatesDispatcher } from './contexts-states-dispatcher.js'; +import type { KubeConfigSingleContext } from './kubeconfig-single-context.js'; test('ContextsStatesDispatcher should call updateHealthStates when onContextHealthStateChange event is fired', () => { const onContextHealthStateChangeMock = vi.fn(); @@ -31,7 +34,10 @@ test('ContextsStatesDispatcher should call updateHealthStates when onContextHeal getHealthCheckersStates: vi.fn(), getPermissions: vi.fn(), } as unknown as ContextsManagerExperimental; - const dispatcher = new ContextsStatesDispatcher(manager); + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates'); const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions'); dispatcher.init(); @@ -39,6 +45,7 @@ test('ContextsStatesDispatcher should call updateHealthStates when onContextHeal expect(updatePermissionsSpy).not.toHaveBeenCalled(); onContextHealthStateChangeMock.mockImplementation(f => f()); + vi.mocked(manager.getHealthCheckersStates).mockReturnValue(new Map()); dispatcher.init(); expect(updateHealthStatesSpy).toHaveBeenCalled(); expect(updatePermissionsSpy).not.toHaveBeenCalled(); @@ -54,7 +61,8 @@ test('ContextsStatesDispatcher should call updatePermissions when onContextPermi getHealthCheckersStates: vi.fn(), getPermissions: vi.fn(), } as unknown as ContextsManagerExperimental; - const dispatcher = new ContextsStatesDispatcher(manager); + const apiSender: ApiSenderType = {} as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates'); const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions'); dispatcher.init(); @@ -76,9 +84,13 @@ test('ContextsStatesDispatcher should call updateHealthStates and updatePermissi getHealthCheckersStates: vi.fn(), getPermissions: vi.fn(), } as unknown as ContextsManagerExperimental; - const dispatcher = new ContextsStatesDispatcher(manager); + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates'); const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions'); + vi.mocked(manager.getHealthCheckersStates).mockReturnValue(new Map()); dispatcher.init(); expect(updateHealthStatesSpy).not.toHaveBeenCalled(); expect(updatePermissionsSpy).not.toHaveBeenCalled(); @@ -88,3 +100,53 @@ test('ContextsStatesDispatcher should call updateHealthStates and updatePermissi expect(updateHealthStatesSpy).toHaveBeenCalled(); expect(updatePermissionsSpy).toHaveBeenCalled(); }); + +test('getContextsHealths should return the values of the map returned by manager.getHealthCheckersStates without kubeConfig', () => { + const manager: ContextsManagerExperimental = { + onContextHealthStateChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + } as unknown as ContextsManagerExperimental; + const sendMock = vi.fn(); + const apiSender: ApiSenderType = { + send: sendMock, + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + const context1State = { + contextName: 'context1', + checking: true, + reachable: false, + }; + const context2State = { + contextName: 'context2', + checking: false, + reachable: true, + }; + const value = new Map([ + ['context1', { ...context1State, kubeConfig: {} as unknown as KubeConfigSingleContext }], + ['context2', { ...context2State, kubeConfig: {} as unknown as KubeConfigSingleContext }], + ]); + vi.mocked(manager.getHealthCheckersStates).mockReturnValue(value); + const result = dispatcher.getContextsHealths(); + expect(result).toEqual([context1State, context2State]); +}); + +test('updateHealthStates should call apiSender.send with kubernetes-contexts-healths', () => { + const manager: ContextsManagerExperimental = { + onContextHealthStateChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + } as unknown as ContextsManagerExperimental; + const sendMock = vi.fn(); + const apiSender: ApiSenderType = { + send: sendMock, + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + vi.spyOn(dispatcher, 'getContextsHealths').mockReturnValue([]); + dispatcher.updateHealthStates(); + expect(sendMock).toHaveBeenCalledWith('kubernetes-contexts-healths'); +}); diff --git a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts index 5262c325a..6412c2a5c 100644 --- a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts +++ b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts @@ -16,13 +16,19 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ +import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js'; + +import type { ApiSenderType } from '../api.js'; import type { ContextHealthState } from './context-health-checker.js'; import type { ContextPermissionResult } from './context-permissions-checker.js'; import type { DispatcherEvent } from './contexts-dispatcher.js'; import type { ContextsManagerExperimental } from './contexts-manager-experimental.js'; export class ContextsStatesDispatcher { - constructor(private manager: ContextsManagerExperimental) {} + constructor( + private manager: ContextsManagerExperimental, + private apiSender: ApiSenderType, + ) {} init(): void { this.manager.onContextHealthStateChange((_state: ContextHealthState) => this.updateHealthStates()); @@ -34,7 +40,19 @@ export class ContextsStatesDispatcher { } updateHealthStates(): void { - console.log('current health check states', this.manager.getHealthCheckersStates()); + this.apiSender.send('kubernetes-contexts-healths'); + } + + getContextsHealths(): ContextHealth[] { + const value: ContextHealth[] = []; + for (const [contextName, health] of this.manager.getHealthCheckersStates()) { + value.push({ + contextName, + checking: health.checking, + reachable: health.reachable, + }); + } + return value; } updatePermissions(): void { diff --git a/packages/main/src/plugin/kubernetes/kubernetes-client.ts b/packages/main/src/plugin/kubernetes/kubernetes-client.ts index b8092f4b0..4046483cd 100644 --- a/packages/main/src/plugin/kubernetes/kubernetes-client.ts +++ b/packages/main/src/plugin/kubernetes/kubernetes-client.ts @@ -71,6 +71,7 @@ import { parseAllDocuments } from 'yaml'; import type { KubernetesPortForwardService } from '/@/plugin/kubernetes/kubernetes-port-forward-service.js'; import { KubernetesPortForwardServiceProvider } from '/@/plugin/kubernetes/kubernetes-port-forward-service.js'; import type { KubeContext } from '/@api/kubernetes-context.js'; +import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js'; import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model.js'; import type { V1Route } from '/@api/openshift-types.js'; @@ -270,7 +271,7 @@ export class KubernetesClient { if (statesExperimental) { const manager = new ContextsManagerExperimental(); this.contextsState = manager; - this.contextsStatesDispatcher = new ContextsStatesDispatcher(manager); + this.contextsStatesDispatcher = new ContextsStatesDispatcher(manager, this.apiSender); this.contextsStatesDispatcher.init(); } @@ -1844,4 +1845,11 @@ export class KubernetesClient { public async deletePortForward(config: ForwardConfig): Promise { return this.ensurePortForwardService().deleteForward(config); } + + public getContextsHealths(): ContextHealth[] { + if (!this.contextsStatesDispatcher) { + throw new Error('contextsStatesDispatcher is undefined. This should not happen in Kubernetes experimental'); + } + return this.contextsStatesDispatcher?.getContextsHealths(); + } } diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 474d9e07f..6fe51ef76 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -67,6 +67,7 @@ import type { ImageInfo } from '/@api/image-info'; import type { ImageInspectInfo } from '/@api/image-inspect-info'; import type { ImageSearchOptions, ImageSearchResult, ImageTagsListOptions } from '/@api/image-registry'; import type { KubeContext } from '/@api/kubernetes-context'; +import type { ContextHealth } from '/@api/kubernetes-contexts-healths'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states'; import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model'; import type { ManifestCreateOptions, ManifestInspectInfo, ManifestPushOptions } from '/@api/manifest-info'; @@ -1871,6 +1872,10 @@ export function initExposure(): void { }, ); + contextBridge.exposeInMainWorld('kubernetesGetContextsHealths', async (): Promise => { + return ipcInvoke('kubernetes:getContextsHealths'); + }); + contextBridge.exposeInMainWorld('kubernetesGetClusters', async (): Promise => { return ipcInvoke('kubernetes-client:getClusters'); }); diff --git a/packages/renderer/src/lib/preferences/PreferencesKubernetesContextsRendering.spec.ts b/packages/renderer/src/lib/preferences/PreferencesKubernetesContextsRendering.spec.ts index 2a8312d5d..2b4763754 100644 --- a/packages/renderer/src/lib/preferences/PreferencesKubernetesContextsRendering.spec.ts +++ b/packages/renderer/src/lib/preferences/PreferencesKubernetesContextsRendering.spec.ts @@ -20,8 +20,9 @@ import '@testing-library/jest-dom/vitest'; import { fireEvent, render, screen, within } from '@testing-library/svelte'; import { readable } from 'svelte/store'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { kubernetesContextsHealths } from '/@/stores/kubernetes-context-health'; import { kubernetesContexts } from '/@/stores/kubernetes-contexts'; import * as kubernetesContextsState from '/@/stores/kubernetes-contexts-state'; import type { KubeContext } from '/@api/kubernetes-context'; @@ -157,45 +158,95 @@ test('when deleting the non current context, no popup should ask confirmation', expect(showMessageBoxMock).not.toHaveBeenCalled(); }); -test('state and resources counts are displayed in contexts', () => { - const state: Map = new Map(); - state.set('context-name', { - reachable: true, - resources: { - pods: 1, - deployments: 2, +describe.each([ + { + name: 'experimental states', + implemented: { + health: true, + resourcesCount: false, }, - }); - state.set('context-name2', { - reachable: false, - resources: { - pods: 0, - deployments: 0, + initMocks: () => { + Object.defineProperty(global, 'window', { + value: { + getConfigurationValue: vi.fn(), + }, + }); + vi.mocked(window.getConfigurationValue).mockResolvedValue(true); + kubernetesContextsHealths.set([ + { + contextName: 'context-name', + reachable: true, + checking: false, + }, + { + contextName: 'context-name2', + reachable: false, + checking: false, + }, + ]); + }, + }, + { + name: 'non-experimental states', + implemented: { + health: true, + resourcesCount: true, }, + initMocks: () => { + const state: Map = new Map(); + state.set('context-name', { + reachable: true, + resources: { + pods: 1, + deployments: 2, + }, + }); + state.set('context-name2', { + reachable: false, + resources: { + pods: 0, + deployments: 0, + }, + }); + vi.mocked(kubernetesContextsState).kubernetesContextsState = readable>(state); + vi.mocked(kubernetesContextsState).kubernetesContextsCheckingStateDelayed = readable>( + new Map(), + ); + }, + }, +])('$name', ({ implemented, initMocks }) => { + test('state and resources counts are displayed in contexts', async () => { + initMocks(); + render(PreferencesKubernetesContextsRendering, {}); + const context1 = screen.getAllByRole('row')[0]; + const context2 = screen.getAllByRole('row')[1]; + if (implemented.health) { + await vi.waitFor(() => { + expect(within(context1).queryByText('REACHABLE')).toBeInTheDocument(); + }); + } + expect(within(context1).queryByText('PODS')).toBeInTheDocument(); + expect(within(context1).queryByText('DEPLOYMENTS')).toBeInTheDocument(); + + if (implemented.resourcesCount) { + const checkCount = (el: HTMLElement, label: string, count: number) => { + const countEl = within(el).getByLabelText(label); + expect(countEl).toBeInTheDocument(); + expect(within(countEl).queryByText(count)).toBeTruthy(); + }; + checkCount(context1, 'Context Pods Count', 1); + checkCount(context1, 'Context Deployments Count', 2); + } + + if (implemented.health) { + expect(within(context2).queryByText('UNREACHABLE')).toBeInTheDocument(); + } + expect(within(context2).queryByText('PODS')).not.toBeInTheDocument(); + expect(within(context2).queryByText('DEPLOYMENTS')).not.toBeInTheDocument(); + + const podsCountContext2 = within(context2).queryByLabelText('Context Pods Count'); + expect(podsCountContext2).not.toBeInTheDocument(); + const deploymentsCountContext2 = within(context2).queryByLabelText('Context Deployments Count'); + expect(deploymentsCountContext2).not.toBeInTheDocument(); }); - vi.mocked(kubernetesContextsState).kubernetesContextsState = readable>(state); - vi.mocked(kubernetesContextsState).kubernetesContextsCheckingStateDelayed = readable>(new Map()); - render(PreferencesKubernetesContextsRendering, {}); - const context1 = screen.getAllByRole('row')[0]; - const context2 = screen.getAllByRole('row')[1]; - expect(within(context1).queryByText('REACHABLE')).toBeInTheDocument(); - expect(within(context1).queryByText('PODS')).toBeInTheDocument(); - expect(within(context1).queryByText('DEPLOYMENTS')).toBeInTheDocument(); - - const checkCount = (el: HTMLElement, label: string, count: number) => { - const countEl = within(el).getByLabelText(label); - expect(countEl).toBeInTheDocument(); - expect(within(countEl).queryByText(count)).toBeTruthy(); - }; - checkCount(context1, 'Context Pods Count', 1); - checkCount(context1, 'Context Deployments Count', 2); - - expect(within(context2).queryByText('UNREACHABLE')).toBeInTheDocument(); - expect(within(context2).queryByText('PODS')).not.toBeInTheDocument(); - expect(within(context2).queryByText('DEPLOYMENTS')).not.toBeInTheDocument(); - - const podsCountContext2 = within(context2).queryByLabelText('Context Pods Count'); - expect(podsCountContext2).not.toBeInTheDocument(); - const deploymentsCountContext2 = within(context2).queryByLabelText('Context Deployments Count'); - expect(deploymentsCountContext2).not.toBeInTheDocument(); }); diff --git a/packages/renderer/src/lib/preferences/PreferencesKubernetesContextsRendering.svelte b/packages/renderer/src/lib/preferences/PreferencesKubernetesContextsRendering.svelte index e6a6fa1b2..dfda79e06 100644 --- a/packages/renderer/src/lib/preferences/PreferencesKubernetesContextsRendering.svelte +++ b/packages/renderer/src/lib/preferences/PreferencesKubernetesContextsRendering.svelte @@ -4,7 +4,9 @@ import { Button, EmptyScreen, ErrorMessage, Spinner } from '@podman-desktop/ui-s import { onMount } from 'svelte'; import { router } from 'tinro'; +import { kubernetesContextsHealths } from '/@/stores/kubernetes-context-health'; import { kubernetesContextsCheckingStateDelayed, kubernetesContextsState } from '/@/stores/kubernetes-contexts-state'; +import type { KubeContext } from '/@api/kubernetes-context'; import { kubernetesContexts } from '../../stores/kubernetes-contexts'; import { clearKubeUIContextErrors, setKubeUIContextError } from '../kube/KubeContextUI'; @@ -12,9 +14,25 @@ import EngineIcon from '../ui/EngineIcon.svelte'; import ListItemButtonIcon from '../ui/ListItemButtonIcon.svelte'; import SettingsPage from './SettingsPage.svelte'; -$: currentContextName = $kubernetesContexts.find(c => c.currentContext)?.name; +interface KubeContextWithStates extends KubeContext { + isReachable: boolean; + isKnown: boolean; + isBeingChecked: boolean; +} + +const currentContextName = $derived($kubernetesContexts.find(c => c.currentContext)?.name); -let kubeconfigFilePath: string = ''; +let kubeconfigFilePath: string = $state(''); +let experimentalStates: boolean = $state(false); + +const kubernetesContextsWithStates: KubeContextWithStates[] = $derived( + $kubernetesContexts.map(kubeContext => ({ + ...kubeContext, + isReachable: isContextReachable(kubeContext.name, experimentalStates), + isKnown: isContextKnown(kubeContext.name, experimentalStates), + isBeingChecked: isContextBeingChecked(kubeContext.name, experimentalStates), + })), +); onMount(async () => { try { @@ -27,6 +45,12 @@ onMount(async () => { } catch (error) { kubeconfigFilePath = 'Default is usually ~/.kube/config'; } + + try { + experimentalStates = (await window.getConfigurationValue('kubernetes.statesExperimental')) ?? false; + } catch { + // keep default value + } }); async function handleSetContext(contextName: string) { @@ -61,6 +85,31 @@ async function handleDeleteContext(contextName: string) { } } } + +function isContextReachable(contextName: string, experimental: boolean): boolean { + if (experimental) { + return $kubernetesContextsHealths.some( + contextHealth => contextHealth.contextName === contextName && contextHealth.reachable, + ); + } + return $kubernetesContextsState.get(contextName)?.reachable ?? false; +} + +function isContextKnown(contextName: string, experimental: boolean): boolean { + if (experimental) { + return $kubernetesContextsHealths.some(contextHealth => contextHealth.contextName === contextName); + } + return !!$kubernetesContextsState.get(contextName); +} + +function isContextBeingChecked(contextName: string, experimental: boolean): boolean { + if (experimental) { + return $kubernetesContextsHealths.some( + contextHealth => contextHealth.contextName === contextName && contextHealth.checking, + ); + } + return !!$kubernetesContextsCheckingStateDelayed?.get(contextName); +} @@ -80,7 +129,7 @@ async function handleDeleteContext(contextName: string) { Go to Resources - {#each $kubernetesContexts as context} + {#each kubernetesContextsWithStates as context}
- {#if $kubernetesContextsState.get(context.name)?.reachable} + {#if context.isReachable}
- {#if $kubernetesContextsState.get(context.name)} + {#if context.isKnown} UNREACHABLE {:else} UNKNOWN {/if}
- {#if $kubernetesContextsCheckingStateDelayed?.get(context.name)} + {#if context.isBeingChecked}
{/if}
diff --git a/packages/renderer/src/stores/kubernetes-context-health.spec.ts b/packages/renderer/src/stores/kubernetes-context-health.spec.ts new file mode 100644 index 000000000..a5fddef57 --- /dev/null +++ b/packages/renderer/src/stores/kubernetes-context-health.spec.ts @@ -0,0 +1,94 @@ +/********************************************************************** + * 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 { expect, test, vi } from 'vitest'; + +import { kubernetesContextsHealths, kubernetesContextsHealthsStore } from './kubernetes-context-health'; + +const callbacks = new Map(); +const eventEmitter = { + receive: (message: string, callback: any) => { + callbacks.set(message, callback); + }, +}; + +Object.defineProperty(global, 'window', { + value: { + kubernetesGetContextsHealths: vi.fn(), + addEventListener: eventEmitter.receive, + events: { + receive: eventEmitter.receive, + }, + }, + writable: true, +}); + +test('kubernetesContextsHealths', async () => { + const initialValues = [ + { + contextName: 'context1', + checking: true, + reachable: false, + }, + { + contextName: 'context2', + checking: false, + reachable: true, + }, + ]; + + const nextValues = [ + { + contextName: 'context1', + checking: false, + reachable: true, + }, + { + contextName: 'context2', + checking: false, + reachable: true, + }, + ]; + + vi.mocked(window.kubernetesGetContextsHealths).mockResolvedValue(initialValues); + + const kubernetesContextsHealthsInfo = kubernetesContextsHealthsStore.setup(); + await kubernetesContextsHealthsInfo.fetch(); + let currentValue = get(kubernetesContextsHealths); + expect(currentValue).toEqual(initialValues); + + // send 'extensions-already-started' event + const callbackExtensionsStarted = callbacks.get('extensions-already-started'); + expect(callbackExtensionsStarted).toBeDefined(); + await callbackExtensionsStarted(); + + // send an event indicating the data is updated + const event = 'kubernetes-contexts-healths'; + const callback = callbacks.get(event); + expect(callback).toBeDefined(); + await callback(); + + // data has been updated in the backend + vi.mocked(window.kubernetesGetContextsHealths).mockResolvedValue(nextValues); + + // check received data is updated + await kubernetesContextsHealthsInfo.fetch(); + currentValue = get(kubernetesContextsHealths); + expect(currentValue).toEqual(nextValues); +}); diff --git a/packages/renderer/src/stores/kubernetes-context-health.ts b/packages/renderer/src/stores/kubernetes-context-health.ts new file mode 100644 index 000000000..19bd302c4 --- /dev/null +++ b/packages/renderer/src/stores/kubernetes-context-health.ts @@ -0,0 +1,52 @@ +/********************************************************************** + * 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 { ContextHealth } from '/@api/kubernetes-contexts-healths'; + +import { EventStore } from './event-store'; + +const windowEvents = ['kubernetes-contexts-healths', 'extension-stopped', 'extensions-started']; +const windowListeners = ['extensions-already-started']; + +let readyToUpdate = false; +export async function checkForUpdate(eventName: string): Promise { + if ('extensions-already-started' === eventName) { + readyToUpdate = true; + } + // do not fetch until extensions are all started + return readyToUpdate; +} + +export const kubernetesContextsHealths: Writable = writable([]); + +// use helper here as window methods are initialized after the store in tests +const listContextsHealths = (): Promise => { + return window.kubernetesGetContextsHealths(); +}; + +export const kubernetesContextsHealthsStore = new EventStore( + 'contexts healths', + kubernetesContextsHealths, + checkForUpdate, + windowEvents, + windowListeners, + listContextsHealths, +); +kubernetesContextsHealthsStore.setup();