diff --git a/packages/core/src/common/cluster/cluster.ts b/packages/core/src/common/cluster/cluster.ts index 6829a6744a..329d6e2df7 100644 --- a/packages/core/src/common/cluster/cluster.ts +++ b/packages/core/src/common/cluster/cluster.ts @@ -498,6 +498,7 @@ export class Cluster implements ClusterModel { this.allowedResources.replace(await this.getAllowedResources(requestNamespaceListPermissions)); this.ready = this.knownResources.length > 0; + this.dependencies.logger.debug(`[CLUSTER]: refreshed accessibility data`, this.getState()); } /** diff --git a/packages/core/src/common/k8s-api/selected-filter-namespaces.injectable.ts b/packages/core/src/common/k8s-api/selected-filter-namespaces.injectable.ts index 6c70a665a4..41f664b895 100644 --- a/packages/core/src/common/k8s-api/selected-filter-namespaces.injectable.ts +++ b/packages/core/src/common/k8s-api/selected-filter-namespaces.injectable.ts @@ -4,7 +4,7 @@ */ import { getInjectable } from "@ogre-tools/injectable"; import { computed } from "mobx"; -import namespaceStoreInjectable from "../../renderer/components/+namespaces/store.injectable"; +import clusterFrameContextForNamespacedResourcesInjectable from "../../renderer/cluster-frame-context/for-namespaced-resources.injectable"; import { storesAndApisCanBeCreatedInjectionToken } from "./stores-apis-can-be-created.token"; const selectedFilterNamespacesInjectable = getInjectable({ @@ -15,9 +15,9 @@ const selectedFilterNamespacesInjectable = getInjectable({ return computed(() => []); } - const store = di.inject(namespaceStoreInjectable); + const context = di.inject(clusterFrameContextForNamespacedResourcesInjectable); - return computed(() => [...store.contextNamespaces]); + return computed(() => [...context.contextNamespaces]); }, }); diff --git a/packages/core/src/features/helm-charts/installing-chart/installing-helm-chart-from-new-tab.test.ts b/packages/core/src/features/helm-charts/installing-chart/installing-helm-chart-from-new-tab.test.ts index f7aef8dd88..b63d08f904 100644 --- a/packages/core/src/features/helm-charts/installing-chart/installing-helm-chart-from-new-tab.test.ts +++ b/packages/core/src/features/helm-charts/installing-chart/installing-helm-chart-from-new-tab.test.ts @@ -28,6 +28,9 @@ import requestHelmChartReadmeInjectable from "../../../common/k8s-api/endpoints/ import requestHelmChartValuesInjectable from "../../../common/k8s-api/endpoints/helm-charts.api/request-values.injectable"; import type { RequestDetailedHelmRelease } from "../../../renderer/components/+helm-releases/release-details/release-details-model/request-detailed-helm-release.injectable"; import requestDetailedHelmReleaseInjectable from "../../../renderer/components/+helm-releases/release-details/release-details-model/request-detailed-helm-release.injectable"; +import type { RequestHelmReleases } from "../../../common/k8s-api/endpoints/helm-releases.api/request-releases.injectable"; +import requestHelmReleasesInjectable from "../../../common/k8s-api/endpoints/helm-releases.api/request-releases.injectable"; +import { flushPromises } from "../../../common/test-utils/flush-promises"; describe("installing helm chart from new tab", () => { let builder: ApplicationBuilder; @@ -37,6 +40,7 @@ describe("installing helm chart from new tab", () => { let requestHelmChartReadmeMock: AsyncFnMock; let requestHelmChartValuesMock: AsyncFnMock; let requestCreateHelmReleaseMock: AsyncFnMock; + let requestHelmReleasesMock: AsyncFnMock; beforeEach(() => { builder = getApplicationBuilder(); @@ -49,6 +53,7 @@ describe("installing helm chart from new tab", () => { requestHelmChartReadmeMock = asyncFn(); requestHelmChartValuesMock = asyncFn(); requestCreateHelmReleaseMock = asyncFn(); + requestHelmReleasesMock = asyncFn(); builder.beforeWindowStart((windowDi) => { windowDi.override(directoryForLensLocalStorageInjectable, () => "/some-directory-for-lens-local-storage"); @@ -58,6 +63,7 @@ describe("installing helm chart from new tab", () => { windowDi.override(requestHelmChartReadmeInjectable, () => requestHelmChartReadmeMock); windowDi.override(requestHelmChartValuesInjectable, () => requestHelmChartValuesMock); windowDi.override(requestCreateHelmReleaseInjectable, () => requestCreateHelmReleaseMock); + windowDi.override(requestHelmReleasesInjectable, () => requestHelmReleasesMock); windowDi.override(getRandomInstallChartTabIdInjectable, () => jest @@ -386,12 +392,15 @@ describe("installing helm chart from new tab", () => { }); describe("when selected to see the installed release", () => { - beforeEach(() => { + beforeEach(async () => { const releaseButton = rendered.getByTestId( "show-release-some-release-for-some-first-tab-id", ); fireEvent.click(releaseButton); + + await flushPromises(); + await requestHelmReleasesMock.resolve([]); }); it("renders", () => { diff --git a/packages/core/src/features/helm-releases/showing-details-for-helm-release.test.ts b/packages/core/src/features/helm-releases/showing-details-for-helm-release.test.ts index ca947358ad..88ec114eb0 100644 --- a/packages/core/src/features/helm-releases/showing-details-for-helm-release.test.ts +++ b/packages/core/src/features/helm-releases/showing-details-for-helm-release.test.ts @@ -78,9 +78,12 @@ describe("showing details for helm release", () => { }); builder.namespaces.add("some-namespace"); - builder.namespaces.select("some-namespace"); builder.namespaces.add("some-namespace"); - builder.namespaces.select("some-other-namespace"); + + builder.afterWindowStart(() => { + builder.namespaces.select("some-namespace"); + builder.namespaces.select("some-other-namespace"); + }); }); describe("given application is started", () => { @@ -106,10 +109,9 @@ describe("showing details for helm release", () => { }); it("calls for releases for each selected namespace", () => { - expect(requestHelmReleasesMock.mock.calls).toEqual([ - ["some-namespace"], - ["some-other-namespace"], - ]); + expect(requestHelmReleasesMock).toBeCalledTimes(2); + expect(requestHelmReleasesMock).toBeCalledWith("some-namespace"); + expect(requestHelmReleasesMock).toBeCalledWith("some-other-namespace"); }); it("shows spinner", () => { diff --git a/packages/core/src/features/namespace-filtering/renderer/storage.injectable.ts b/packages/core/src/features/namespace-filtering/renderer/storage.injectable.ts new file mode 100644 index 0000000000..d69111a35e --- /dev/null +++ b/packages/core/src/features/namespace-filtering/renderer/storage.injectable.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import assert from "assert"; +import hostedClusterInjectable from "../../../renderer/cluster-frame-context/hosted-cluster.injectable"; +import createStorageInjectable from "../../../renderer/utils/create-storage/create-storage.injectable"; + +const selectedNamespacesStorageInjectable = getInjectable({ + id: "selected-namespaces-storage", + instantiate: (di) => { + const createStorage = di.inject(createStorageInjectable); + const cluster = di.inject(hostedClusterInjectable); + + assert(cluster, "selectedNamespacesStorage is only available in certain environments"); + + const defaultSelectedNamespaces = cluster.allowedNamespaces.includes("default") + ? ["default"] + : cluster.allowedNamespaces.slice(0, 1); + + return createStorage("selected_namespaces", defaultSelectedNamespaces); + }, +}); + +export default selectedNamespacesStorageInjectable; diff --git a/packages/core/src/main/cluster/request-api-versions.ts b/packages/core/src/main/cluster/request-api-versions.ts index be9ec2c21a..ac5a08e675 100644 --- a/packages/core/src/main/cluster/request-api-versions.ts +++ b/packages/core/src/main/cluster/request-api-versions.ts @@ -4,7 +4,6 @@ */ import { getInjectionToken } from "@ogre-tools/injectable"; -import type { Cluster } from "../../common/cluster/cluster"; import type { AsyncResult } from "../../common/utils/async-result"; export interface KubeResourceListGroup { @@ -12,7 +11,11 @@ export interface KubeResourceListGroup { path: string; } -export type RequestApiVersions = (cluster: Cluster) => Promise>; +export interface ClusterData { + readonly id: string; +} + +export type RequestApiVersions = (cluster: ClusterData) => Promise>; export const requestApiVersionsInjectionToken = getInjectionToken({ id: "request-api-versions-token", diff --git a/packages/core/src/main/cluster/request-non-core-api-versions.injectable.ts b/packages/core/src/main/cluster/request-non-core-api-versions.injectable.ts index e5b241c75f..25f8676c2a 100644 --- a/packages/core/src/main/cluster/request-non-core-api-versions.injectable.ts +++ b/packages/core/src/main/cluster/request-non-core-api-versions.injectable.ts @@ -20,10 +20,10 @@ const requestNonCoreApiVersionsInjectable = getInjectable({ return { callWasSuccessful: true, response: chain(groups.values()) - .filterMap(group => group.preferredVersion?.groupVersion && ({ + .flatMap(group => group.versions.map(version => ({ group: group.name, - path: `/apis/${group.preferredVersion.groupVersion}`, - })) + path: `/apis/${version.groupVersion}`, + }))) .collect(v => [...v]), }; } catch (error) { diff --git a/packages/core/src/main/cluster/request-non-core-api-versions.test.ts b/packages/core/src/main/cluster/request-non-core-api-versions.test.ts new file mode 100644 index 0000000000..34c2492b4f --- /dev/null +++ b/packages/core/src/main/cluster/request-non-core-api-versions.test.ts @@ -0,0 +1,167 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { AsyncFnMock } from "@async-fn/jest"; +import asyncFn from "@async-fn/jest"; +import type { V1APIGroupList } from "@kubernetes/client-node"; +import type { DiContainer } from "@ogre-tools/injectable"; +import { getDiForUnitTesting } from "../getDiForUnitTesting"; +import type { K8sRequest } from "../k8s-request.injectable"; +import k8sRequestInjectable from "../k8s-request.injectable"; +import type { RequestApiVersions } from "./request-api-versions"; +import requestNonCoreApiVersionsInjectable from "./request-non-core-api-versions.injectable"; + +describe("requestNonCoreApiVersions", () => { + let di: DiContainer; + let k8sRequestMock: AsyncFnMock; + let requestNonCoreApiVersions: RequestApiVersions; + + beforeEach(() => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + k8sRequestMock = asyncFn(); + di.override(k8sRequestInjectable, () => k8sRequestMock); + + requestNonCoreApiVersions = di.inject(requestNonCoreApiVersionsInjectable); + }); + + describe("when called", () => { + let versionsRequest: ReturnType; + + beforeEach(() => { + versionsRequest = requestNonCoreApiVersions({ id: "some-cluster-id" }); + }); + + it("should request all api groups", () => { + expect(k8sRequestMock).toBeCalledWith({ id: "some-cluster-id" }, "/apis"); + }); + + describe("when api groups request resolves to empty", () => { + beforeEach(async () => { + await k8sRequestMock.resolve({ groups: [] } as V1APIGroupList); + }); + + it("should return empty list", async () => { + expect(await versionsRequest).toEqual({ + callWasSuccessful: true, + response: [], + }); + }); + }); + + describe("when api groups request resolves to single group", () => { + beforeEach(async () => { + await k8sRequestMock.resolve({ groups: [{ + name: "some-name", + versions: [{ + groupVersion: "some-name/v1", + version: "v1", + }], + }] } as V1APIGroupList); + }); + + it("should return single entry in list", async () => { + expect(await versionsRequest).toEqual({ + callWasSuccessful: true, + response: [{ + group: "some-name", + path: "/apis/some-name/v1", + }], + }); + }); + }); + + describe("when api groups request resolves to single group with multiple versions", () => { + beforeEach(async () => { + await k8sRequestMock.resolve({ groups: [{ + name: "some-name", + versions: [ + { + groupVersion: "some-name/v1", + version: "v1", + }, + { + groupVersion: "some-name/v1beta1", + version: "v1beta1", + }, + ], + }] } as V1APIGroupList); + }); + + it("should return multiple entries in list", async () => { + expect(await versionsRequest).toEqual({ + callWasSuccessful: true, + response: [ + { + group: "some-name", + path: "/apis/some-name/v1", + }, + { + group: "some-name", + path: "/apis/some-name/v1beta1", + }, + ], + }); + }); + }); + + describe("when api groups request resolves to multiple groups with multiple versions", () => { + beforeEach(async () => { + await k8sRequestMock.resolve({ groups: [ + { + name: "some-name", + versions: [ + { + groupVersion: "some-name/v1", + version: "v1", + }, + { + groupVersion: "some-name/v1beta1", + version: "v1beta1", + }, + ], + }, + { + name: "some-other-name.foo.com", + versions: [ + { + groupVersion: "some-other-name.foo.com/v1", + version: "v1", + }, + { + groupVersion: "some-other-name.foo.com/v1beta1", + version: "v1beta1", + }, + ], + }, + ] } as V1APIGroupList); + }); + + it("should return multiple entries in list", async () => { + expect(await versionsRequest).toEqual({ + callWasSuccessful: true, + response: [ + { + group: "some-name", + path: "/apis/some-name/v1", + }, + { + group: "some-name", + path: "/apis/some-name/v1beta1", + }, + { + group: "some-other-name.foo.com", + path: "/apis/some-other-name.foo.com/v1", + }, + { + group: "some-other-name.foo.com", + path: "/apis/some-other-name.foo.com/v1beta1", + }, + ], + }); + }); + }); + }); +}); diff --git a/packages/core/src/main/k8s-request.injectable.ts b/packages/core/src/main/k8s-request.injectable.ts index b15056b50b..70ca3c3b6e 100644 --- a/packages/core/src/main/k8s-request.injectable.ts +++ b/packages/core/src/main/k8s-request.injectable.ts @@ -2,7 +2,6 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import type { Cluster } from "../common/cluster/cluster"; import { getInjectable } from "@ogre-tools/injectable"; import type { LensRequestInit } from "../common/fetch/lens-fetch.injectable"; import lensFetchInjectable from "../common/fetch/lens-fetch.injectable"; @@ -12,7 +11,11 @@ export interface K8sRequestInit extends LensRequestInit { timeout?: number; } -export type K8sRequest = (cluster: Cluster, pathnameAndQuery: string, init?: K8sRequestInit) => Promise; +export interface ClusterData { + readonly id: string; +} + +export type K8sRequest = (cluster: ClusterData, pathnameAndQuery: string, init?: K8sRequestInit) => Promise; const k8sRequestInjectable = getInjectable({ id: "k8s-request", diff --git a/packages/core/src/renderer/cluster-frame-context/for-namespaced-resources.injectable.ts b/packages/core/src/renderer/cluster-frame-context/for-namespaced-resources.injectable.ts index 1eb85b3a43..f4795f8523 100644 --- a/packages/core/src/renderer/cluster-frame-context/for-namespaced-resources.injectable.ts +++ b/packages/core/src/renderer/cluster-frame-context/for-namespaced-resources.injectable.ts @@ -8,6 +8,7 @@ import namespaceStoreInjectable from "../components/+namespaces/store.injectable import hostedClusterInjectable from "./hosted-cluster.injectable"; import assert from "assert"; import { computed } from "mobx"; +import selectedNamespacesStorageInjectable from "../../features/namespace-filtering/renderer/storage.injectable"; const clusterFrameContextForNamespacedResourcesInjectable = getInjectable({ id: "cluster-frame-context-for-namespaced-resources", @@ -15,6 +16,7 @@ const clusterFrameContextForNamespacedResourcesInjectable = getInjectable({ instantiate: (di): ClusterContext => { const cluster = di.inject(hostedClusterInjectable); const namespaceStore = di.inject(namespaceStoreInjectable); + const selectedNamespacesStorage = di.inject(selectedNamespacesStorageInjectable); assert(cluster, "This can only be injected within a cluster frame"); @@ -32,13 +34,19 @@ const clusterFrameContextForNamespacedResourcesInjectable = getInjectable({ // fallback to cluster resolved namespaces because we could not load list return cluster.allowedNamespaces.slice(); }); - const contextNamespaces = computed(() => namespaceStore.contextNamespaces); + const contextNamespaces = computed(() => { + const selectedNamespaces = selectedNamespacesStorage.get(); + + return selectedNamespaces.length > 0 + ? selectedNamespaces + : allNamespaces.get(); + }); const hasSelectedAll = computed(() => { const namespaces = new Set(contextNamespaces.get()); return allNamespaces.get().length > 1 - && cluster.accessibleNamespaces.length === 0 - && allNamespaces.get().every(ns => namespaces.has(ns)); + && cluster.accessibleNamespaces.length === 0 + && allNamespaces.get().every(ns => namespaces.has(ns)); }); return { diff --git a/packages/core/src/renderer/components/+helm-releases/releases.injectable.ts b/packages/core/src/renderer/components/+helm-releases/releases.injectable.ts index 7f4d2d286b..ae90a67e18 100644 --- a/packages/core/src/renderer/components/+helm-releases/releases.injectable.ts +++ b/packages/core/src/renderer/components/+helm-releases/releases.injectable.ts @@ -22,13 +22,11 @@ const releasesInjectable = getInjectable({ getValueFromObservedPromise: async () => { void releaseSecrets.get(); - const releaseArrays = await (clusterContext.hasSelectedAll - ? requestHelmReleases() - : Promise.all( - clusterContext.contextNamespaces.map((namespace) => - requestHelmReleases(namespace), - ), - )); + const releaseArrays = await ( + clusterContext.hasSelectedAll + ? requestHelmReleases() + : Promise.all(clusterContext.contextNamespaces.map((namespace) => requestHelmReleases(namespace))) + ); return releaseArrays.flat().map(toHelmRelease); }, diff --git a/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-select-filter.test.tsx.snap b/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-select-filter.test.tsx.snap index 335cc1d159..a03fe634d7 100644 --- a/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-select-filter.test.tsx.snap +++ b/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-select-filter.test.tsx.snap @@ -1412,7 +1412,7 @@ exports[` once the subscribe resolves when clicked when - test-10 + test-2 @@ -1436,7 +1436,7 @@ exports[` once the subscribe resolves when clicked when - test-11 + test-10 @@ -1460,7 +1460,7 @@ exports[` once the subscribe resolves when clicked when - test-12 + test-11 @@ -1484,7 +1484,7 @@ exports[` once the subscribe resolves when clicked when - test-13 + test-12 @@ -1508,7 +1508,7 @@ exports[` once the subscribe resolves when clicked when - test-2 + test-13 diff --git a/packages/core/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts b/packages/core/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts index 5578767cf1..d84292410b 100644 --- a/packages/core/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts +++ b/packages/core/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts @@ -6,6 +6,7 @@ import { namespaceSelectFilterModelFor } from "./namespace-select-filter-model"; import { getInjectable } from "@ogre-tools/injectable"; import namespaceStoreInjectable from "../store.injectable"; import isMultiSelectionKeyInjectable from "./is-selection-key.injectable"; +import clusterFrameContextForNamespacedResourcesInjectable from "../../../cluster-frame-context/for-namespaced-resources.injectable"; const namespaceSelectFilterModelInjectable = getInjectable({ id: "namespace-select-filter-model", @@ -13,6 +14,7 @@ const namespaceSelectFilterModelInjectable = getInjectable({ instantiate: (di) => namespaceSelectFilterModelFor({ namespaceStore: di.inject(namespaceStoreInjectable), isMultiSelectionKey: di.inject(isMultiSelectionKeyInjectable), + context: di.inject(clusterFrameContextForNamespacedResourcesInjectable), }), }); diff --git a/packages/core/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx b/packages/core/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx index a3d850cdcd..7f99db46f3 100644 --- a/packages/core/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx +++ b/packages/core/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx @@ -11,8 +11,10 @@ import { Icon } from "../../icon"; import type { SelectOption } from "../../select"; import { observableCrate } from "../../../utils"; import type { IsMultiSelectionKey } from "./is-selection-key.injectable"; +import type { ClusterContext } from "../../../cluster-frame-context/cluster-frame-context"; interface Dependencies { + context: ClusterContext; namespaceStore: NamespaceStore; isMultiSelectionKey: IsMultiSelectionKey; } @@ -44,7 +46,7 @@ enum SelectMenuState { } export function namespaceSelectFilterModelFor(dependencies: Dependencies): NamespaceSelectFilterModel { - const { isMultiSelectionKey, namespaceStore } = dependencies; + const { isMultiSelectionKey, namespaceStore, context } = dependencies; let didToggle = false; let isMultiSelection = false; @@ -56,7 +58,7 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names didToggle = false; }, }]); - const selectedNames = computed(() => new Set(namespaceStore.contextNamespaces), { + const selectedNames = computed(() => new Set(context.contextNamespaces), { equals: comparer.structural, }); const optionsSortingSelected = observable.set(selectedNames.get()); @@ -78,9 +80,8 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names label: "All Namespaces", id: "all-namespaces", }, - ...namespaceStore - .items - .map(ns => ns.getName()) + ...context + .allNamespaces .sort(sortNamespacesByIfTheyHaveBeenSelected) .map(namespace => ({ value: namespace, diff --git a/packages/core/src/renderer/components/+namespaces/namespace-select.tsx b/packages/core/src/renderer/components/+namespaces/namespace-select.tsx index 6c50f3f921..baf2ba2d92 100644 --- a/packages/core/src/renderer/components/+namespaces/namespace-select.tsx +++ b/packages/core/src/renderer/components/+namespaces/namespace-select.tsx @@ -12,9 +12,9 @@ import type { SelectProps } from "../select"; import { Select } from "../select"; import { cssNames } from "../../utils"; import { Icon } from "../icon"; -import type { NamespaceStore } from "./store"; import { withInjectables } from "@ogre-tools/injectable-react"; -import namespaceStoreInjectable from "./store.injectable"; +import clusterFrameContextForNamespacedResourcesInjectable from "../../cluster-frame-context/for-namespaced-resources.injectable"; +import type { ClusterContext } from "../../cluster-frame-context/cluster-frame-context"; export type NamespaceSelectSort = (left: string, right: string) => number; @@ -25,12 +25,12 @@ export interface NamespaceSelectProps extends Omit { - const baseOptions = namespaceStore.items.map(ns => ns.getName()); + const baseOptions = context.allNamespaces; if (sort) { baseOptions.sort(sort); @@ -44,16 +44,16 @@ function getOptions(namespaceStore: NamespaceStore, sort: NamespaceSelectSort | } const NonInjectedNamespaceSelect = observer(({ - namespaceStore, + context, showIcons, formatOptionLabel, sort, className, ...selectProps }: Dependencies & NamespaceSelectProps) => { - const [baseOptions, setBaseOptions] = useState(getOptions(namespaceStore, sort)); + const [baseOptions, setBaseOptions] = useState(getOptions(context, sort)); - useEffect(() => setBaseOptions(getOptions(namespaceStore, sort)), [sort]); + useEffect(() => setBaseOptions(getOptions(context, sort)), [sort]); return (