diff --git a/__mocks__/@podman-desktop/api.js b/__mocks__/@podman-desktop/api.js index 7aaf4c70..fac062f3 100644 --- a/__mocks__/@podman-desktop/api.js +++ b/__mocks__/@podman-desktop/api.js @@ -17,7 +17,6 @@ ***********************************************************************/ import { vi } from 'vitest'; import { EventEmitter } from 'node:events'; -import { parse } from 'node:path'; /** * Mock the extension API for vitest. @@ -75,6 +74,10 @@ const plugin = { process: { exec: vi.fn(), }, + kubernetes: { + onDidUpdateKubeconfig: vi.fn(), + getKubeconfig: vi.fn(), + } }; module.exports = plugin; diff --git a/packages/extension/__mocks__/fs.cjs b/packages/extension/__mocks__/fs.cjs new file mode 100644 index 00000000..f9a27d9f --- /dev/null +++ b/packages/extension/__mocks__/fs.cjs @@ -0,0 +1,2 @@ +const { fs } = require('memfs') +module.exports = fs diff --git a/packages/extension/__mocks__/fs/promises.cjs b/packages/extension/__mocks__/fs/promises.cjs new file mode 100644 index 00000000..149d2a47 --- /dev/null +++ b/packages/extension/__mocks__/fs/promises.cjs @@ -0,0 +1,2 @@ +const { fs } = require('memfs') +module.exports = fs.promises diff --git a/packages/extension/package.json b/packages/extension/package.json index 3b23e9c4..466a1ffc 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -34,6 +34,7 @@ "eslint-plugin-no-null": "^1.0.2", "eslint-plugin-redundant-undefined": "^1.0.0", "eslint-plugin-sonarjs": "^3.0.2", + "memfs": "^4.17.2", "prettier": "^3.6.1", "typescript": "5.8.3", "vite": "^7.0", diff --git a/packages/extension/src/dashboard-extension.spec.ts b/packages/extension/src/dashboard-extension.spec.ts index 9253b821..5bf544e5 100644 --- a/packages/extension/src/dashboard-extension.spec.ts +++ b/packages/extension/src/dashboard-extension.spec.ts @@ -17,21 +17,27 @@ ***********************************************************************/ import type { WebviewPanel, ExtensionContext } from '@podman-desktop/api'; -import { Uri, window } from '@podman-desktop/api'; -import { beforeEach, test, vi } from 'vitest'; +import { kubernetes, Uri, window } from '@podman-desktop/api'; +import { assert, beforeEach, describe, expect, test, vi } from 'vitest'; import { DashboardExtension } from './dashboard-extension'; +import { vol } from 'memfs'; -import { readFile } from 'node:fs/promises'; +import type { ContextsManager } from './manager/contexts-manager'; +import type { ContextsStatesDispatcher } from './manager/contexts-states-dispatcher'; let extensionContextMock: ExtensionContext; let dashboardExtension: DashboardExtension; +let contextsManagerMock: ContextsManager; +let contextsStatesDispatcher: ContextsStatesDispatcher; vi.mock(import('node:fs')); vi.mock(import('node:fs/promises')); +vi.mock(import('@kubernetes/client-node')); beforeEach(() => { vi.restoreAllMocks(); vi.resetAllMocks(); + vol.reset(); vi.mocked(window.createWebviewPanel).mockReturnValue({ webview: { @@ -39,20 +45,73 @@ beforeEach(() => { onDidReceiveMessage: vi.fn(), }, } as unknown as WebviewPanel); - vi.mocked(Uri.joinPath).mockReturnValue({ fsPath: '' } as unknown as Uri); - vi.mocked(readFile).mockResolvedValue(''); + vi.mocked(Uri.joinPath).mockReturnValue({ fsPath: '/path/to/extension/index.html' } as unknown as Uri); // Create a mock for the ExtensionContext extensionContextMock = { subscriptions: [], } as unknown as ExtensionContext; - dashboardExtension = new DashboardExtension(extensionContextMock); + // Create a mock for the contextsManager + contextsManagerMock = { + update: vi.fn(), + } as unknown as ContextsManager; + contextsStatesDispatcher = { + init: vi.fn(), + } as unknown as ContextsStatesDispatcher; + dashboardExtension = new DashboardExtension(extensionContextMock, contextsManagerMock, contextsStatesDispatcher); + vi.mocked(kubernetes.getKubeconfig).mockReturnValue({ + path: '/path/to/kube/config', + } as Uri); }); -test('should activate correctly', async () => { - await dashboardExtension.activate(); +describe('a kubeconfig file is not present', () => { + beforeEach(() => { + vol.fromJSON({ + '/path/to/extension/index.html': '', + }); + }); + + test('should activate correctly and calls contextsManager every time the kubeconfig file changes', async () => { + await dashboardExtension.activate(); + expect(contextsManagerMock.update).not.toHaveBeenCalled(); + + const callback = vi.mocked(kubernetes.onDidUpdateKubeconfig).mock.lastCall?.[0]; + assert(callback); + vi.mocked(contextsManagerMock.update).mockClear(); + callback({ type: 'UPDATE', location: { path: '/path/to/kube/config' } as Uri }); + expect(contextsManagerMock.update).toHaveBeenCalledOnce(); + + expect(contextsStatesDispatcher.init).toHaveBeenCalledOnce(); + }); + + test('should deactivate correctly', async () => { + await dashboardExtension.activate(); + await dashboardExtension.deactivate(); + }); }); -test('should deactivate correctly', async () => { - await dashboardExtension.activate(); - await dashboardExtension.deactivate(); +describe('a kubeconfig file is present', () => { + beforeEach(() => { + vol.fromJSON({ + '/path/to/extension/index.html': '', + '/path/to/kube/config': '{}', + }); + }); + + test('should activate correctly and calls contextsManager every time the kubeconfig file changes', async () => { + await dashboardExtension.activate(); + expect(contextsManagerMock.update).toHaveBeenCalledOnce(); + + const callback = vi.mocked(kubernetes.onDidUpdateKubeconfig).mock.lastCall?.[0]; + assert(callback); + vi.mocked(contextsManagerMock.update).mockClear(); + callback({ type: 'UPDATE', location: { path: '/path/to/kube/config' } as Uri }); + expect(contextsManagerMock.update).toHaveBeenCalledOnce(); + + expect(contextsStatesDispatcher.init).toHaveBeenCalledOnce(); + }); + + test('should deactivate correctly', async () => { + await dashboardExtension.activate(); + await dashboardExtension.deactivate(); + }); }); diff --git a/packages/extension/src/dashboard-extension.ts b/packages/extension/src/dashboard-extension.ts index 89db340e..b04cc8e4 100644 --- a/packages/extension/src/dashboard-extension.ts +++ b/packages/extension/src/dashboard-extension.ts @@ -16,20 +16,55 @@ * SPDX-License-Identifier: Apache-2.0 ***********************************************************************/ -import { Uri, window, type ExtensionContext } from '@podman-desktop/api'; +import type { WebviewPanel, ExtensionContext, KubeconfigUpdateEvent } from '@podman-desktop/api'; +import { kubernetes, Uri, window } from '@podman-desktop/api'; import { RpcExtension } from '/@common/rpc/rpc'; import { readFile } from 'node:fs/promises'; +import type { ContextsManager } from './manager/contexts-manager'; +import { existsSync } from 'node:fs'; +import { KubeConfig } from '@kubernetes/client-node'; +import type { ContextsStatesDispatcher } from './manager/contexts-states-dispatcher'; export class DashboardExtension { #extensionContext: ExtensionContext; - - constructor(readonly extensionContext: ExtensionContext) { + #contextsManager: ContextsManager; + #contextsStatesDispatcher: ContextsStatesDispatcher; + + constructor( + readonly extensionContext: ExtensionContext, + readonly contextManager: ContextsManager, + readonly contextsStatesDispatcher: ContextsStatesDispatcher, + ) { this.#extensionContext = extensionContext; + this.#contextsManager = contextManager; + this.#contextsStatesDispatcher = contextsStatesDispatcher; } async activate(): Promise { + const panel = await this.createWebview(); + + // Register webview communication for this webview + const rpcExtension = new RpcExtension(panel.webview); + rpcExtension.init(); + this.#extensionContext.subscriptions.push(rpcExtension); + + const now = performance.now(); + + const afterFirst = performance.now(); + + console.log('activation time:', afterFirst - now); + + await this.listenMonitoring(); + await this.startMonitoring(); + } + + async deactivate(): Promise { + console.log('deactivating Kubernetes Dashboard extension'); + } + + private async createWebview(): Promise { const panel = window.createWebviewPanel('kubernetes-dashboard', 'Kubernetes', { localResourceRoots: [Uri.joinPath(this.#extensionContext.extensionUri, 'media')], }); @@ -70,19 +105,37 @@ export class DashboardExtension { // Update the webview panel with the new index.html file with corrected links. panel.webview.html = indexHtml; - // Register webview communication for this webview - const rpcExtension = new RpcExtension(panel.webview); - rpcExtension.init(); - this.#extensionContext.subscriptions.push(rpcExtension); + return panel; + } - const now = performance.now(); + private async listenMonitoring(): Promise { + this.#contextsStatesDispatcher.init(); + } - const afterFirst = performance.now(); + private async startMonitoring(): Promise { + this.#extensionContext.subscriptions.push(this.#contextsManager); - console.log('activation time:', afterFirst - now); + const kubeconfigWatcher = kubernetes.onDidUpdateKubeconfig(this.onKubeconfigUpdate.bind(this)); + this.#extensionContext.subscriptions.push(kubeconfigWatcher); + + // initial state is not sent by watcher, let's get it explicitely + const kubeconfig = kubernetes.getKubeconfig(); + if (existsSync(kubeconfig.path)) { + await this.onKubeconfigUpdate({ + location: kubeconfig, + type: 'CREATE', + }); + } } - async deactivate(): Promise { - console.log('deactivating Kubernetes Dashboard extension'); + private async onKubeconfigUpdate(event: KubeconfigUpdateEvent): Promise { + if (event.type === 'DELETE') { + // update with an empty KubeConfig + await this.#contextsManager.update(new KubeConfig()); + return; + } + const kubeConfig = new KubeConfig(); + kubeConfig.loadFromFile(event.location.path); + await this.#contextsManager.update(kubeConfig); } } diff --git a/packages/extension/src/main.ts b/packages/extension/src/main.ts index 42860bc9..b264fff5 100644 --- a/packages/extension/src/main.ts +++ b/packages/extension/src/main.ts @@ -19,12 +19,21 @@ import type { ExtensionContext } from '@podman-desktop/api'; import { DashboardExtension } from './dashboard-extension'; +import { ContextsManager } from './manager/contexts-manager'; +import { ContextsStatesDispatcher } from './manager/contexts-states-dispatcher'; let dashboardExtension: DashboardExtension | undefined; // Initialize the activation of the extension. export async function activate(extensionContext: ExtensionContext): Promise { - dashboardExtension ??= new DashboardExtension(extensionContext); + const contextsManager = new ContextsManager(); + const apiSender = { + send: (channel: string, data?: unknown): void => { + console.log(`==> recv data "${data}" on channel ${channel}`); + }, + }; + const contextsStatesDispatcher = new ContextsStatesDispatcher(contextsManager, apiSender); + dashboardExtension ??= new DashboardExtension(extensionContext, contextsManager, contextsStatesDispatcher); await dashboardExtension.activate(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63b182d1..5f11aa5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,9 @@ importers: eslint-plugin-sonarjs: specifier: ^3.0.2 version: 3.0.4(eslint@9.29.0(jiti@2.4.2)) + memfs: + specifier: ^4.17.2 + version: 4.17.2 prettier: specifier: ^3.6.1 version: 3.6.2 @@ -657,6 +660,24 @@ packages: peerDependencies: jsep: ^0.4.0||^1.0.0 + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.2.0': + resolution: {integrity: sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.6.0': + resolution: {integrity: sha512-sw/RMbehRhN68WRtcKCpQOPfnH6lLP4GJfqzi3iYej8tnzpZUDr6UkZYJjcjjC0FWEJOJbyM3PTIwxucUmDG2A==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + '@kubernetes/client-node@1.3.0': resolution: {integrity: sha512-IE0yrIpOT97YS5fg2QpzmPzm8Wmcdf4ueWMn+FiJSI3jgTTQT1u+LUhoYpdfhdHAVxdrNsaBg2C0UXSnOgMoCQ==} @@ -2157,6 +2178,10 @@ packages: humanize-duration@3.33.0: resolution: {integrity: sha512-vYJX7BSzn7EQ4SaP2lPYVy+icHDppB6k7myNeI3wrSRfwMS5+BHyGgzpHR0ptqJ2AQ6UuIKrclSg5ve6Ci4IAQ==} + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2555,6 +2580,10 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + memfs@4.17.2: + resolution: {integrity: sha512-NgYhCOWgovOXSzvYgUW0LQ7Qy72rWQMGGFJDoWg4G30RHd3z77VbYdtJ4fembJXBy8pMIUA31XNAupobOQlwdg==} + engines: {node: '>= 4.0.0'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -3290,6 +3319,12 @@ packages: text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + thingies@1.21.0: + resolution: {integrity: sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -3338,6 +3373,12 @@ packages: resolution: {integrity: sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==} engines: {node: '>=18'} + tree-dump@1.0.3: + resolution: {integrity: sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3969,6 +4010,22 @@ snapshots: dependencies: jsep: 1.4.0 + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.2.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.6.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 1.21.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.6.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + '@kubernetes/client-node@1.3.0(encoding@0.1.13)': dependencies: '@types/js-yaml': 4.0.9 @@ -5729,6 +5786,8 @@ snapshots: humanize-duration@3.33.0: {} + hyperdyperid@1.2.0: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -6115,6 +6174,13 @@ snapshots: math-intrinsics@1.1.0: {} + memfs@4.17.2: + dependencies: + '@jsonjoy.com/json-pack': 1.2.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.6.0(tslib@2.8.1) + tree-dump: 1.0.3(tslib@2.8.1) + tslib: 2.8.1 + merge2@1.4.1: {} micromatch@4.0.8: @@ -6855,6 +6921,10 @@ snapshots: dependencies: b4a: 1.6.7 + thingies@1.21.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -6895,6 +6965,10 @@ snapshots: dependencies: punycode: 2.3.1 + tree-dump@1.0.3(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tree-kill@1.2.2: {} ts-api-utils@1.4.3(typescript@5.8.3): diff --git a/vitest.config.ts b/vitest.config.ts index 38933357..bcdd29cf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ excludeAfterRemap: true, provider: 'v8', exclude: ['**/coverage/**'], - reporter: [process.env.GITHUB_ACTIONS?'html':'text'], + reporter: process.env.GITHUB_ACTIONS ? ['html'] : ['text','lcov'], }, }, }); \ No newline at end of file