Skip to content

Commit

Permalink
feat: experimental health frontend (podman-desktop#10349)
Browse files Browse the repository at this point in the history
* feat: display health in frontend
Signed-off-by: Philippe Martin <[email protected]>

* refactor: get experimentalStates config value
Signed-off-by: Philippe Martin <[email protected]>

* test: unit tests
Signed-off-by: Philippe Martin <[email protected]>

* fix: use vi.mocked
Signed-off-by: Philippe Martin <[email protected]>

* fix: do not wast window to any
Signed-off-by: Philippe Martin <[email protected]>

* refactor: use EventStore
Signed-off-by: Philippe Martin <[email protected]>

* fix: compute outside of template
Signed-off-by: Philippe Martin <[email protected]>
  • Loading branch information
feloy authored Dec 16, 2024
1 parent da13d98 commit 7da6e4a
Show file tree
Hide file tree
Showing 10 changed files with 418 additions and 51 deletions.
23 changes: 23 additions & 0 deletions packages/api/src/kubernetes-contexts-healths.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions packages/main/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2584,6 +2585,10 @@ export class PluginSystem {
},
);

this.ipcHandle('kubernetes:getContextsHealths', async (_listener): Promise<ContextHealth[]> => {
return kubernetesClient.getContextsHealths();
});

const kubernetesExecCallbackMap = new Map<
number,
{ onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -31,14 +34,18 @@ 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();
expect(updateHealthStatesSpy).not.toHaveBeenCalled();
expect(updatePermissionsSpy).not.toHaveBeenCalled();

onContextHealthStateChangeMock.mockImplementation(f => f());
vi.mocked(manager.getHealthCheckersStates).mockReturnValue(new Map<string, ContextHealthState>());
dispatcher.init();
expect(updateHealthStatesSpy).toHaveBeenCalled();
expect(updatePermissionsSpy).not.toHaveBeenCalled();
Expand All @@ -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();
Expand All @@ -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<string, ContextHealthState>());
dispatcher.init();
expect(updateHealthStatesSpy).not.toHaveBeenCalled();
expect(updatePermissionsSpy).not.toHaveBeenCalled();
Expand All @@ -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<string, ContextHealthState>([
['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');
});
22 changes: 20 additions & 2 deletions packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion packages/main/src/plugin/kubernetes/kubernetes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -1844,4 +1845,11 @@ export class KubernetesClient {
public async deletePortForward(config: ForwardConfig): Promise<void> {
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();
}
}
5 changes: 5 additions & 0 deletions packages/preload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1871,6 +1872,10 @@ export function initExposure(): void {
},
);

contextBridge.exposeInMainWorld('kubernetesGetContextsHealths', async (): Promise<ContextHealth[]> => {
return ipcInvoke('kubernetes:getContextsHealths');
});

contextBridge.exposeInMainWorld('kubernetesGetClusters', async (): Promise<Cluster[]> => {
return ipcInvoke('kubernetes-client:getClusters');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, ContextGeneralState> = 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<boolean>).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<string, ContextGeneralState> = 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<Map<string, ContextGeneralState>>(state);
vi.mocked(kubernetesContextsState).kubernetesContextsCheckingStateDelayed = readable<Map<string, boolean>>(
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<Map<string, ContextGeneralState>>(state);
vi.mocked(kubernetesContextsState).kubernetesContextsCheckingStateDelayed = readable<Map<string, boolean>>(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();
});
Loading

0 comments on commit 7da6e4a

Please sign in to comment.