1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Fix opening of release details (#5850)

* Make sure release details are updates when opening details

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Relax filtering of resources to prevent crashing when release has installed resources in another namespace

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add Open Closed Principle compliant way to introduce global overrides without modification in getDiForUnitTesting

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Rework helm release details to fix multiple bugs

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove redundant optional chaining

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Simplify code

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen 2022-07-26 14:57:46 +03:00 committed by GitHub
parent e7e8d1688c
commit bedc440d42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 17798 additions and 566 deletions

View File

@ -11123,6 +11123,49 @@ exports[`installing helm chart from new tab given tab for installing chart was n
</div>
</div>
</div>
<div
class="Animate slide-right Drawer ReleaseDetails dark right enter"
data-testid="helm-release-details-for-default/some-release"
style="--size: 725px; --enter-duration: 100ms; --leave-duration: 100ms;"
>
<div
class="drawer-wrapper flex column"
>
<div
class="drawer-title flex align-center"
>
<div
class="drawer-title-text flex gaps align-center"
>
</div>
<i
class="Icon material interactive focusable"
data-testid="close-helm-release-detail"
tabindex="0"
tooltip="Close"
>
<span
class="icon"
data-icon-name="close"
>
close
</span>
</i>
</div>
<div
class="drawer-content flex column box grow"
>
<div
class="Spinner singleColor center"
data-testid="helm-release-detail-content-spinner"
/>
</div>
</div>
<div
class="ResizingAnchor horizontal leading"
/>
</div>
</body>
`;

View File

@ -0,0 +1,604 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import { getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import navigateToHelmReleasesInjectable from "../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import type { RenderResult } from "@testing-library/react";
import { fireEvent } from "@testing-library/react";
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import type { CallForHelmReleases } from "../../renderer/components/+helm-releases/call-for-helm-releases/call-for-helm-releases.injectable";
import callForHelmReleasesInjectable from "../../renderer/components/+helm-releases/call-for-helm-releases/call-for-helm-releases.injectable";
import namespaceStoreInjectable from "../../renderer/components/+namespaces/store.injectable";
import type { NamespaceStore } from "../../renderer/components/+namespaces/store";
import type { CallForHelmReleaseConfiguration } from "../../renderer/components/+helm-releases/release-details/release-details-model/call-for-helm-release-configuration/call-for-helm-release-configuration.injectable";
import callForHelmReleaseConfigurationInjectable from "../../renderer/components/+helm-releases/release-details/release-details-model/call-for-helm-release-configuration/call-for-helm-release-configuration.injectable";
import type { CallForHelmReleaseUpdate } from "../../renderer/components/+helm-releases/update-release/call-for-helm-release-update/call-for-helm-release-update.injectable";
import callForHelmReleaseUpdateInjectable from "../../renderer/components/+helm-releases/update-release/call-for-helm-release-update/call-for-helm-release-update.injectable";
import { useFakeTime } from "../../common/test-utils/use-fake-time";
import type { CallForHelmRelease, DetailedHelmRelease } from "../../renderer/components/+helm-releases/release-details/release-details-model/call-for-helm-release/call-for-helm-release.injectable";
import callForHelmReleaseInjectable from "../../renderer/components/+helm-releases/release-details/release-details-model/call-for-helm-release/call-for-helm-release.injectable";
import showSuccessNotificationInjectable from "../../renderer/components/notifications/show-success-notification.injectable";
import showCheckedErrorInjectable from "../../renderer/components/notifications/show-checked-error.injectable";
import getRandomUpgradeChartTabIdInjectable from "../../renderer/components/dock/upgrade-chart/get-random-upgrade-chart-tab-id.injectable";
// TODO: Make tooltips free of side effects by making it deterministic
jest.mock("../../renderer/components/tooltip/withTooltip", () => ({
withTooltip: (target: any) => target,
}));
describe("showing details for helm release", () => {
let builder: ApplicationBuilder;
let callForHelmReleasesMock: AsyncFnMock<CallForHelmReleases>;
let callForHelmReleaseMock: AsyncFnMock<CallForHelmRelease>;
let callForHelmReleaseConfigurationMock: AsyncFnMock<CallForHelmReleaseConfiguration>;
let callForHelmReleaseUpdateMock: AsyncFnMock<CallForHelmReleaseUpdate>;
let showSuccessNotificationMock: jest.Mock;
let showCheckedErrorNotificationMock: jest.Mock;
beforeEach(() => {
useFakeTime("2015-10-21T07:28:00Z");
builder = getApplicationBuilder();
builder.setEnvironmentToClusterFrame();
callForHelmReleasesMock = asyncFn();
callForHelmReleaseMock = asyncFn();
callForHelmReleaseConfigurationMock = asyncFn();
callForHelmReleaseUpdateMock = asyncFn();
showSuccessNotificationMock = jest.fn();
showCheckedErrorNotificationMock = jest.fn();
builder.beforeApplicationStart(({ rendererDi }) => {
rendererDi.override(
getRandomUpgradeChartTabIdInjectable,
() => () => "some-tab-id",
);
rendererDi.override(
showSuccessNotificationInjectable,
() => showSuccessNotificationMock,
);
rendererDi.override(
showCheckedErrorInjectable,
() => showCheckedErrorNotificationMock,
);
rendererDi.override(
callForHelmReleasesInjectable,
() => callForHelmReleasesMock,
);
rendererDi.override(
callForHelmReleaseInjectable,
() => callForHelmReleaseMock,
);
rendererDi.override(
callForHelmReleaseConfigurationInjectable,
() => callForHelmReleaseConfigurationMock,
);
rendererDi.override(
callForHelmReleaseUpdateInjectable,
() => callForHelmReleaseUpdateMock,
);
rendererDi.override(
namespaceStoreInjectable,
() =>
({
contextNamespaces: ["some-namespace", "some-other-namespace"],
items: [],
selectNamespaces: () => {},
} as unknown as NamespaceStore),
);
});
});
describe("given application is started", () => {
let rendered: RenderResult;
beforeEach(async () => {
rendered = await builder.render();
});
describe("when navigating to helm releases", () => {
beforeEach(() => {
const rendererDi = builder.dis.rendererDi;
const navigateToHelmReleases = rendererDi.inject(
navigateToHelmReleasesInjectable,
);
navigateToHelmReleases();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("calls for releases for each selected namespace", () => {
expect(callForHelmReleasesMock.mock.calls).toEqual([
["some-namespace"],
["some-other-namespace"],
]);
});
it("shows spinner", () => {
expect(
rendered.getByTestId("helm-releases-spinner"),
).toBeInTheDocument();
});
it("when releases resolve but there is none, renders", async () => {
await callForHelmReleasesMock.resolve([]);
await callForHelmReleasesMock.resolve([]);
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when releases resolve", () => {
beforeEach(async () => {
await callForHelmReleasesMock.resolveSpecific(
([namespace]) => namespace === "some-namespace",
[
{
appVersion: "some-app-version",
name: "some-name",
namespace: "some-namespace",
chart: "some-chart",
status: "some-status",
updated: "some-updated",
revision: "some-revision",
},
],
);
await callForHelmReleasesMock.resolveSpecific(
([namespace]) => namespace === "some-other-namespace",
[
{
appVersion: "some-other-app-version",
name: "some-other-name",
namespace: "some-other-namespace",
chart: "some-other-chart",
status: "some-other-status",
updated: "some-other-updated",
revision: "some-other-revision",
},
],
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not show spinner anymore", () => {
expect(
rendered.queryByTestId("helm-releases-spinner"),
).not.toBeInTheDocument();
});
describe("when selecting release to see details", () => {
beforeEach(() => {
const row = rendered.getByTestId(
"helm-release-row-for-some-namespace/some-name",
);
fireEvent.click(row);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("opens the details", () => {
expect(
rendered.getByTestId("helm-release-details-for-some-namespace/some-name"),
).toBeInTheDocument();
});
it("calls for release", () => {
expect(callForHelmReleaseMock).toHaveBeenCalledWith(
"some-name",
"some-namespace",
);
});
it("shows spinner", () => {
expect(
rendered.getByTestId("helm-release-detail-content-spinner"),
).toBeInTheDocument();
});
describe("when opening details for second release", () => {
beforeEach(() => {
callForHelmReleaseMock.mockClear();
const row = rendered.getByTestId(
"helm-release-row-for-some-other-namespace/some-other-name",
);
fireEvent.click(row);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("calls for another release", () => {
expect(callForHelmReleaseMock).toHaveBeenCalledWith(
"some-other-name",
"some-other-namespace",
);
});
it("closes details for first release", () => {
expect(
rendered.queryByTestId("helm-release-details-for-some-namespace/some-name"),
).not.toBeInTheDocument();
});
it("opens details for second release", () => {
expect(
rendered.getByTestId("helm-release-details-for-some-other-namespace/some-other-name"),
).toBeInTheDocument();
});
});
describe("when details is closed", () => {
beforeEach(() => {
const closeButton = rendered.getByTestId(
"close-helm-release-detail",
);
fireEvent.click(closeButton);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("closes the details", () => {
expect(
rendered.queryByTestId("helm-release-details-for-some-namespace/some-name"),
).not.toBeInTheDocument();
});
describe("when opening details for same release", () => {
beforeEach(() => {
callForHelmReleaseMock.mockClear();
const row = rendered.getByTestId(
"helm-release-row-for-some-namespace/some-name",
);
fireEvent.click(row);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not reload", () => {
expect(callForHelmReleaseMock).not.toHaveBeenCalled();
});
});
});
it("when release resolve with no data, renders", async () => {
await callForHelmReleaseMock.resolve(undefined);
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when details resolve", () => {
beforeEach(async () => {
await callForHelmReleaseMock.resolve(detailedReleaseFake);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("calls for release configuration", () => {
expect(callForHelmReleaseConfigurationMock).toHaveBeenCalledWith(
"some-name",
"some-namespace",
true,
);
});
describe("when configuration resolves", () => {
beforeEach(async () => {
await callForHelmReleaseConfigurationMock.resolve(
"some-configuration",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not have tab for upgrading chart yet", () => {
expect(
rendered.queryByTestId("dock-tab-for-some-tab-id"),
).not.toBeInTheDocument();
});
describe("when selecting to upgrade chart", () => {
beforeEach(() => {
const upgradeButton = rendered.getByTestId(
"helm-release-upgrade-button",
);
fireEvent.click(upgradeButton);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("opens tab for upgrading chart", () => {
expect(
rendered.getByTestId("dock-tab-for-some-tab-id"),
).toBeInTheDocument();
});
it("closes the details", () => {
expect(
rendered.queryByTestId("helm-release-details-for-some-namespace/some-name"),
).not.toBeInTheDocument();
});
});
describe("when changing the configuration", () => {
beforeEach(() => {
const configuration = rendered.getByTestId(
"monaco-editor-for-helm-release-configuration",
);
fireEvent.change(configuration, {
target: { value: "some-new-configuration" },
});
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("has the configuration", () => {
const input = rendered.getByTestId(
"monaco-editor-for-helm-release-configuration",
);
expect(input).toHaveValue("some-new-configuration");
});
it("does not save changes yet", () => {
expect(callForHelmReleaseUpdateMock).not.toHaveBeenCalled();
});
describe("when toggling to see only user defined values", () => {
beforeEach(() => {
callForHelmReleaseConfigurationMock.mockClear();
const toggle = rendered.getByTestId(
"user-supplied-values-only-checkbox",
);
fireEvent.click(toggle);
});
it("calls for only user defined configuration", () => {
expect(callForHelmReleaseConfigurationMock).toHaveBeenCalledWith(
"some-name",
"some-namespace",
false,
);
});
describe("when configuration resolves", () => {
beforeEach(async () => {
await callForHelmReleaseConfigurationMock.resolve(
"some-other-configuration",
);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("overrides the user inputted configuration with new configuration", () => {
const input = rendered.getByTestId(
"monaco-editor-for-helm-release-configuration",
);
expect(input).toHaveValue("some-other-configuration");
});
it("when toggling again, calls for all configuration", () => {
callForHelmReleaseConfigurationMock.mockClear();
const toggle = rendered.getByTestId(
"user-supplied-values-only-checkbox",
);
fireEvent.click(toggle);
expect(callForHelmReleaseConfigurationMock).toHaveBeenCalledWith(
"some-name",
"some-namespace",
true,
);
});
});
});
describe("when saving", () => {
beforeEach(() => {
const saveButton = rendered.getByTestId(
"helm-release-configuration-save-button",
);
fireEvent.click(saveButton);
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("calls for update", () => {
expect(callForHelmReleaseUpdateMock).toHaveBeenCalledWith(
"some-name",
"some-namespace",
{
chart: "some-chart",
repo: "",
values: "some-new-configuration",
version: "",
},
);
});
it("shows spinner", () => {
const saveButton = rendered.getByTestId(
"helm-release-configuration-save-button",
);
expect(saveButton).toHaveClass("waiting");
});
describe("when update resolves with success", () => {
beforeEach(async () => {
callForHelmReleasesMock.mockClear();
callForHelmReleaseConfigurationMock.mockClear();
await callForHelmReleaseUpdateMock.resolve({
updateWasSuccessful: true,
});
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not show spinner anymore", () => {
const saveButton = rendered.getByTestId(
"helm-release-configuration-save-button",
);
expect(saveButton).not.toHaveClass("waiting");
});
it("reloads the configuration", () => {
expect(callForHelmReleaseConfigurationMock).toHaveBeenCalledWith(
"some-name",
"some-namespace",
true,
);
});
it("shows success notification", () => {
expect(showSuccessNotificationMock).toHaveBeenCalled();
});
it("does not show error notification", () => {
expect(showCheckedErrorNotificationMock).not.toHaveBeenCalled();
});
});
describe("when update resolves with failure", () => {
beforeEach(async () => {
callForHelmReleasesMock.mockClear();
callForHelmReleaseConfigurationMock.mockClear();
await callForHelmReleaseUpdateMock.resolve({
updateWasSuccessful: false,
error: "some-error",
});
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("does not show spinner anymore", () => {
const saveButton = rendered.getByTestId(
"helm-release-configuration-save-button",
);
expect(saveButton).not.toHaveClass("waiting");
});
it("does not reload the configuration", () => {
expect(callForHelmReleaseConfigurationMock).not.toHaveBeenCalled();
});
it("does not show success notification", () => {
expect(showSuccessNotificationMock).not.toHaveBeenCalled();
});
it("shows error notification", () => {
expect(showCheckedErrorNotificationMock).toHaveBeenCalled();
});
});
});
});
});
});
});
});
});
});
});
const detailedReleaseFake: DetailedHelmRelease = {
release: {
appVersion: "some-app-version",
chart: "some-chart",
status: "some-status",
updated: "some-updated",
revision: "some-revision",
name: "some-name",
namespace: "some-namespace",
},
details: {
name: "some-name",
namespace: "some-namespace",
version: "some-version",
config: "some-config",
manifest: "some-manifest",
info: {
deleted: "some-deleted",
description: "some-description",
first_deployed: "some-first-deployed",
last_deployed: "some-last-deployed",
notes: "some-notes",
status: "some-status",
},
resources: [
{
kind: "some-kind",
apiVersion: "some-api-version",
metadata: {
uid: "some-uid",
name: "some-resource",
namespace: "some-namespace",
creationTimestamp: "2015-10-22T07:28:00Z",
},
},
],
},
};

View File

@ -3,48 +3,11 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import yaml from "js-yaml";
import { formatDuration } from "../../utils";
import capitalize from "lodash/capitalize";
import { apiBase } from "../index";
import { helmChartStore } from "../../../renderer/components/+helm-charts/helm-chart.store";
import type { ItemObject } from "../../item.store";
import type { JsonApiData } from "../json-api";
import { buildURLPositional } from "../../utils/buildUrl";
import type { KubeJsonApiData } from "../kube-json-api";
export interface HelmReleaseDetails {
resources: KubeJsonApiData[];
name: string;
namespace: string;
version: string;
config: string; // release values
manifest: string;
info: {
deleted: string;
description: string;
first_deployed: string;
last_deployed: string;
notes: string;
status: string;
};
}
export interface HelmReleaseCreatePayload {
name?: string;
repo: string;
chart: string;
namespace: string;
version: string;
values: string;
}
export interface HelmReleaseUpdatePayload {
repo: string;
chart: string;
version: string;
values: string;
}
import type { HelmReleaseDetails } from "../../../renderer/components/+helm-releases/release-details/release-details-model/call-for-helm-release/call-for-helm-release-details/call-for-helm-release-details.injectable";
export interface HelmReleaseUpdateDetails {
log: string;
@ -69,47 +32,7 @@ interface EndpointQuery {
all?: boolean;
}
const endpoint = buildURLPositional<EndpointParams, EndpointQuery>("/v2/releases/:namespace?/:name?/:route?");
export async function listReleases(namespace?: string): Promise<HelmRelease[]> {
const releases = await apiBase.get<HelmReleaseDto[]>(endpoint({ namespace }));
return releases.map(toHelmRelease);
}
export async function getRelease(name: string, namespace: string): Promise<HelmReleaseDetails> {
const path = endpoint({ name, namespace });
return apiBase.get(path);
}
export async function createRelease(payload: HelmReleaseCreatePayload): Promise<HelmReleaseUpdateDetails> {
const { repo, chart: rawChart, values: rawValues, ...data } = payload;
const chart = `${repo}/${rawChart}`;
const values = yaml.load(rawValues);
return apiBase.post(endpoint(), {
data: {
chart,
values,
...data,
},
});
}
export async function updateRelease(name: string, namespace: string, payload: HelmReleaseUpdatePayload): Promise<HelmReleaseUpdateDetails> {
const { repo, chart: rawChart, values: rawValues, ...data } = payload;
const chart = `${repo}/${rawChart}`;
const values = yaml.load(rawValues);
return apiBase.put(endpoint({ name, namespace }), {
data: {
chart,
values,
...data,
},
});
}
export const endpoint = buildURLPositional<EndpointParams, EndpointQuery>("/v2/releases/:namespace?/:name?/:route?");
export async function deleteRelease(name: string, namespace: string): Promise<JsonApiData> {
const path = endpoint({ name, namespace });
@ -139,7 +62,7 @@ export async function rollbackRelease(name: string, namespace: string, revision:
return apiBase.put(path, { data });
}
interface HelmReleaseDto {
export interface HelmReleaseDto {
appVersion: string;
name: string;
namespace: string;
@ -158,70 +81,3 @@ export interface HelmRelease extends HelmReleaseDto, ItemObject {
getUpdated: (humanize?: boolean, compact?: boolean) => string | number;
getRepo: () => Promise<string>;
}
const toHelmRelease = (release: HelmReleaseDto) : HelmRelease => ({
...release,
getId() {
return this.namespace + this.name;
},
getName() {
return this.name;
},
getNs() {
return this.namespace;
},
getChart(withVersion = false) {
let chart = this.chart;
if (!withVersion && this.getVersion() != "") {
const search = new RegExp(`-${this.getVersion()}`);
chart = chart.replace(search, "");
}
return chart;
},
getRevision() {
return parseInt(this.revision, 10);
},
getStatus() {
return capitalize(this.status);
},
getVersion() {
const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/);
return versions?.[0] ?? "";
},
getUpdated(humanize = true, compact = true) {
const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date()
const updatedDate = new Date(updated).getTime();
const diff = Date.now() - updatedDate;
if (humanize) {
return formatDuration(diff, compact);
}
return diff;
},
// Helm does not store from what repository the release is installed,
// so we have to try to guess it by searching charts
async getRepo() {
const chartName = this.getChart();
const version = this.getVersion();
const versions = await helmChartStore.getVersions(chartName);
const chartVersion = versions.find(
(chartVersion) => chartVersion.version === version,
);
return chartVersion ? chartVersion.repo : "";
},
});

View File

@ -0,0 +1,13 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Injectable } from "@ogre-tools/injectable";
export const getGlobalOverride = <T extends Injectable<any, any, any>>(
injectable: T,
overridingInstantiate: T["instantiate"],
) => ({
injectable,
overridingInstantiate,
});

View File

@ -124,6 +124,16 @@ export function getDiForUnitTesting(opts: { doGeneralOverrides?: boolean } = {})
di.preventSideEffects();
if (doGeneralOverrides) {
const globalOverrideFilePaths = getGlobalOverridePaths();
const globalOverrides = globalOverrideFilePaths.map(
(filePath) => require(filePath).default,
);
globalOverrides.forEach(globalOverride => {
di.override(globalOverride.injectable, globalOverride.overridingInstantiate);
});
di.override(electronInjectable, () => ({}));
di.override(waitUntilBundledExtensionsAreLoadedInjectable, () => async () => {});
di.override(getRandomIdInjectable, () => () => "some-irrelevant-random-id");
@ -211,6 +221,14 @@ const getInjectableFilePaths = memoize(() => [
...glob.sync("../common/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
]);
const getGlobalOverridePaths = memoize(() =>
glob.sync(
"../{common,extensions,main}/**/*.global-override-for-injectable.{ts,tsx}",
{ cwd: __dirname },
),
);
// TODO: Reorganize code in Runnables to get rid of requirement for override
const overrideRunnablesHavingSideEffects = (di: DiContainer) => {
[

View File

@ -191,7 +191,6 @@ async function getResources(name: string, namespace: string, kubeconfigPath: str
];
const kubectlArgs = [
"get",
"--namespace", namespace,
"--kubeconfig", kubeconfigPath,
"-f", "-",
"--output", "json",

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import callForHelmReleasesInjectable from "./call-for-helm-releases.injectable";
import { getGlobalOverride } from "../../../../common/test-utils/get-global-override";
export default getGlobalOverride(
callForHelmReleasesInjectable,
() => () => {
throw new Error(
"Tried to call for helm releases without explicit override.",
);
},
);

View File

@ -0,0 +1,24 @@
/**
* 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 type { HelmReleaseDto } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { apiBase } from "../../../../common/k8s-api";
import { endpoint } from "../../../../common/k8s-api/endpoints/helm-releases.api";
export type CallForHelmReleases = (
namespace?: string
) => Promise<HelmReleaseDto[]>;
const callForHelmReleasesInjectable = getInjectable({
id: "call-for-helm-releases",
instantiate: (): CallForHelmReleases => async (namespace) =>
await apiBase.get<HelmReleaseDto[]>(endpoint({ namespace })),
causesSideEffects: true,
});
export default callForHelmReleasesInjectable;

View File

@ -2,9 +2,20 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import yaml from "js-yaml";
import { getInjectable } from "@ogre-tools/injectable";
import type { HelmReleaseCreatePayload, HelmReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { createRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import type { HelmReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { endpoint } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { apiBase } from "../../../../common/k8s-api";
interface HelmReleaseCreatePayload {
name?: string;
repo: string;
chart: string;
namespace: string;
version: string;
values: string;
}
export type CallForCreateHelmRelease = (
payload: HelmReleaseCreatePayload
@ -12,7 +23,21 @@ export type CallForCreateHelmRelease = (
const callForCreateHelmReleaseInjectable = getInjectable({
id: "call-for-create-helm-release",
instantiate: (): CallForCreateHelmRelease => createRelease,
instantiate: (): CallForCreateHelmRelease => (payload) => {
const { repo, chart: rawChart, values: rawValues, ...data } = payload;
const chart = `${repo}/${rawChart}`;
const values = yaml.load(rawValues);
return apiBase.post(endpoint(), {
data: {
chart,
values,
...data,
},
});
},
causesSideEffects: true,
});

View File

@ -4,19 +4,18 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import type {
HelmReleaseCreatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import releasesInjectable from "../releases.injectable";
import type { CallForCreateHelmRelease } from "./call-for-create-helm-release.injectable";
import callForCreateHelmReleaseInjectable from "./call-for-create-helm-release.injectable";
const createReleaseInjectable = getInjectable({
id: "create-release",
instantiate: (di) => {
instantiate: (di): CallForCreateHelmRelease => {
const releases = di.inject(releasesInjectable);
const callForCreateRelease = di.inject(callForCreateHelmReleaseInjectable);
return async (payload: HelmReleaseCreatePayload) => {
return async (payload) => {
const release = await callForCreateRelease(payload);
releases.invalidate();

View File

@ -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 { computed } from "mobx";
import { clusterFrameChildComponentInjectionToken } from "../../../frames/cluster-frame/cluster-frame-child-component-injection-token";
import { ReleaseDetails } from "./release-details";
import targetHelmReleaseInjectable from "./target-helm-release.injectable";
const releaseDetailsClusterFrameChildComponentInjectable = getInjectable({
id: "release-details-cluster-frame-child-component",
instantiate: (di) => {
const targetRelease = di.inject(targetHelmReleaseInjectable);
return {
id: "release-details",
Component: ReleaseDetails,
shouldRender: computed(() => !!targetRelease.get()),
};
},
injectionToken: clusterFrameChildComponentInjectionToken,
});
export default releaseDetailsClusterFrameChildComponentInjectable;

View File

@ -0,0 +1,210 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./release-details.scss";
import React from "react";
import { Link } from "react-router-dom";
import { Drawer, DrawerItem, DrawerTitle } from "../../drawer";
import { cssNames, stopPropagation } from "../../../utils";
import { observer } from "mobx-react";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { ConfigurationInput, MinimalResourceGroup, OnlyUserSuppliedValuesAreShownToggle, ReleaseDetailsModel } from "./release-details-model/release-details-model.injectable";
import releaseDetailsModelInjectable from "./release-details-model/release-details-model.injectable";
import { Button } from "../../button";
import { kebabCase } from "lodash/fp";
import { Badge } from "../../badge";
import { SubTitle } from "../../layout/sub-title";
import { Table, TableCell, TableHead, TableRow } from "../../table";
import { ReactiveDuration } from "../../duration/reactive-duration";
import { HelmReleaseMenu } from "../release-menu";
import { Checkbox } from "../../checkbox";
import { MonacoEditor } from "../../monaco-editor";
import { Spinner } from "../../spinner";
import type { TargetHelmRelease } from "./target-helm-release.injectable";
interface ReleaseDetailsContentProps {
targetRelease: TargetHelmRelease;
}
interface Dependencies {
model: ReleaseDetailsModel;
}
const NonInjectedReleaseDetailsContent = observer(({ model }: Dependencies & ReleaseDetailsContentProps) => {
const isLoading = model.isLoading.get();
return (
<Drawer
className={cssNames("ReleaseDetails", model.activeTheme)}
usePortal={true}
open={true}
title={isLoading ? "" : model.release.getName()}
onClose={model.close}
testIdForClose="close-helm-release-detail"
toolbar={
!isLoading && (
<HelmReleaseMenu
release={model.release}
toolbar
hideDetails={model.close}
/>
)
}
data-testid={`helm-release-details-for-${model.id}`}
>
{isLoading ? (
<Spinner center data-testid="helm-release-detail-content-spinner" />
) : (
<div>
<DrawerItem name="Chart" className="chart">
<div className="flex gaps align-center">
<span>{model.release.chart}</span>
<Button
primary
label="Upgrade"
className="box right upgrade"
onClick={model.startUpgradeProcess}
data-testid="helm-release-upgrade-button"
/>
</div>
</DrawerItem>
<DrawerItem name="Updated">
{model.release.getUpdated()}
{` ago (${model.release.updated})`}
</DrawerItem>
<DrawerItem name="Namespace">{model.release.getNs()}</DrawerItem>
<DrawerItem name="Version" onClick={stopPropagation}>
<div className="version flex gaps align-center">
<span>{model.release.getVersion()}</span>
</div>
</DrawerItem>
<DrawerItem
name="Status"
className="status"
labelsOnly>
<Badge
label={model.release.getStatus()}
className={kebabCase(model.release.getStatus())}
/>
</DrawerItem>
<ReleaseValues
configuration={model.configuration}
onlyUserSuppliedValuesAreShown={
model.onlyUserSuppliedValuesAreShown
}
/>
<DrawerTitle>Notes</DrawerTitle>
{model.notes && <div className="notes">{model.notes}</div>}
<DrawerTitle>Resources</DrawerTitle>
{model.groupedResources.length > 0 && (
<div className="resources">
{model.groupedResources.map((group) => (
<ResourceGroup key={group.kind} group={group} />
))}
</div>
)}
</div>
)}
</Drawer>
);
});
export const ReleaseDetailsContent = withInjectables<Dependencies, ReleaseDetailsContentProps>(NonInjectedReleaseDetailsContent, {
getProps: (di, props) => ({
model: di.inject(releaseDetailsModelInjectable, props.targetRelease),
...props,
}),
});
const ResourceGroup = ({
group: { kind, isNamespaced, resources },
}: {
group: MinimalResourceGroup;
}) => (
<>
<SubTitle title={kind} />
<Table scrollable={false}>
<TableHead sticky={false}>
<TableCell className="name">Name</TableCell>
{isNamespaced && <TableCell className="namespace">Namespace</TableCell>}
<TableCell className="age">Age</TableCell>
</TableHead>
{resources.map(
({ creationTimestamp, detailsUrl, name, namespace, uid }) => (
<TableRow key={uid}>
<TableCell className="name">
{detailsUrl ? <Link to={detailsUrl}>{name}</Link> : name}
</TableCell>
{isNamespaced && (
<TableCell className="namespace">{namespace}</TableCell>
)}
<TableCell className="age">
<ReactiveDuration timestamp={creationTimestamp} />
</TableCell>
</TableRow>
),
)}
</Table>
</>
);
interface ReleaseValuesProps {
configuration: ConfigurationInput;
onlyUserSuppliedValuesAreShown: OnlyUserSuppliedValuesAreShownToggle;
}
const ReleaseValues = observer(({ configuration, onlyUserSuppliedValuesAreShown }: ReleaseValuesProps) => {
const configurationIsLoading = configuration.isLoading.get();
return (
<div className="values">
<DrawerTitle>Values</DrawerTitle>
<div className="flex column gaps">
<Checkbox
label="User-supplied values only"
value={onlyUserSuppliedValuesAreShown.value.get()}
onChange={onlyUserSuppliedValuesAreShown.toggle}
disabled={configurationIsLoading}
data-testid="user-supplied-values-only-checkbox"
/>
<MonacoEditor
id="helm-release-configuration"
style={{ minHeight: 300 }}
value={configuration.nonSavedValue.get()}
onChange={configuration.onChange}
/>
<Button
primary
label="Save"
waiting={configuration.isSaving.get()}
disabled={configurationIsLoading}
onClick={configuration.save}
data-testid="helm-release-configuration-save-button"
/>
</div>
</div>
);
});

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../../../../common/test-utils/get-global-override";
import callForHelmReleaseConfigurationInjectable from "./call-for-helm-release-configuration.injectable";
export default getGlobalOverride(
callForHelmReleaseConfigurationInjectable,
() => () => {
throw new Error(
"Tried to call for helm release configuration without explicit override.",
);
},
);

View File

@ -0,0 +1,29 @@
/**
* 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 { apiBase } from "../../../../../../common/k8s-api";
import { endpoint } from "../../../../../../common/k8s-api/endpoints/helm-releases.api";
export type CallForHelmReleaseConfiguration = (
name: string,
namespace: string,
all: boolean
) => Promise<string>;
const callForHelmReleaseConfigurationInjectable = getInjectable({
id: "call-for-helm-release-configuration",
instantiate:
(): CallForHelmReleaseConfiguration => async (name, namespace, all: boolean) => {
const route = "values";
const path = endpoint({ name, namespace, route }, { all });
return apiBase.get<string>(path);
},
causesSideEffects: true,
});
export default callForHelmReleaseConfigurationInjectable;

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../../../../../common/test-utils/get-global-override";
import callForHelmReleaseDetailsInjectable from "./call-for-helm-release-details.injectable";
export default getGlobalOverride(
callForHelmReleaseDetailsInjectable,
() => () => {
throw new Error(
"Tried to call for helm release details without explicit override.",
);
},
);

View File

@ -0,0 +1,45 @@
/**
* 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 { apiBase } from "../../../../../../../common/k8s-api";
import { endpoint } from "../../../../../../../common/k8s-api/endpoints/helm-releases.api";
import type { KubeJsonApiData } from "../../../../../../../common/k8s-api/kube-json-api";
export interface HelmReleaseDetails {
resources: KubeJsonApiData[];
name: string;
namespace: string;
version: string;
config: string; // release values
manifest: string;
info: {
deleted: string;
description: string;
first_deployed: string;
last_deployed: string;
notes: string;
status: string;
};
}
export type CallForHelmReleaseDetails = (
name: string,
namespace: string
) => Promise<HelmReleaseDetails>;
const callForHelmReleaseDetailsInjectable = getInjectable({
id: "call-for-helm-release-details",
instantiate: (): CallForHelmReleaseDetails => async (name, namespace) => {
const path = endpoint({ name, namespace });
return apiBase.get<HelmReleaseDetails>(path);
},
causesSideEffects: true,
});
export default callForHelmReleaseDetailsInjectable;

View File

@ -0,0 +1,47 @@
/**
* 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 type { HelmReleaseDto } from "../../../../../../common/k8s-api/endpoints/helm-releases.api";
import callForHelmReleasesInjectable from "../../../call-for-helm-releases/call-for-helm-releases.injectable";
import type { HelmReleaseDetails } from "./call-for-helm-release-details/call-for-helm-release-details.injectable";
import callForHelmReleaseDetailsInjectable from "./call-for-helm-release-details/call-for-helm-release-details.injectable";
export interface DetailedHelmRelease {
release: HelmReleaseDto;
details: HelmReleaseDetails;
}
export type CallForHelmRelease = (
name: string,
namespace: string
) => Promise<DetailedHelmRelease | undefined>;
const callForHelmReleaseInjectable = getInjectable({
id: "call-for-helm-release",
instantiate: (di): CallForHelmRelease => {
const callForHelmReleases = di.inject(callForHelmReleasesInjectable);
const callForHelmReleaseDetails = di.inject(callForHelmReleaseDetailsInjectable);
return async (name, namespace) => {
const [releases, details] = await Promise.all([
callForHelmReleases(namespace),
callForHelmReleaseDetails(name, namespace),
]);
const release = releases.find(
(rel) => rel.name === name && rel.namespace === namespace,
);
if (!release) {
return undefined;
}
return { release, details };
};
},
});
export default callForHelmReleaseInjectable;

View File

@ -0,0 +1,45 @@
/**
* 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 apiManagerInjectable from "../../../../../common/k8s-api/api-manager/manager.injectable";
import getDetailsUrlInjectable from "../../../kube-detail-params/get-details-url.injectable";
export type GetResourceDetailsUrl = (
kind: string,
apiVersion: string,
namespace: string | undefined,
name: string
) => string;
const getResourceDetailsUrlInjectable = getInjectable({
id: "get-resource-details-url",
instantiate: (di): GetResourceDetailsUrl => {
const apiManager = di.inject(apiManagerInjectable);
const getDetailsUrl = di.inject(getDetailsUrlInjectable);
const getKubeApi = (kind: string, apiVersion: string) =>
apiManager.getApi(
(api) => api.kind === kind && api.apiVersionWithGroup == apiVersion,
);
return (kind, apiVersion, namespace, name) => {
const kubeApi = getKubeApi(kind, apiVersion);
if (!kubeApi) {
return "";
}
const resourceUrl = kubeApi.getUrl({
name,
namespace,
});
return getDetailsUrl(resourceUrl);
};
},
});
export default getResourceDetailsUrlInjectable;

View File

@ -0,0 +1,308 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { IObservableValue } from "mobx";
import { runInAction, action, observable, computed } from "mobx";
import type { TargetHelmRelease } from "../target-helm-release.injectable";
import type { CallForHelmRelease, DetailedHelmRelease } from "./call-for-helm-release/call-for-helm-release.injectable";
import callForHelmReleaseInjectable from "./call-for-helm-release/call-for-helm-release.injectable";
import type { ThemeStore } from "../../../../themes/store";
import themeStoreInjectable from "../../../../themes/store.injectable";
import type { CallForHelmReleaseConfiguration } from "./call-for-helm-release-configuration/call-for-helm-release-configuration.injectable";
import callForHelmReleaseConfigurationInjectable from "./call-for-helm-release-configuration/call-for-helm-release-configuration.injectable";
import { toHelmRelease } from "../../releases.injectable";
import { pipeline } from "@ogre-tools/fp";
import { groupBy, map } from "lodash/fp";
import type { KubeJsonApiData } from "../../../../../common/k8s-api/kube-json-api";
import type { GetResourceDetailsUrl } from "./get-resource-details-url.injectable";
import getResourceDetailsUrlInjectable from "./get-resource-details-url.injectable";
import type { CallForHelmReleaseUpdate } from "../../update-release/call-for-helm-release-update/call-for-helm-release-update.injectable";
import updateReleaseInjectable from "../../update-release/update-release.injectable";
import type { ShowCheckedErrorNotification } from "../../../notifications/show-checked-error.injectable";
import showCheckedErrorNotificationInjectable from "../../../notifications/show-checked-error.injectable";
import type { ShowNotification } from "../../../notifications";
import showSuccessNotificationInjectable from "../../../notifications/show-success-notification.injectable";
import React from "react";
import createUpgradeChartTabInjectable from "../../../dock/upgrade-chart/create-upgrade-chart-tab.injectable";
import type { HelmRelease } from "../../../../../common/k8s-api/endpoints/helm-releases.api";
import type { NavigateToHelmReleases } from "../../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import navigateToHelmReleasesInjectable from "../../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import assert from "assert";
import withOrphanPromiseInjectable from "../../../../../common/utils/with-orphan-promise/with-orphan-promise.injectable";
const releaseDetailsModelInjectable = getInjectable({
id: "release-details-model",
instantiate: (di, targetRelease: TargetHelmRelease) => {
const callForHelmRelease = di.inject(callForHelmReleaseInjectable);
const callForHelmReleaseConfiguration = di.inject(callForHelmReleaseConfigurationInjectable);
const themeStore = di.inject(themeStoreInjectable);
const getResourceDetailsUrl = di.inject(getResourceDetailsUrlInjectable);
const updateRelease = di.inject(updateReleaseInjectable);
const showCheckedErrorNotification = di.inject(showCheckedErrorNotificationInjectable);
const showSuccessNotification = di.inject(showSuccessNotificationInjectable);
const createUpgradeChartTab = di.inject(createUpgradeChartTabInjectable);
const navigateToHelmReleases = di.inject(navigateToHelmReleasesInjectable);
const withOrphanPromise = di.inject(withOrphanPromiseInjectable);
const model = new ReleaseDetailsModel({
callForHelmRelease,
targetRelease,
themeStore,
callForHelmReleaseConfiguration,
getResourceDetailsUrl,
updateRelease,
showCheckedErrorNotification,
showSuccessNotification,
createUpgradeChartTab,
navigateToHelmReleases,
});
const load = withOrphanPromise(model.load);
// TODO: Reorganize Drawer to allow setting of header-bar in children to make "getPlaceholder" from injectable usable.
load();
return model;
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, release: TargetHelmRelease) =>
`${release.namespace}/${release.name}`,
}),
});
export default releaseDetailsModelInjectable;
export interface OnlyUserSuppliedValuesAreShownToggle {
value: IObservableValue<boolean>;
toggle: () => Promise<void>;
}
export interface ConfigurationInput {
nonSavedValue: IObservableValue<string>;
isLoading: IObservableValue<boolean>;
isSaving: IObservableValue<boolean>;
onChange: (value: string) => void;
save: () => Promise<void>;
}
interface Dependencies {
callForHelmRelease: CallForHelmRelease;
targetRelease: TargetHelmRelease;
themeStore: ThemeStore;
callForHelmReleaseConfiguration: CallForHelmReleaseConfiguration;
getResourceDetailsUrl: GetResourceDetailsUrl;
updateRelease: CallForHelmReleaseUpdate;
showCheckedErrorNotification: ShowCheckedErrorNotification;
showSuccessNotification: ShowNotification;
createUpgradeChartTab: (release: HelmRelease) => string;
navigateToHelmReleases: NavigateToHelmReleases;
}
export class ReleaseDetailsModel {
id = `${this.dependencies.targetRelease.namespace}/${this.dependencies.targetRelease.name}`;
constructor(private dependencies: Dependencies) {}
private detailedRelease = observable.box<DetailedHelmRelease | undefined>();
readonly isLoading = observable.box(false);
readonly configuration: ConfigurationInput = {
nonSavedValue: observable.box(""),
isLoading: observable.box(false),
isSaving: observable.box(false),
onChange: action((value: string) => {
this.configuration.nonSavedValue.set(value);
}),
save: async () => {
runInAction(() => {
this.configuration.isSaving.set(true);
});
const name = this.release.getName();
const namespace = this.release.getNs();
const data = {
chart: this.release.getChart(),
repo: await this.release.getRepo(),
version: this.release.getVersion(),
values: this.configuration.nonSavedValue.get(),
};
const result = await this.dependencies.updateRelease(name, namespace, data);
runInAction(() => {
this.configuration.isSaving.set(false);
});
if (!result.updateWasSuccessful) {
this.dependencies.showCheckedErrorNotification(
result.error,
"Unknown error occured while updating release",
);
return;
}
this.dependencies.showSuccessNotification(
<p>
Release
{" "}
<b>{name}</b>
{" successfully updated!"}
</p>,
);
await this.loadConfiguration();
},
};
readonly onlyUserSuppliedValuesAreShown: OnlyUserSuppliedValuesAreShownToggle = {
value: observable.box(false),
toggle: action(async () => {
const value = this.onlyUserSuppliedValuesAreShown.value;
value.set(!value.get());
await this.loadConfiguration();
}),
};
load = async () => {
runInAction(() => {
this.isLoading.set(true);
});
const { name, namespace } = this.dependencies.targetRelease;
const detailedRelease = await this.dependencies.callForHelmRelease(
name,
namespace,
);
runInAction(() => {
this.detailedRelease.set(detailedRelease);
});
await this.loadConfiguration();
runInAction(() => {
this.isLoading.set(false);
});
};
private loadConfiguration = async () => {
runInAction(() => {
this.configuration.isLoading.set(true);
});
const { name, namespace } = this.release;
const configuration =
await this.dependencies.callForHelmReleaseConfiguration(
name,
namespace,
!this.onlyUserSuppliedValuesAreShown.value.get(),
);
runInAction(() => {
this.configuration.isLoading.set(false);
this.configuration.nonSavedValue.set(configuration);
});
};
@computed get release() {
const detailedRelease = this.detailedRelease.get();
assert(detailedRelease, "Tried to access release before load");
return toHelmRelease(detailedRelease.release);
}
@computed private get details() {
const detailedRelease = this.detailedRelease.get();
assert(detailedRelease, "Tried to access details before load");
return detailedRelease.details;
}
@computed get notes() {
return this.details.info.notes;
}
@computed get groupedResources(): MinimalResourceGroup[] {
return pipeline(
this.details.resources,
groupBy((resource) => resource.kind),
(grouped) => Object.entries(grouped),
map(([kind, resources]) => ({
kind,
resources: resources.map(
toMinimalResourceFor(this.dependencies.getResourceDetailsUrl, kind),
),
isNamespaced: resources.some(
(resource) => !!resource.metadata.namespace,
),
})),
);
}
@computed get activeTheme() {
return this.dependencies.themeStore.activeTheme.type;
}
close = () => {
this.dependencies.navigateToHelmReleases();
};
startUpgradeProcess = () => {
this.dependencies.createUpgradeChartTab(this.release);
this.close();
};
}
export interface MinimalResourceGroup {
kind: string;
isNamespaced: boolean;
resources: MinimalResource[];
}
export interface MinimalResource {
uid: string | undefined;
name: string;
namespace: string | undefined;
detailsUrl: string | undefined;
creationTimestamp: string | undefined;
}
const toMinimalResourceFor =
(getResourceDetailsUrl: GetResourceDetailsUrl, kind: string) =>
(resource: KubeJsonApiData): MinimalResource => {
const { creationTimestamp, name, namespace, uid } = resource.metadata;
return {
uid,
name,
namespace,
creationTimestamp,
detailsUrl: getResourceDetailsUrl(
kind,
resource.apiVersion,
namespace,
name,
),
};
};

View File

@ -1,24 +0,0 @@
/**
* 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 { getRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { asyncComputed } from "@ogre-tools/injectable-react";
import releaseInjectable from "./release.injectable";
import { waitUntilDefined } from "../../../utils";
const releaseDetailsInjectable = getInjectable({
id: "release-details",
instantiate: (di) => {
const releaseComputed = di.inject(releaseInjectable);
return asyncComputed(async () => {
const release = await waitUntilDefined(releaseComputed);
return getRelease(release.name, release.namespace);
});},
});
export default releaseDetailsInjectable;

View File

@ -5,296 +5,35 @@
import "./release-details.scss";
import React, { Component } from "react";
import groupBy from "lodash/groupBy";
import type { IComputedValue } from "mobx";
import { computed, makeObservable, observable } from "mobx";
import { Link } from "react-router-dom";
import kebabCase from "lodash/kebabCase";
import type { HelmRelease, HelmReleaseDetails, HelmReleaseUpdateDetails, HelmReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { HelmReleaseMenu } from "../release-menu";
import { Drawer, DrawerItem, DrawerTitle } from "../../drawer";
import { Badge } from "../../badge";
import { cssNames, stopPropagation } from "../../../utils";
import { Observer, observer } from "mobx-react";
import { Spinner } from "../../spinner";
import { Table, TableCell, TableHead, TableRow } from "../../table";
import { Button } from "../../button";
import { Notifications } from "../../notifications";
import type { ThemeStore } from "../../../themes/store";
import type { ApiManager } from "../../../../common/k8s-api/api-manager";
import { SubTitle } from "../../layout/sub-title";
import { Checkbox } from "../../checkbox";
import { MonacoEditor } from "../../monaco-editor";
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
import { withInjectables } from "@ogre-tools/injectable-react";
import createUpgradeChartTabInjectable from "../../dock/upgrade-chart/create-upgrade-chart-tab.injectable";
import updateReleaseInjectable from "../update-release/update-release.injectable";
import releaseInjectable from "./release.injectable";
import releaseDetailsInjectable from "./release-details.injectable";
import releaseValuesInjectable from "./release-values.injectable";
import userSuppliedValuesAreShownInjectable from "./user-supplied-values-are-shown.injectable";
import { KubeObjectAge } from "../../kube-object/age";
import type { KubeJsonApiData } from "../../../../common/k8s-api/kube-json-api";
import { entries } from "../../../../common/utils/objects";
import themeStoreInjectable from "../../../themes/store.injectable";
import type { GetDetailsUrl } from "../../kube-detail-params/get-details-url.injectable";
import apiManagerInjectable from "../../../../common/k8s-api/api-manager/manager.injectable";
import getDetailsUrlInjectable from "../../kube-detail-params/get-details-url.injectable";
import React from "react";
export interface ReleaseDetailsProps {
hideDetails(): void;
}
import { observer } from "mobx-react";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { IComputedValue } from "mobx";
import { ReleaseDetailsContent } from "./release-details-content";
import type { TargetHelmRelease } from "./target-helm-release.injectable";
import targetHelmReleaseInjectable from "./target-helm-release.injectable";
interface Dependencies {
release: IComputedValue<HelmRelease | null | undefined>;
releaseDetails: IAsyncComputed<HelmReleaseDetails>;
releaseValues: IAsyncComputed<string>;
updateRelease: (name: string, namespace: string, payload: HelmReleaseUpdatePayload) => Promise<HelmReleaseUpdateDetails>;
createUpgradeChartTab: (release: HelmRelease) => void;
userSuppliedValuesAreShown: { toggle: () => void; value: boolean };
themeStore: ThemeStore;
apiManager: ApiManager;
getDetailsUrl: GetDetailsUrl;
targetRelease: IComputedValue<
TargetHelmRelease | undefined
>;
}
@observer
class NonInjectedReleaseDetails extends Component<ReleaseDetailsProps & Dependencies> {
@observable saving = false;
const NonInjectedReleaseDetails = observer(
({ targetRelease }: Dependencies) => {
const release = targetRelease.get();
private nonSavedValues = "";
return release ? <ReleaseDetailsContent targetRelease={release} /> : null;
},
);
constructor(props: ReleaseDetailsProps & Dependencies) {
super(props);
makeObservable(this);
}
@computed get details() {
return this.props.releaseDetails.value.get();
}
updateValues = async (release: HelmRelease) => {
const name = release.getName();
const namespace = release.getNs();
const data = {
chart: release.getChart(),
repo: await release.getRepo(),
version: release.getVersion(),
values: this.nonSavedValues,
};
this.saving = true;
try {
await this.props.updateRelease(name, namespace, data);
Notifications.ok(
<p>
Release
<b>{name}</b>
{" successfully updated!"}
</p>,
);
this.props.releaseValues.invalidate();
} catch (err) {
Notifications.checkedError(err, "Unknown error occured while updating release");
}
this.saving = false;
};
upgradeVersion = (release: HelmRelease) => {
const { hideDetails, createUpgradeChartTab } = this.props;
createUpgradeChartTab(release);
hideDetails();
};
renderValues(release: HelmRelease) {
return (
<Observer>
{() => {
const { saving } = this;
const releaseValuesArePending =
this.props.releaseValues.pending.get();
this.nonSavedValues = this.props.releaseValues.value.get();
return (
<div className="values">
<DrawerTitle>Values</DrawerTitle>
<div className="flex column gaps">
<Checkbox
label="User-supplied values only"
value={this.props.userSuppliedValuesAreShown.value}
onChange={this.props.userSuppliedValuesAreShown.toggle}
disabled={releaseValuesArePending}
/>
<MonacoEditor
style={{ minHeight: 300 }}
value={this.nonSavedValues}
onChange={(text) => (this.nonSavedValues = text)}
/>
<Button
primary
label="Save"
waiting={saving}
disabled={releaseValuesArePending}
onClick={() => this.updateValues(release)}
/>
</div>
</div>
);
}}
</Observer>
);
}
renderNotes() {
if (!this.details.info?.notes) return null;
const { notes } = this.details.info;
return (
<div className="notes">
{notes}
</div>
);
}
renderResources(resources: KubeJsonApiData[]) {
const { apiManager, getDetailsUrl } = this.props;
return (
<div className="resources">
{
entries(groupBy(resources, item => item.kind))
.map(([kind, items]) => (
<React.Fragment key={kind}>
<SubTitle title={kind} />
<Table scrollable={false}>
<TableHead sticky={false}>
<TableCell className="name">Name</TableCell>
{items[0].metadata.namespace && <TableCell className="namespace">Namespace</TableCell>}
<TableCell className="age">Age</TableCell>
</TableHead>
{items.map(item => {
const { name, namespace, uid } = item.metadata;
const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == item.apiVersion);
const detailsUrl = api ? getDetailsUrl(api.getUrl({ name, namespace })) : "";
return (
<TableRow key={uid}>
<TableCell className="name">
{detailsUrl ? <Link to={detailsUrl}>{name}</Link> : name}
</TableCell>
{namespace && (
<TableCell className="namespace">
{namespace}
</TableCell>
)}
<TableCell className="age">
<KubeObjectAge key="age" object={item} />
</TableCell>
</TableRow>
);
})}
</Table>
</React.Fragment>
))
}
</div>
);
}
renderContent(release: HelmRelease) {
if (!this.details) {
return <Spinner center/>;
}
const { resources } = this.details;
return (
<div>
<DrawerItem name="Chart" className="chart">
<div className="flex gaps align-center">
<span>{release.getChart()}</span>
<Button
primary
label="Upgrade"
className="box right upgrade"
onClick={() => this.upgradeVersion(release)}
/>
</div>
</DrawerItem>
<DrawerItem name="Updated">
{release.getUpdated()}
{` ago (${release.updated})`}
</DrawerItem>
<DrawerItem name="Namespace">
{release.getNs()}
</DrawerItem>
<DrawerItem name="Version" onClick={stopPropagation}>
<div className="version flex gaps align-center">
<span>
{release.getVersion()}
</span>
</div>
</DrawerItem>
<DrawerItem
name="Status"
className="status"
labelsOnly
>
<Badge
label={release.getStatus()}
className={kebabCase(release.getStatus())}
/>
</DrawerItem>
{this.renderValues(release)}
<DrawerTitle>Notes</DrawerTitle>
{this.renderNotes()}
<DrawerTitle>Resources</DrawerTitle>
{resources && this.renderResources(resources)}
</div>
);
}
render() {
const { hideDetails, themeStore } = this.props;
const release = this.props.release.get();
return (
<Drawer
className={cssNames("ReleaseDetails", themeStore.activeTheme.type)}
usePortal={true}
open={Boolean(release)}
title={release ? `Release: ${release.getName()}` : ""}
onClose={hideDetails}
toolbar={release && (
<HelmReleaseMenu
release={release}
toolbar
hideDetails={hideDetails}
/>
)}
>
{release && this.renderContent(release)}
</Drawer>
);
}
}
export const ReleaseDetails = withInjectables<Dependencies, ReleaseDetailsProps>(NonInjectedReleaseDetails, {
getProps: (di, props) => ({
...props,
release: di.inject(releaseInjectable),
releaseDetails: di.inject(releaseDetailsInjectable),
releaseValues: di.inject(releaseValuesInjectable),
userSuppliedValuesAreShown: di.inject(userSuppliedValuesAreShownInjectable),
updateRelease: di.inject(updateReleaseInjectable),
createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable),
themeStore: di.inject(themeStoreInjectable),
apiManager: di.inject(apiManagerInjectable),
getDetailsUrl: di.inject(getDetailsUrlInjectable),
}),
});
export const ReleaseDetails = withInjectables<Dependencies>(
NonInjectedReleaseDetails,
{
getProps: (di) => ({
targetRelease: di.inject(targetHelmReleaseInjectable),
}),
},
);

View File

@ -1,36 +0,0 @@
/**
* 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 { getReleaseValues } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { asyncComputed } from "@ogre-tools/injectable-react";
import releaseInjectable from "./release.injectable";
import { Notifications } from "../../notifications";
import userSuppliedValuesAreShownInjectable from "./user-supplied-values-are-shown.injectable";
const releaseValuesInjectable = getInjectable({
id: "release-values",
instantiate: (di) =>
asyncComputed(async () => {
const release = di.inject(releaseInjectable).get();
// TODO: Figure out way to get rid of defensive code
if (!release) {
return "";
}
const userSuppliedValuesAreShown = di.inject(userSuppliedValuesAreShownInjectable).value;
try {
return await getReleaseValues(release.getName(), release.getNs(), !userSuppliedValuesAreShown) ?? "";
} catch (error) {
Notifications.error(`Failed to load values for ${release.getName()}: ${error}`);
return "";
}
}),
});
export default releaseValuesInjectable;

View File

@ -3,29 +3,24 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { matches } from "lodash/fp";
import releasesInjectable from "../releases.injectable";
import { computed } from "mobx";
import helmReleasesRouteParametersInjectable from "../helm-releases-route-parameters.injectable";
const releaseInjectable = getInjectable({
id: "release",
export interface TargetHelmRelease { name: string; namespace: string }
const targetHelmReleaseInjectable = getInjectable({
id: "target-helm-release",
instantiate: (di) => {
const releases = di.inject(releasesInjectable);
const routeParameters = di.inject(helmReleasesRouteParametersInjectable);
return computed(() => {
return computed((): TargetHelmRelease | undefined => {
const name = routeParameters.name.get();
const namespace = routeParameters.namespace.get();
if (!name || !namespace) {
return null;
}
return releases.value.get().find(matches({ name, namespace }));
return name && namespace ? { name, namespace } : undefined;
});
},
});
export default releaseInjectable;
export default targetHelmReleaseInjectable;

View File

@ -1,27 +0,0 @@
/**
* 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 { observable } from "mobx";
const userSuppliedValuesAreShownInjectable = getInjectable({
id: "user-supplied-values-are-shown",
instantiate: () => {
const state = observable.box(false);
return {
get value() {
return state.get();
},
toggle: () => {
state.set(!state.get());
},
};
},
});
export default userSuppliedValuesAreShownInjectable;

View File

@ -5,9 +5,13 @@
import { getInjectable } from "@ogre-tools/injectable";
import { asyncComputed } from "@ogre-tools/injectable-react";
import namespaceStoreInjectable from "../+namespaces/store.injectable";
import { listReleases } from "../../../common/k8s-api/endpoints/helm-releases.api";
import clusterFrameContextInjectable from "../../cluster-frame-context/cluster-frame-context.injectable";
import releaseSecretsInjectable from "./release-secrets.injectable";
import callForHelmReleasesInjectable from "./call-for-helm-releases/call-for-helm-releases.injectable";
import type { HelmRelease, HelmReleaseDto } from "../../../common/k8s-api/endpoints/helm-releases.api";
import { formatDuration } from "../../../common/utils";
import { helmChartStore } from "../+helm-charts/helm-chart.store";
import { capitalize } from "lodash/fp";
const releasesInjectable = getInjectable({
id: "releases",
@ -16,6 +20,7 @@ const releasesInjectable = getInjectable({
const clusterContext = di.inject(clusterFrameContextInjectable);
const namespaceStore = di.inject(namespaceStoreInjectable);
const releaseSecrets = di.inject(releaseSecretsInjectable);
const callForHelmReleases = di.inject(callForHelmReleasesInjectable);
return asyncComputed(async () => {
const contextNamespaces = namespaceStore.contextNamespaces || [];
@ -29,15 +34,83 @@ const releasesInjectable = getInjectable({
contextNamespaces.includes(namespace),
);
const releaseArrays = await (isLoadingAll ? listReleases() : Promise.all(
const releaseArrays = await (isLoadingAll ? callForHelmReleases() : Promise.all(
contextNamespaces.map((namespace) =>
listReleases(namespace),
callForHelmReleases(namespace),
),
));
return releaseArrays.flat();
return releaseArrays.flat().map(toHelmRelease);
}, []);
},
});
export const toHelmRelease = (release: HelmReleaseDto) : HelmRelease => ({
...release,
getId() {
return `${this.namespace}/${this.name}`;
},
getName() {
return this.name;
},
getNs() {
return this.namespace;
},
getChart(withVersion = false) {
let chart = this.chart;
if (!withVersion && this.getVersion() != "") {
const search = new RegExp(`-${this.getVersion()}`);
chart = chart.replace(search, "");
}
return chart;
},
getRevision() {
return parseInt(this.revision, 10);
},
getStatus() {
return capitalize(this.status);
},
getVersion() {
const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/);
return versions?.[0] ?? "";
},
getUpdated(humanize = true, compact = true) {
const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date()
const updatedDate = new Date(updated).getTime();
const diff = Date.now() - updatedDate;
if (humanize) {
return formatDuration(diff, compact);
}
return diff;
},
// Helm does not store from what repository the release is installed,
// so we have to try to guess it by searching charts
async getRepo() {
const chartName = this.getChart();
const version = this.getVersion();
const versions = await helmChartStore.getVersions(chartName);
const chartVersion = versions.find(
(chartVersion) => chartVersion.version === version,
);
return chartVersion ? chartVersion.repo : "";
},
});
export default releasesInjectable;

View File

@ -16,7 +16,6 @@ import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import { kebabCase } from "lodash/fp";
import { HelmReleaseMenu } from "./release-menu";
import { ReleaseRollbackDialog } from "./dialog/dialog";
import { ReleaseDetails } from "./release-details/release-details";
import removableReleasesInjectable from "./removable-releases.injectable";
import type { RemovableHelmRelease } from "./removable-releases";
import type { IComputedValue } from "mobx";
@ -145,6 +144,7 @@ class NonInjectedHelmReleases extends Component<Dependencies> {
isConfigurable
tableId="helm_releases"
className="HelmReleases"
customizeTableRowProps={(item) => ({ testId: `helm-release-row-for-${item.getId()}` })}
sortingCallbacks={{
[columnId.name]: release => release.getName(),
[columnId.namespace]: release => release.getNs(),
@ -204,10 +204,7 @@ class NonInjectedHelmReleases extends Component<Dependencies> {
message: this.renderRemoveDialogMessage(selectedItems),
})}
onDetails={this.onDetails}
/>
<ReleaseDetails
hideDetails={this.hideDetails}
spinnerTestId="helm-releases-spinner"
/>
<ReleaseRollbackDialog/>

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../../../common/test-utils/get-global-override";
import callForHelmReleaseUpdateInjectable from "./call-for-helm-release-update.injectable";
export default getGlobalOverride(
callForHelmReleaseUpdateInjectable,
() => () => {
throw new Error(
"Tried to call for helm release update without explicit override.",
);
},
);

View File

@ -0,0 +1,50 @@
/**
* 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 { apiBase } from "../../../../../common/k8s-api";
import { endpoint } from "../../../../../common/k8s-api/endpoints/helm-releases.api";
import yaml from "js-yaml";
interface HelmReleaseUpdatePayload {
repo: string;
chart: string;
version: string;
values: string;
}
export type CallForHelmReleaseUpdate = (
name: string,
namespace: string,
payload: HelmReleaseUpdatePayload
) => Promise<{ updateWasSuccessful: true } | { updateWasSuccessful: false; error: unknown }>;
const callForHelmReleaseUpdateInjectable = getInjectable({
id: "call-for-helm-release-update",
instantiate:
(): CallForHelmReleaseUpdate => async (name, namespace, payload) => {
const { repo, chart: rawChart, values: rawValues, ...data } = payload;
const chart = `${repo}/${rawChart}`;
const values = yaml.load(rawValues);
try {
await apiBase.put(endpoint({ name, namespace }), {
data: {
chart,
values,
...data,
},
});
} catch (e) {
return { updateWasSuccessful: false, error: e };
}
return { updateWasSuccessful: true };
},
causesSideEffects: true,
});
export default callForHelmReleaseUpdateInjectable;

View File

@ -3,26 +3,23 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type {
HelmReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import {
updateRelease,
} from "../../../../common/k8s-api/endpoints/helm-releases.api";
import releasesInjectable from "../releases.injectable";
import type { CallForHelmReleaseUpdate } from "./call-for-helm-release-update/call-for-helm-release-update.injectable";
import callForHelmReleaseUpdateInjectable from "./call-for-helm-release-update/call-for-helm-release-update.injectable";
const updateReleaseInjectable = getInjectable({
id: "update-release",
instantiate: (di) => {
instantiate: (di): CallForHelmReleaseUpdate => {
const releases = di.inject(releasesInjectable);
const callForHelmReleaseUpdate = di.inject(callForHelmReleaseUpdateInjectable);
return async (
name: string,
namespace: string,
payload: HelmReleaseUpdatePayload,
name,
namespace,
payload,
) => {
const result = await updateRelease(name, namespace, payload);
const result = await callForHelmReleaseUpdate(name, namespace, payload);
releases.invalidate();

View File

@ -10,13 +10,15 @@ import type { DockStore, DockTabCreateSpecific, TabId } from "../dock/store";
import { TabKind } from "../dock/store";
import type { UpgradeChartTabStore } from "./store";
import { runInAction } from "mobx";
import getRandomUpgradeChartTabIdInjectable from "./get-random-upgrade-chart-tab-id.injectable";
interface Dependencies {
upgradeChartStore: UpgradeChartTabStore;
dockStore: DockStore;
getRandomId: () => string;
}
const createUpgradeChartTab = ({ upgradeChartStore, dockStore }: Dependencies) => (release: HelmRelease, tabParams: DockTabCreateSpecific = {}): TabId => {
const createUpgradeChartTab = ({ upgradeChartStore, dockStore, getRandomId }: Dependencies) => (release: HelmRelease, tabParams: DockTabCreateSpecific = {}): TabId => {
const tabId = upgradeChartStore.getTabIdByRelease(release.getName());
if (tabId) {
@ -29,6 +31,7 @@ const createUpgradeChartTab = ({ upgradeChartStore, dockStore }: Dependencies) =
return runInAction(() => {
const tab = dockStore.createTab(
{
id: getRandomId(),
title: `Helm Upgrade: ${release.getName()}`,
...tabParams,
kind: TabKind.UPGRADE_CHART,
@ -51,6 +54,7 @@ const createUpgradeChartTabInjectable = getInjectable({
instantiate: (di) => createUpgradeChartTab({
upgradeChartStore: di.inject(upgradeChartTabStoreInjectable),
dockStore: di.inject(dockStoreInjectable),
getRandomId: di.inject(getRandomUpgradeChartTabIdInjectable),
}),
});

View File

@ -0,0 +1,13 @@
/**
* 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 getRandomIdInjectable from "../../../../common/utils/get-random-id.injectable";
const getRandomUpgradeChartTabIdInjectable = getInjectable({
id: "get-random-upgrade-chart-tab-id",
instantiate: (di) => di.inject(getRandomIdInjectable),
});
export default getRandomUpgradeChartTabIdInjectable;

View File

@ -16,7 +16,7 @@ import { Spinner } from "../../spinner";
import { Badge } from "../../badge";
import { EditorPanel } from "../editor-panel";
import { helmChartStore, type ChartVersion } from "../../+helm-charts/helm-chart.store";
import type { HelmRelease, HelmReleaseUpdateDetails, HelmReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import type { HelmRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import type { SelectOption } from "../../select";
import { Select } from "../../select";
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
@ -24,6 +24,8 @@ import { withInjectables } from "@ogre-tools/injectable-react";
import upgradeChartTabStoreInjectable from "./store.injectable";
import updateReleaseInjectable from "../../+helm-releases/update-release/update-release.injectable";
import releasesInjectable from "../../+helm-releases/releases.injectable";
import type { CallForHelmReleaseUpdate } from "../../+helm-releases/update-release/call-for-helm-release-update/call-for-helm-release-update.injectable";
import { first } from "lodash/fp";
export interface UpgradeChartProps {
className?: string;
@ -33,7 +35,7 @@ export interface UpgradeChartProps {
interface Dependencies {
releases: IAsyncComputed<HelmRelease[]>;
upgradeChartTabStore: UpgradeChartTabStore;
updateRelease: (name: string, namespace: string, payload: HelmReleaseUpdatePayload) => Promise<HelmReleaseUpdateDetails>;
updateRelease: CallForHelmReleaseUpdate;
}
@observer
@ -96,7 +98,7 @@ export class NonInjectedUpgradeChart extends React.Component<UpgradeChartProps &
const versions = await helmChartStore.getVersions(release.getChart());
this.versions.replace(versions);
this.version = this.versions[0];
this.version = first(this.versions);
}
onChange = action((value: string) => {

View File

@ -39,6 +39,8 @@ export interface DrawerProps {
onClose?: () => void;
toolbar?: React.ReactNode;
children?: SingleOrMany<React.ReactNode>;
"data-testid"?: string;
testIdForClose?: string;
}
const defaultProps = {
@ -179,7 +181,7 @@ class NonInjectedDrawer extends React.Component<DrawerProps & Dependencies & typ
};
render() {
const { className, contentClass, animation, open, position, title, children, toolbar, size, usePortal } = this.props;
const { className, contentClass, animation, open, position, title, children, toolbar, size, usePortal, "data-testid": testId, testIdForClose } = this.props;
const { isCopied, width } = this.state;
const copyTooltip = isCopied ? "Copied!" : "Copy";
const copyIcon = isCopied ? "done" : "content_copy";
@ -193,6 +195,7 @@ class NonInjectedDrawer extends React.Component<DrawerProps & Dependencies & typ
className={cssNames("Drawer", className, position)}
style={{ "--size": drawerSize } as React.CSSProperties}
ref={e => this.contentElem = e}
data-testid={testId}
>
<div className="drawer-wrapper flex column">
<div className="drawer-title flex align-center">
@ -211,6 +214,7 @@ class NonInjectedDrawer extends React.Component<DrawerProps & Dependencies & typ
material="close"
tooltip="Close"
onClick={this.close}
data-testid={testIdForClose}
/>
</div>
<div

View File

@ -60,6 +60,8 @@ export interface ItemListLayoutContentProps<Item extends ItemObject, PreLoadStor
// other
customizeRemoveDialog?: (selectedItems: Item[]) => Partial<ConfirmDialogParams>;
spinnerTestId?: string;
/**
* Message to display when a store failed to load
*
@ -221,7 +223,7 @@ class NonInjectedItemListLayoutContent<
}
if (!this.props.getIsReady()) {
return <Spinner center />;
return <Spinner center data-testid={this.props.spinnerTestId} />;
}
if (this.props.getFilters().length > 0) {

View File

@ -121,6 +121,8 @@ export type ItemListLayoutProps<Item extends ItemObject, PreLoadStores extends b
customizeRemoveDialog?: (selectedItems: Item[]) => Partial<ConfirmDialogParams>;
renderFooter?: (parent: NonInjectedItemListLayout<Item, PreLoadStores>) => React.ReactNode;
spinnerTestId?: string;
/**
* Message to display when a store failed to load
*
@ -321,6 +323,7 @@ class NonInjectedItemListLayout<I extends ItemObject, PreLoadStores extends bool
onDetails={this.props.onDetails}
customizeRemoveDialog={this.props.customizeRemoveDialog}
failedToLoadMessage={this.props.failedToLoadMessage}
spinnerTestId={this.props.spinnerTestId}
/>
{this.props.renderFooter?.(this)}

View File

@ -96,6 +96,16 @@ export const getDiForUnitTesting = (opts: { doGeneralOverrides?: boolean } = {})
di.preventSideEffects();
if (doGeneralOverrides) {
const globalOverrideFilePaths = getGlobalOverridePaths();
const globalOverrides = globalOverrideFilePaths.map(
(filePath) => require(filePath).default,
);
globalOverrides.forEach(globalOverride => {
di.override(globalOverride.injectable, globalOverride.overridingInstantiate);
});
di.override(getRandomIdInjectable, () => () => "some-irrelevant-random-id");
di.override(platformInjectable, () => "darwin");
di.override(startTopbarStateSyncInjectable, () => ({
@ -228,6 +238,14 @@ const getInjectableFilePaths = memoize(() => [
...glob.sync("../extensions/**/*.injectable.{ts,tsx}", { cwd: __dirname }),
]);
const getGlobalOverridePaths = memoize(() =>
glob.sync(
"../{common,extensions,renderer}/**/*.global-override-for-injectable.{ts,tsx}",
{ cwd: __dirname },
),
);
const overrideFunctionalInjectables = (di: DiContainer, injectables: Injectable<any, any, any>[]) => {
injectables.forEach(injectable => {
di.override(injectable, () => () => {