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

Fix infinite render loop in release details (#4710)

* Fix infinite render loop in release details by replacing stateful, UI-triggered releaseStore with reactive async computed

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

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

* Update injectable

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

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

* Remove unnecessary return

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

* Remove empty lines

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

* Allow injection of history

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

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

* Make data required for opening of release details a dependency to make sure it's present

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

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

* Remove dead code

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

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

* Make arriving release values not re-render whole details

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen 2022-01-21 16:29:10 +02:00 committed by GitHub
parent b7d29f8c49
commit 4f5a2988cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 836 additions and 600 deletions

View File

@ -195,8 +195,8 @@
"@hapi/call": "^8.0.1", "@hapi/call": "^8.0.1",
"@hapi/subtext": "^7.0.3", "@hapi/subtext": "^7.0.3",
"@kubernetes/client-node": "^0.16.1", "@kubernetes/client-node": "^0.16.1",
"@ogre-tools/injectable": "3.1.1", "@ogre-tools/injectable": "3.2.0",
"@ogre-tools/injectable-react": "3.1.1", "@ogre-tools/injectable-react": "3.2.0",
"@sentry/electron": "^2.5.4", "@sentry/electron": "^2.5.4",
"@sentry/integrations": "^6.15.0", "@sentry/integrations": "^6.15.0",
"@types/circular-dependency-plugin": "5.0.4", "@types/circular-dependency-plugin": "5.0.4",

View File

@ -4,7 +4,7 @@
*/ */
import yaml from "js-yaml"; import yaml from "js-yaml";
import { autoBind, formatDuration } from "../../utils"; import { formatDuration } from "../../utils";
import capitalize from "lodash/capitalize"; import capitalize from "lodash/capitalize";
import { apiBase } from "../index"; import { apiBase } from "../index";
import { helmChartStore } from "../../../renderer/components/+apps-helm-charts/helm-chart.store"; import { helmChartStore } from "../../../renderer/components/+apps-helm-charts/helm-chart.store";
@ -80,9 +80,9 @@ interface EndpointQuery {
const endpoint = buildURLPositional<EndpointParams, EndpointQuery>("/v2/releases/:namespace?/:name?/:route?"); const endpoint = buildURLPositional<EndpointParams, EndpointQuery>("/v2/releases/:namespace?/:name?/:route?");
export async function listReleases(namespace?: string): Promise<HelmRelease[]> { export async function listReleases(namespace?: string): Promise<HelmRelease[]> {
const releases = await apiBase.get<HelmRelease[]>(endpoint({ namespace })); const releases = await apiBase.get<HelmReleaseDto[]>(endpoint({ namespace }));
return releases.map(HelmRelease.create); return releases.map(toHelmRelease);
} }
export async function getRelease(name: string, namespace: string): Promise<IReleaseDetails> { export async function getRelease(name: string, namespace: string): Promise<IReleaseDetails> {
@ -152,7 +152,7 @@ export async function rollbackRelease(name: string, namespace: string, revision:
return apiBase.put(path, { data }); return apiBase.put(path, { data });
} }
export interface HelmRelease { interface HelmReleaseDto {
appVersion: string; appVersion: string;
name: string; name: string;
namespace: string; namespace: string;
@ -162,27 +162,30 @@ export interface HelmRelease {
revision: string; revision: string;
} }
export class HelmRelease implements ItemObject { export interface HelmRelease extends HelmReleaseDto, ItemObject {
constructor(data: any) { getNs: () => string
Object.assign(this, data); getChart: (withVersion?: boolean) => string
autoBind(this); getRevision: () => number
} getStatus: () => string
getVersion: () => string
getUpdated: (humanize?: boolean, compact?: boolean) => string | number
getRepo: () => Promise<string>
}
static create(data: any) { const toHelmRelease = (release: HelmReleaseDto) : HelmRelease => ({
return new HelmRelease(data); ...release,
}
getId() { getId() {
return this.namespace + this.name; return this.namespace + this.name;
} },
getName() { getName() {
return this.name; return this.name;
} },
getNs() { getNs() {
return this.namespace; return this.namespace;
} },
getChart(withVersion = false) { getChart(withVersion = false) {
let chart = this.chart; let chart = this.chart;
@ -194,24 +197,24 @@ export class HelmRelease implements ItemObject {
} }
return chart; return chart;
} },
getRevision() { getRevision() {
return parseInt(this.revision, 10); return parseInt(this.revision, 10);
} },
getStatus() { getStatus() {
return capitalize(this.status); return capitalize(this.status);
} },
getVersion() { getVersion() {
const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/); const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/);
return versions?.[0] ?? ""; return versions?.[0] ?? "";
} },
getUpdated(humanize = true, compact = true) { 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 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 updatedDate = new Date(updated).getTime();
const diff = Date.now() - updatedDate; const diff = Date.now() - updatedDate;
@ -220,7 +223,7 @@ export class HelmRelease implements ItemObject {
} }
return diff; return diff;
} },
// Helm does not store from what repository the release is installed, // Helm does not store from what repository the release is installed,
// so we have to try to guess it by searching charts // so we have to try to guess it by searching charts
@ -228,8 +231,10 @@ export class HelmRelease implements ItemObject {
const chartName = this.getChart(); const chartName = this.getChart();
const version = this.getVersion(); const version = this.getVersion();
const versions = await helmChartStore.getVersions(chartName); const versions = await helmChartStore.getVersions(chartName);
const chartVersion = versions.find(chartVersion => chartVersion.version === version); const chartVersion = versions.find(
(chartVersion) => chartVersion.version === version,
);
return chartVersion ? chartVersion.repo : ""; return chartVersion ? chartVersion.repo : "";
} },
} });

View File

@ -0,0 +1,28 @@
/**
* 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 {
createRelease,
IReleaseCreatePayload,
} from "../../../../common/k8s-api/endpoints/helm-releases.api";
import releasesInjectable from "../releases.injectable";
const createReleaseInjectable = getInjectable({
instantiate: (di) => {
const releases = di.inject(releasesInjectable);
return async (payload: IReleaseCreatePayload) => {
const release = await createRelease(payload);
releases.invalidate();
return release;
};
},
lifecycle: lifecycleEnum.singleton,
});
export default createReleaseInjectable;

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, lifecycleEnum } from "@ogre-tools/injectable";
import {
deleteRelease,
HelmRelease,
} from "../../../../common/k8s-api/endpoints/helm-releases.api";
import releasesInjectable from "../releases.injectable";
const deleteReleaseInjectable = getInjectable({
instantiate: (di) => {
const releases = di.inject(releasesInjectable);
return async (release: HelmRelease) => {
await deleteRelease(release.getName(), release.getNs());
releases.invalidate();
};
},
lifecycle: lifecycleEnum.singleton,
});
export default deleteReleaseInjectable;

View File

@ -1,321 +0,0 @@
/**
* 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, { Component } from "react";
import groupBy from "lodash/groupBy";
import isEqual from "lodash/isEqual";
import { makeObservable, observable, reaction } from "mobx";
import { Link } from "react-router-dom";
import kebabCase from "lodash/kebabCase";
import { getRelease, getReleaseValues, HelmRelease, IReleaseDetails } 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 { disposeOnUnmount, observer } from "mobx-react";
import { Spinner } from "../spinner";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { Button } from "../button";
import type { ReleaseStore } from "./release.store";
import { Notifications } from "../notifications";
import { ThemeStore } from "../../theme.store";
import { apiManager } from "../../../common/k8s-api/api-manager";
import { SubTitle } from "../layout/sub-title";
import { secretsStore } from "../+config-secrets/secrets.store";
import { Secret } from "../../../common/k8s-api/endpoints";
import { getDetailsUrl } from "../kube-detail-params";
import { Checkbox } from "../checkbox";
import { MonacoEditor } from "../monaco-editor";
import { withInjectables } from "@ogre-tools/injectable-react";
import releaseStoreInjectable from "./release-store.injectable";
import createUpgradeChartTabInjectable
from "../dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable";
interface Props {
release: HelmRelease;
hideDetails(): void;
}
interface Dependencies {
releaseStore: ReleaseStore
createUpgradeChartTab: (release: HelmRelease) => void
}
@observer
class NonInjectedReleaseDetails extends Component<Props & Dependencies> {
@observable details: IReleaseDetails | null = null;
@observable values = "";
@observable valuesLoading = false;
@observable showOnlyUserSuppliedValues = true;
@observable saving = false;
@observable releaseSecret: Secret;
@observable error?: string = undefined;
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.release, release => {
if (!release) return;
this.loadDetails();
this.loadValues();
this.releaseSecret = null;
}),
reaction(() => secretsStore.getItems(), () => {
if (!this.props.release) return;
const { getReleaseSecret } = this.props.releaseStore;
const { release } = this.props;
const secret = getReleaseSecret(release);
if (this.releaseSecret) {
if (isEqual(this.releaseSecret.getLabels(), secret.getLabels())) return;
this.loadDetails();
}
this.releaseSecret = secret;
}),
reaction(() => this.showOnlyUserSuppliedValues, () => {
this.loadValues();
}),
]);
}
constructor(props: Props & Dependencies) {
super(props);
makeObservable(this);
}
async loadDetails() {
const { release } = this.props;
try {
this.details = null;
this.details = await getRelease(release.getName(), release.getNs());
} catch (error) {
this.error = `Failed to get release details: ${error}`;
}
}
async loadValues() {
const { release } = this.props;
try {
this.valuesLoading = true;
this.values = (await getReleaseValues(release.getName(), release.getNs(), !this.showOnlyUserSuppliedValues)) ?? "";
} catch (error) {
Notifications.error(`Failed to load values for ${release.getName()}: ${error}`);
this.values = "";
} finally {
this.valuesLoading = false;
}
}
updateValues = async () => {
const { release } = this.props;
const name = release.getName();
const namespace = release.getNs();
const data = {
chart: release.getChart(),
repo: await release.getRepo(),
version: release.getVersion(),
values: this.values,
};
this.saving = true;
try {
await this.props.releaseStore.update(name, namespace, data);
Notifications.ok(
<p>Release <b>{name}</b> successfully updated!</p>,
);
} catch (err) {
Notifications.error(err);
}
this.saving = false;
};
upgradeVersion = () => {
const { release, hideDetails } = this.props;
this.props.createUpgradeChartTab(release);
hideDetails();
};
renderValues() {
const { values, valuesLoading, saving } = this;
return (
<div className="values">
<DrawerTitle title="Values"/>
<div className="flex column gaps">
<Checkbox
label="User-supplied values only"
value={this.showOnlyUserSuppliedValues}
onChange={value => this.showOnlyUserSuppliedValues = value}
disabled={valuesLoading}
/>
<MonacoEditor
readOnly={valuesLoading}
className={cssNames({ loading: valuesLoading })}
style={{ minHeight: 300 }}
value={values}
onChange={text => this.values = text}
>
{valuesLoading && <Spinner center/>}
</MonacoEditor>
<Button
primary
label="Save"
waiting={saving}
disabled={valuesLoading}
onClick={this.updateValues}
/>
</div>
</div>
);
}
renderNotes() {
if (!this.details.info?.notes) return null;
const { notes } = this.details.info;
return (
<div className="notes">
{notes}
</div>
);
}
renderResources() {
const { resources } = this.details;
if (!resources) return null;
const groups = groupBy(resources, item => item.kind);
const tables = Object.entries(groups).map(([kind, items]) => {
return (
<React.Fragment key={kind}>
<SubTitle title={kind}/>
<Table scrollable={false}>
<TableHead sticky={false}>
<TableCell className="name">Name</TableCell>
{items[0].getNs() && <TableCell className="namespace">Namespace</TableCell>}
<TableCell className="age">Age</TableCell>
</TableHead>
{items.map(item => {
const name = item.getName();
const namespace = item.getNs();
const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == item.apiVersion);
const detailsUrl = api ? getDetailsUrl(api.getUrl({ name, namespace })) : "";
return (
<TableRow key={item.getId()}>
<TableCell className="name">
{detailsUrl ? <Link to={detailsUrl}>{name}</Link> : name}
</TableCell>
{namespace && <TableCell className="namespace">{namespace}</TableCell>}
<TableCell className="age">{item.getAge()}</TableCell>
</TableRow>
);
})}
</Table>
</React.Fragment>
);
});
return (
<div className="resources">
{tables}
</div>
);
}
renderContent() {
const { release } = this.props;
if (!release) return null;
if (this.error) {
return (
<div className="loading-error">
{this.error}
</div>
);
}
if (!this.details) {
return <Spinner center/>;
}
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}
/>
</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()}
<DrawerTitle title="Notes"/>
{this.renderNotes()}
<DrawerTitle title="Resources"/>
{this.renderResources()}
</div>
);
}
render() {
const { release, hideDetails } = this.props;
const title = release ? `Release: ${release.getName()}` : "";
const toolbar = <HelmReleaseMenu release={release} toolbar hideDetails={hideDetails}/>;
return (
<Drawer
className={cssNames("ReleaseDetails", ThemeStore.getInstance().activeTheme.type)}
usePortal={true}
open={!!release}
title={title}
onClose={hideDetails}
toolbar={toolbar}
>
{this.renderContent()}
</Drawer>
);
}
}
export const ReleaseDetails = withInjectables<Dependencies, Props>(
NonInjectedReleaseDetails,
{
getProps: (di, props) => ({
releaseStore: di.inject(releaseStoreInjectable),
createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable),
...props,
}),
},
);

View File

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

View File

@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
@import "release.mixins"; @import "../release.mixins";
.ReleaseDetails { .ReleaseDetails {
.DrawerItem { .DrawerItem {

View File

@ -0,0 +1,280 @@
/**
* 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, { Component } from "react";
import groupBy from "lodash/groupBy";
import { computed, makeObservable, observable } from "mobx";
import { Link } from "react-router-dom";
import kebabCase from "lodash/kebabCase";
import type { HelmRelease, IReleaseDetails, IReleaseUpdateDetails, IReleaseUpdatePayload } 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 { ThemeStore } from "../../../theme.store";
import { apiManager } from "../../../../common/k8s-api/api-manager";
import { SubTitle } from "../../layout/sub-title";
import { getDetailsUrl } from "../../kube-detail-params";
import { Checkbox } from "../../checkbox";
import { MonacoEditor } from "../../monaco-editor";
import { IAsyncComputed, withInjectables } from "@ogre-tools/injectable-react";
import createUpgradeChartTabInjectable from "../../dock/create-upgrade-chart-tab/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";
interface Props {
hideDetails(): void;
}
interface Dependencies {
release: IAsyncComputed<HelmRelease>
releaseDetails: IAsyncComputed<IReleaseDetails>
releaseValues: IAsyncComputed<string>
updateRelease: (name: string, namespace: string, payload: IReleaseUpdatePayload) => Promise<IReleaseUpdateDetails>
createUpgradeChartTab: (release: HelmRelease) => void
userSuppliedValuesAreShown: { toggle: () => void, value: boolean }
}
@observer
class NonInjectedReleaseDetails extends Component<Props & Dependencies> {
@observable saving = false;
private nonSavedValues: string;
constructor(props: Props & Dependencies) {
super(props);
makeObservable(this);
}
@computed get release() {
return this.props.release.value.get();
}
@computed get details() {
return this.props.releaseDetails.value.get();
}
updateValues = async () => {
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.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.error(err);
}
this.saving = false;
};
upgradeVersion = () => {
const { hideDetails } = this.props;
this.props.createUpgradeChartTab(this.release);
hideDetails();
};
renderValues() {
return (
<Observer>
{() => {
const { saving } = this;
const releaseValuesArePending =
this.props.releaseValues.pending.get();
this.nonSavedValues = this.props.releaseValues.value.get();
return (
<div className="values">
<DrawerTitle title="Values" />
<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}
/>
</div>
</div>
);
}}
</Observer>
);
}
renderNotes() {
if (!this.details.info?.notes) return null;
const { notes } = this.details.info;
return (
<div className="notes">
{notes}
</div>
);
}
renderResources() {
const { resources } = this.details;
if (!resources) return null;
const groups = groupBy(resources, item => item.kind);
const tables = Object.entries(groups).map(([kind, items]) => {
return (
<React.Fragment key={kind}>
<SubTitle title={kind}/>
<Table scrollable={false}>
<TableHead sticky={false}>
<TableCell className="name">Name</TableCell>
{items[0].getNs() && <TableCell className="namespace">Namespace</TableCell>}
<TableCell className="age">Age</TableCell>
</TableHead>
{items.map(item => {
const name = item.getName();
const namespace = item.getNs();
const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == item.apiVersion);
const detailsUrl = api ? getDetailsUrl(api.getUrl({ name, namespace })) : "";
return (
<TableRow key={item.getId()}>
<TableCell className="name">
{detailsUrl ? <Link to={detailsUrl}>{name}</Link> : name}
</TableCell>
{namespace && <TableCell className="namespace">{namespace}</TableCell>}
<TableCell className="age">{item.getAge()}</TableCell>
</TableRow>
);
})}
</Table>
</React.Fragment>
);
});
return (
<div className="resources">
{tables}
</div>
);
}
renderContent() {
if (!this.release) return null;
if (!this.details) {
return <Spinner center/>;
}
return (
<div>
<DrawerItem name="Chart" className="chart">
<div className="flex gaps align-center">
<span>{this.release.getChart()}</span>
<Button
primary
label="Upgrade"
className="box right upgrade"
onClick={this.upgradeVersion}
/>
</div>
</DrawerItem>
<DrawerItem name="Updated">
{this.release.getUpdated()} ago ({this.release.updated})
</DrawerItem>
<DrawerItem name="Namespace">
{this.release.getNs()}
</DrawerItem>
<DrawerItem name="Version" onClick={stopPropagation}>
<div className="version flex gaps align-center">
<span>
{this.release.getVersion()}
</span>
</div>
</DrawerItem>
<DrawerItem name="Status" className="status" labelsOnly>
<Badge
label={this.release.getStatus()}
className={kebabCase(this.release.getStatus())}
/>
</DrawerItem>
{this.renderValues()}
<DrawerTitle title="Notes"/>
{this.renderNotes()}
<DrawerTitle title="Resources"/>
{this.renderResources()}
</div>
);
}
render() {
const { hideDetails } = this.props;
const title = this.release ? `Release: ${this.release.getName()}` : "";
const toolbar = <HelmReleaseMenu release={this.release} toolbar hideDetails={hideDetails}/>;
return (
<Drawer
className={cssNames("ReleaseDetails", ThemeStore.getInstance().activeTheme.type)}
usePortal={true}
open={!!this.release}
title={title}
onClose={hideDetails}
toolbar={toolbar}
>
{this.renderContent()}
</Drawer>
);
}
}
export const ReleaseDetails = withInjectables<Dependencies, Props>(
NonInjectedReleaseDetails,
{
getProps: (di, 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),
...props,
}),
},
);

View File

@ -0,0 +1,31 @@
/**
* 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 { computed } from "mobx";
import { matchPath } from "react-router";
import observableHistoryInjectable from "../../../navigation/observable-history.injectable";
import { releaseRoute, ReleaseRouteParams } from "../../../../common/routes";
const releaseRouteParametersInjectable = getInjectable({
instantiate: (di) => {
const observableHistory = di.inject(observableHistoryInjectable);
return computed(() => {
const releasePathParameters = matchPath<ReleaseRouteParams>(observableHistory.location.pathname, {
path: releaseRoute.path,
});
if (!releasePathParameters) {
return {};
}
return releasePathParameters.params;
});
},
lifecycle: lifecycleEnum.singleton,
});
export default releaseRouteParametersInjectable;

View File

@ -0,0 +1,30 @@
/**
* 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 { 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({
instantiate: (di) =>
asyncComputed(async () => {
const release = di.inject(releaseInjectable).value.get();
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 "";
}
}),
lifecycle: lifecycleEnum.singleton,
});
export default releaseValuesInjectable;

View File

@ -0,0 +1,30 @@
/**
* 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 { matches } from "lodash/fp";
import releasesInjectable from "../releases.injectable";
import releaseRouteParametersInjectable from "./release-route-parameters.injectable";
import { asyncComputed } from "@ogre-tools/injectable-react";
const releaseInjectable = getInjectable({
instantiate: (di) => {
const releases = di.inject(releasesInjectable);
const releaseRouteParameters = di.inject(releaseRouteParametersInjectable);
return asyncComputed(async () => {
const { name, namespace } = releaseRouteParameters.get();
if (!name || !namespace) {
return null;
}
return releases.value.get().find(matches({ name, namespace }));
});
},
lifecycle: lifecycleEnum.singleton,
});
export default releaseInjectable;

View File

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

View File

@ -6,16 +6,13 @@
import React from "react"; import React from "react";
import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import type { ReleaseStore } from "./release.store";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import releaseStoreInjectable from "./release-store.injectable"; import createUpgradeChartTabInjectable from "../dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable";
import createUpgradeChartTabInjectable import releaseRollbackDialogModelInjectable from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable";
from "../dock/create-upgrade-chart-tab/create-upgrade-chart-tab.injectable"; import deleteReleaseInjectable from "./delete-release/delete-release.injectable";
import releaseRollbackDialogModelInjectable
from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable";
interface Props extends MenuActionsProps { interface Props extends MenuActionsProps {
release: HelmRelease; release: HelmRelease;
@ -23,14 +20,14 @@ interface Props extends MenuActionsProps {
} }
interface Dependencies { interface Dependencies {
releaseStore: ReleaseStore deleteRelease: (release: HelmRelease) => Promise<any>
createUpgradeChartTab: (release: HelmRelease) => void createUpgradeChartTab: (release: HelmRelease) => void
openRollbackDialog: (release: HelmRelease) => void openRollbackDialog: (release: HelmRelease) => void
} }
class NonInjectedHelmReleaseMenu extends React.Component<Props & Dependencies> { class NonInjectedHelmReleaseMenu extends React.Component<Props & Dependencies> {
remove = () => { remove = () => {
return this.props.releaseStore.remove(this.props.release); return this.props.deleteRelease(this.props.release);
}; };
upgrade = () => { upgrade = () => {
@ -87,7 +84,7 @@ export const HelmReleaseMenu = withInjectables<Dependencies, Props>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
releaseStore: di.inject(releaseStoreInjectable), deleteRelease: di.inject(deleteReleaseInjectable),
createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable), createUpgradeChartTab: di.inject(createUpgradeChartTabInjectable),
openRollbackDialog: di.inject(releaseRollbackDialogModelInjectable).open, openRollbackDialog: di.inject(releaseRollbackDialogModelInjectable).open,

View File

@ -15,16 +15,16 @@ import { Select, SelectOption } from "../select";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import orderBy from "lodash/orderBy"; import orderBy from "lodash/orderBy";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import releaseStoreInjectable from "./release-store.injectable";
import releaseRollbackDialogModelInjectable import releaseRollbackDialogModelInjectable
from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable"; from "./release-rollback-dialog-model/release-rollback-dialog-model.injectable";
import type { ReleaseRollbackDialogModel } from "./release-rollback-dialog-model/release-rollback-dialog-model"; import type { ReleaseRollbackDialogModel } from "./release-rollback-dialog-model/release-rollback-dialog-model";
import rollbackReleaseInjectable from "./rollback-release/rollback-release.injectable";
interface Props extends DialogProps { interface Props extends DialogProps {
} }
interface Dependencies { interface Dependencies {
rollbackRelease: (releaseName: string, namespace: string, revisionNumber: number) => Promise<any> rollbackRelease: (releaseName: string, namespace: string, revisionNumber: number) => Promise<void>
model: ReleaseRollbackDialogModel model: ReleaseRollbackDialogModel
} }
@ -119,7 +119,7 @@ export const ReleaseRollbackDialog = withInjectables<Dependencies, Props>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
rollbackRelease: di.inject(releaseStoreInjectable).rollback, rollbackRelease: di.inject(rollbackReleaseInjectable),
model: di.inject(releaseRollbackDialogModelInjectable), model: di.inject(releaseRollbackDialogModelInjectable),
...props, ...props,
}), }),

View File

@ -1,17 +0,0 @@
/**
* 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 { ReleaseStore } from "./release.store";
import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable";
const releaseStoreInjectable = getInjectable({
instantiate: (di) => new ReleaseStore({
namespaceStore: di.inject(namespaceStoreInjectable),
}),
lifecycle: lifecycleEnum.singleton,
});
export default releaseStoreInjectable;

View File

@ -1,145 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import isEqual from "lodash/isEqual";
import { action, observable, reaction, when, makeObservable } from "mobx";
import { autoBind } from "../../utils";
import { createRelease, deleteRelease, HelmRelease, IReleaseCreatePayload, IReleaseUpdatePayload, listReleases, rollbackRelease, updateRelease } from "../../../common/k8s-api/endpoints/helm-releases.api";
import { ItemStore } from "../../../common/item.store";
import type { Secret } from "../../../common/k8s-api/endpoints";
import { secretsStore } from "../+config-secrets/secrets.store";
import type { NamespaceStore } from "../+namespaces/namespace-store/namespace.store";
import { Notifications } from "../notifications";
interface Dependencies {
namespaceStore: NamespaceStore
}
export class ReleaseStore extends ItemStore<HelmRelease> {
releaseSecrets = observable.map<string, Secret>();
constructor(private dependencies: Dependencies ) {
super();
makeObservable(this);
autoBind(this);
when(() => secretsStore.isLoaded, () => {
this.releaseSecrets.replace(this.getReleaseSecrets());
});
}
watchAssociatedSecrets(): (() => void) {
return reaction(() => secretsStore.getItems(), () => {
if (this.isLoading) return;
const newSecrets = this.getReleaseSecrets();
const amountChanged = newSecrets.length !== this.releaseSecrets.size;
const labelsChanged = newSecrets.some(([id, secret]) => (
!isEqual(secret.getLabels(), this.releaseSecrets.get(id)?.getLabels())
));
if (amountChanged || labelsChanged) {
this.loadFromContextNamespaces();
}
this.releaseSecrets.replace(newSecrets);
}, {
fireImmediately: true,
});
}
watchSelectedNamespaces(): (() => void) {
return reaction(() => this.dependencies.namespaceStore.context.contextNamespaces, namespaces => {
this.loadAll(namespaces);
}, {
fireImmediately: true,
});
}
private getReleaseSecrets() {
return secretsStore
.getByLabel({ owner: "helm" })
.map(s => [s.getId(), s] as const);
}
getReleaseSecret(release: HelmRelease) {
return secretsStore.getByLabel({
owner: "helm",
name: release.getName(),
})
.find(secret => secret.getNs() == release.getNs());
}
@action
async loadAll(namespaces: string[]) {
this.isLoading = true;
this.isLoaded = false;
try {
const items = await this.loadItems(namespaces);
this.items.replace(this.sortItems(items));
this.isLoaded = true;
this.failedLoading = false;
} catch (error) {
this.failedLoading = true;
console.warn("Loading Helm Chart releases has failed", error);
if (error.error) {
Notifications.error(error.error);
}
} finally {
this.isLoading = false;
}
}
async loadFromContextNamespaces(): Promise<void> {
return this.loadAll(this.dependencies.namespaceStore.context.contextNamespaces);
}
async loadItems(namespaces: string[]) {
const isLoadingAll = this.dependencies.namespaceStore.context.allNamespaces?.length > 1
&& this.dependencies.namespaceStore.context.cluster.accessibleNamespaces.length === 0
&& this.dependencies.namespaceStore.context.allNamespaces.every(ns => namespaces.includes(ns));
if (isLoadingAll) {
return listReleases();
}
return Promise // load resources per namespace
.all(namespaces.map(namespace => listReleases(namespace)))
.then(items => items.flat());
}
create = async (payload: IReleaseCreatePayload) => {
const response = await createRelease(payload);
if (this.isLoaded) this.loadFromContextNamespaces();
return response;
};
async update(name: string, namespace: string, payload: IReleaseUpdatePayload) {
const response = await updateRelease(name, namespace, payload);
if (this.isLoaded) this.loadFromContextNamespaces();
return response;
}
rollback = async (name: string, namespace: string, revision: number) => {
const response = await rollbackRelease(name, namespace, revision);
if (this.isLoaded) this.loadFromContextNamespaces();
return response;
};
async remove(release: HelmRelease) {
return super.removeItem(release, () => deleteRelease(release.getName(), release.getNs()));
}
async removeSelectedItems() {
return Promise.all(this.selectedItems.map(this.remove));
}
}

View File

@ -0,0 +1,40 @@
/**
* 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 { asyncComputed } from "@ogre-tools/injectable-react";
import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable";
import { listReleases } from "../../../common/k8s-api/endpoints/helm-releases.api";
const releasesInjectable = getInjectable({
instantiate: (di) => {
const namespaceStore = di.inject(namespaceStoreInjectable);
// TODO: Inject clusterContext directly instead of accessing dependency of a dependency
const clusterContext = namespaceStore.context;
return asyncComputed(async () => {
const contextNamespaces = namespaceStore.contextNamespaces || [];
const isLoadingAll =
clusterContext.allNamespaces?.length > 1 &&
clusterContext.cluster.accessibleNamespaces.length === 0 &&
clusterContext.allNamespaces.every((namespace) =>
contextNamespaces.includes(namespace),
);
const releaseArrays = await (isLoadingAll ? listReleases() : Promise.all(
contextNamespaces.map((namespace) =>
listReleases(namespace),
),
));
return releaseArrays.flat();
}, []);
},
lifecycle: lifecycleEnum.singleton,
});
export default releasesInjectable;

View File

@ -3,26 +3,30 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import "../item-object-list/item-list-layout.scss";
import "./releases.scss"; import "./releases.scss";
import React, { Component } from "react"; import React, { Component } from "react";
import kebabCase from "lodash/kebabCase";
import { disposeOnUnmount, observer } from "mobx-react";
import type { RouteComponentProps } from "react-router"; import type { RouteComponentProps } from "react-router";
import type { ReleaseStore } from "./release.store";
import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api";
import { ReleaseDetails } from "./release-details";
import { ReleaseRollbackDialog } from "./release-rollback-dialog";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { HelmReleaseMenu } from "./release-menu";
import { secretsStore } from "../+config-secrets/secrets.store";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import type { ReleaseRouteParams } from "../../../common/routes"; import type { ReleaseRouteParams } from "../../../common/routes";
import { releaseURL } from "../../../common/routes"; import { releaseURL } from "../../../common/routes";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import releaseStoreInjectable from "./release-store.injectable";
import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable"; import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable";
import { ItemListLayout } from "../item-object-list";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import { kebabCase } from "lodash/fp";
import { HelmReleaseMenu } from "./release-menu";
import type { ItemStore } from "../../../common/item.store";
import { ReleaseRollbackDialog } from "./release-rollback-dialog";
import { ReleaseDetails } from "./release-details/release-details";
import removableReleasesInjectable from "./removable-releases.injectable";
import type { RemovableHelmRelease } from "./removable-releases";
import { observer } from "mobx-react";
import type { IComputedValue } from "mobx";
import releasesInjectable from "./releases.injectable";
import { Spinner } from "../spinner";
enum columnId { enum columnId {
name = "name", name = "name",
@ -39,7 +43,8 @@ interface Props extends RouteComponentProps<ReleaseRouteParams> {
} }
interface Dependencies { interface Dependencies {
releaseStore: ReleaseStore releases: IComputedValue<RemovableHelmRelease[]>
releasesArePending: IComputedValue<boolean>
selectNamespace: (namespace: string) => void selectNamespace: (namespace: string) => void
} }
@ -51,27 +56,10 @@ class NonInjectedHelmReleases extends Component<Dependencies & Props> {
if (namespace) { if (namespace) {
this.props.selectNamespace(namespace); this.props.selectNamespace(namespace);
} }
disposeOnUnmount(this, [
this.props.releaseStore.watchAssociatedSecrets(),
this.props.releaseStore.watchSelectedNamespaces(),
]);
}
get selectedRelease() {
const { match: { params: { name, namespace }}} = this.props;
return this.props.releaseStore.items.find(release => {
return release.getName() == name && release.getNs() == namespace;
});
} }
onDetails = (item: HelmRelease) => { onDetails = (item: HelmRelease) => {
if (item === this.selectedRelease) { this.showDetails(item);
this.hideDetails();
} else {
this.showDetails(item);
}
}; };
showDetails = (item: HelmRelease) => { showDetails = (item: HelmRelease) => {
@ -101,14 +89,57 @@ class NonInjectedHelmReleases extends Component<Dependencies & Props> {
} }
render() { render() {
if (this.props.releasesArePending.get()) {
// TODO: Make Spinner "center" work properly
return <div className="flex center" style={{ height: "100%" }}><Spinner /></div>;
}
const releases = this.props.releases;
// TODO: Implement ItemListLayout without stateful stores
const legacyReleaseStore = {
get items() {
return releases.get();
},
loadAll: () => Promise.resolve(),
isLoaded: true,
failedLoading: false,
getTotalCount: () => releases.get().length,
toggleSelection: (item) => {
item.toggle();
},
isSelectedAll: () =>
releases.get().every((release) => release.isSelected),
toggleSelectionAll: () => {
releases.get().forEach((release) => release.toggle());
},
isSelected: (item) => item.isSelected,
get selectedItems() {
return releases.get().filter((release) => release.isSelected);
},
removeSelectedItems() {
return Promise.all(
releases.get().filter((release) => release.isSelected).map((release) => release.delete()),
);
},
} as ItemStore<RemovableHelmRelease>;
return ( return (
<> <>
<ItemListLayout <ItemListLayout
store={legacyReleaseStore}
preloadStores={false}
isConfigurable isConfigurable
tableId="helm_releases" tableId="helm_releases"
className="HelmReleases" className="HelmReleases"
store={this.props.releaseStore}
dependentStores={[secretsStore]}
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: release => release.getName(), [columnId.name]: release => release.getName(),
[columnId.namespace]: release => release.getNs(), [columnId.namespace]: release => release.getNs(),
@ -167,13 +198,13 @@ class NonInjectedHelmReleases extends Component<Dependencies & Props> {
customizeRemoveDialog={selectedItems => ({ customizeRemoveDialog={selectedItems => ({
message: this.renderRemoveDialogMessage(selectedItems), message: this.renderRemoveDialogMessage(selectedItems),
})} })}
detailsItem={this.selectedRelease}
onDetails={this.onDetails} onDetails={this.onDetails}
/> />
<ReleaseDetails <ReleaseDetails
release={this.selectedRelease}
hideDetails={this.hideDetails} hideDetails={this.hideDetails}
/> />
<ReleaseRollbackDialog/> <ReleaseRollbackDialog/>
</> </>
); );
@ -185,7 +216,8 @@ export const HelmReleases = withInjectables<Dependencies, Props>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
releaseStore: di.inject(releaseStoreInjectable), releases: di.inject(removableReleasesInjectable),
releasesArePending: di.inject(releasesInjectable).pending,
selectNamespace: di.inject(namespaceStoreInjectable).selectNamespaces, selectNamespace: di.inject(namespaceStoreInjectable).selectNamespaces,
...props, ...props,
}), }),

View File

@ -0,0 +1,22 @@
/**
* 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 { observable } from "mobx";
import releasesInjectable from "./releases.injectable";
import deleteReleaseInjectable from "./delete-release/delete-release.injectable";
import { removableReleases } from "./removable-releases";
const removableReleasesInjectable = getInjectable({
instantiate: (di) =>
removableReleases({
releases: di.inject(releasesInjectable),
deleteRelease: di.inject(deleteReleaseInjectable),
releaseSelectionStatus: observable.map<string, boolean>(),
}),
lifecycle: lifecycleEnum.singleton,
});
export default removableReleasesInjectable;

View File

@ -0,0 +1,48 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
import { computed, ObservableMap } from "mobx";
import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api";
interface Dependencies {
releases: IAsyncComputed<HelmRelease[]>;
releaseSelectionStatus: ObservableMap<string, boolean>;
deleteRelease: (release: HelmRelease) => Promise<any>;
}
export interface RemovableHelmRelease extends HelmRelease {
toggle: () => void;
isSelected: boolean;
delete: () => Promise<void>;
}
export const removableReleases = ({
releases,
releaseSelectionStatus,
deleteRelease,
}: Dependencies) => {
const isSelected = (release: HelmRelease) =>
releaseSelectionStatus.get(release.getId()) || false;
return computed(() =>
releases.value.get().map(
(release): RemovableHelmRelease => ({
...release,
toggle: () => {
releaseSelectionStatus.set(release.getId(), !isSelected(release));
},
get isSelected() {
return isSelected(release);
},
delete: async () => {
await deleteRelease(release);
},
}),
),
);
};

View File

@ -0,0 +1,22 @@
/**
* 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 { rollbackRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import releasesInjectable from "../releases.injectable";
const rollbackReleaseInjectable = getInjectable({
instantiate: (di) => {
const releases = di.inject(releasesInjectable);
return async (name: string, namespace: string, revision: number) => {
await rollbackRelease(name, namespace, revision);
releases.invalidate();
};
},
lifecycle: lifecycleEnum.singleton,
});
export default rollbackReleaseInjectable;

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, lifecycleEnum } from "@ogre-tools/injectable";
import { updateRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
const updateReleaseInjectable = getInjectable({
instantiate: () => updateRelease,
lifecycle: lifecycleEnum.singleton,
});
export default updateReleaseInjectable;

View File

@ -27,10 +27,10 @@ import type {
IReleaseCreatePayload, IReleaseCreatePayload,
IReleaseUpdateDetails, IReleaseUpdateDetails,
} from "../../../common/k8s-api/endpoints/helm-releases.api"; } from "../../../common/k8s-api/endpoints/helm-releases.api";
import releaseStoreInjectable from "../+apps-releases/release-store.injectable";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import installChartStoreInjectable from "./install-chart-store/install-chart-store.injectable"; import installChartStoreInjectable from "./install-chart-store/install-chart-store.injectable";
import dockStoreInjectable from "./dock-store/dock-store.injectable"; import dockStoreInjectable from "./dock-store/dock-store.injectable";
import createReleaseInjectable from "../+apps-releases/create-release/create-release.injectable";
interface Props { interface Props {
tab: DockTab; tab: DockTab;
@ -222,7 +222,7 @@ export const InstallChart = withInjectables<Dependencies, Props>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
createRelease: di.inject(releaseStoreInjectable).create, createRelease: di.inject(createReleaseInjectable),
installChartStore: di.inject(installChartStoreInjectable), installChartStore: di.inject(installChartStoreInjectable),
dockStore: di.inject(dockStoreInjectable), dockStore: di.inject(dockStoreInjectable),
...props, ...props,

View File

@ -4,10 +4,10 @@
*/ */
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { UpgradeChartStore } from "./upgrade-chart.store"; import { UpgradeChartStore } from "./upgrade-chart.store";
import releaseStoreInjectable from "../../+apps-releases/release-store.injectable";
import dockStoreInjectable from "../dock-store/dock-store.injectable"; import dockStoreInjectable from "../dock-store/dock-store.injectable";
import createDockTabStoreInjectable from "../dock-tab-store/create-dock-tab-store.injectable"; import createDockTabStoreInjectable from "../dock-tab-store/create-dock-tab-store.injectable";
import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable";
import releasesInjectable from "../../+apps-releases/releases.injectable";
const upgradeChartStoreInjectable = getInjectable({ const upgradeChartStoreInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {
@ -16,7 +16,7 @@ const upgradeChartStoreInjectable = getInjectable({
const valuesStore = createDockTabStore<string>(); const valuesStore = createDockTabStore<string>();
return new UpgradeChartStore({ return new UpgradeChartStore({
releaseStore: di.inject(releaseStoreInjectable), releases: di.inject(releasesInjectable),
dockStore: di.inject(dockStoreInjectable), dockStore: di.inject(dockStoreInjectable),
createStorage: di.inject(createStorageInjectable), createStorage: di.inject(createStorageInjectable),
valuesStore, valuesStore,

View File

@ -3,12 +3,22 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { action, autorun, computed, IReactionDisposer, reaction, makeObservable } from "mobx"; import {
action,
autorun,
computed,
IReactionDisposer,
reaction,
makeObservable,
} from "mobx";
import { DockStore, DockTab, TabId, TabKind } from "../dock-store/dock.store"; import { DockStore, DockTab, TabId, TabKind } from "../dock-store/dock.store";
import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store"; import { DockTabStorageState, DockTabStore } from "../dock-tab-store/dock-tab.store";
import { getReleaseValues } from "../../../../common/k8s-api/endpoints/helm-releases.api"; import {
import type { ReleaseStore } from "../../+apps-releases/release.store"; getReleaseValues,
HelmRelease,
} from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { iter, StorageHelper } from "../../../utils"; import { iter, StorageHelper } from "../../../utils";
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
export interface IChartUpgradeData { export interface IChartUpgradeData {
releaseName: string; releaseName: string;
@ -16,7 +26,7 @@ export interface IChartUpgradeData {
} }
interface Dependencies { interface Dependencies {
releaseStore: ReleaseStore releases: IAsyncComputed<HelmRelease[]>
valuesStore: DockTabStore<string> valuesStore: DockTabStore<string>
dockStore: DockStore dockStore: DockStore
createStorage: <T>(storageKey: string, options: DockTabStorageState<T>) => StorageHelper<DockTabStorageState<T>> createStorage: <T>(storageKey: string, options: DockTabStorageState<T>) => StorageHelper<DockTabStorageState<T>>
@ -60,14 +70,14 @@ export class UpgradeChartStore extends DockTabStore<IChartUpgradeData> {
return; return;
} }
const dispose = reaction(() => { const dispose = reaction(() => {
const release = this.dependencies.releaseStore.getByName(releaseName); const release = this.dependencies.releases.value.get().find(release => release.getName() === releaseName);
return release?.getRevision(); // watch changes only by revision return release?.getRevision(); // watch changes only by revision
}, },
release => { release => {
const releaseTab = this.getTabByRelease(releaseName); const releaseTab = this.getTabByRelease(releaseName);
if (!this.dependencies.releaseStore.isLoaded || !releaseTab) { if (!releaseTab) {
return; return;
} }
@ -91,7 +101,7 @@ export class UpgradeChartStore extends DockTabStore<IChartUpgradeData> {
isLoading(tabId = this.dependencies.dockStore.selectedTabId) { isLoading(tabId = this.dependencies.dockStore.selectedTabId) {
const values = this.values.getData(tabId); const values = this.values.getData(tabId);
return !this.dependencies.releaseStore.isLoaded || values === undefined; return values === undefined;
} }
@action @action
@ -99,7 +109,6 @@ export class UpgradeChartStore extends DockTabStore<IChartUpgradeData> {
const values = this.values.getData(tabId); const values = this.values.getData(tabId);
await Promise.all([ await Promise.all([
!this.dependencies.releaseStore.isLoaded && this.dependencies.releaseStore.loadFromContextNamespaces(),
!values && this.loadValues(tabId), !values && this.loadValues(tabId),
]); ]);
} }

View File

@ -13,15 +13,22 @@ import type { DockTab } from "./dock-store/dock.store";
import { InfoPanel } from "./info-panel"; import { InfoPanel } from "./info-panel";
import type { UpgradeChartStore } from "./upgrade-chart-store/upgrade-chart.store"; import type { UpgradeChartStore } from "./upgrade-chart-store/upgrade-chart.store";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import type { ReleaseStore } from "../+apps-releases/release.store";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { EditorPanel } from "./editor-panel"; import { EditorPanel } from "./editor-panel";
import { helmChartStore, IChartVersion } from "../+apps-helm-charts/helm-chart.store"; import {
import type { HelmRelease } from "../../../common/k8s-api/endpoints/helm-releases.api"; helmChartStore,
IChartVersion,
} from "../+apps-helm-charts/helm-chart.store";
import type {
HelmRelease,
IReleaseUpdateDetails,
IReleaseUpdatePayload,
} from "../../../common/k8s-api/endpoints/helm-releases.api";
import { Select, SelectOption } from "../select"; import { Select, SelectOption } from "../select";
import { withInjectables } from "@ogre-tools/injectable-react"; import { IAsyncComputed, withInjectables } from "@ogre-tools/injectable-react";
import releaseStoreInjectable from "../+apps-releases/release-store.injectable";
import upgradeChartStoreInjectable from "./upgrade-chart-store/upgrade-chart-store.injectable"; import upgradeChartStoreInjectable from "./upgrade-chart-store/upgrade-chart-store.injectable";
import updateReleaseInjectable from "../+apps-releases/update-release/update-release.injectable";
import releasesInjectable from "../+apps-releases/releases.injectable";
interface Props { interface Props {
className?: string; className?: string;
@ -29,8 +36,9 @@ interface Props {
} }
interface Dependencies { interface Dependencies {
releaseStore: ReleaseStore releases: IAsyncComputed<HelmRelease[]>
upgradeChartStore: UpgradeChartStore upgradeChartStore: UpgradeChartStore
updateRelease: (name: string, namespace: string, payload: IReleaseUpdatePayload) => Promise<IReleaseUpdateDetails>
} }
@observer @observer
@ -61,7 +69,7 @@ export class NonInjectedUpgradeChart extends React.Component<Props & Dependencie
if (!tabData) return null; if (!tabData) return null;
return this.props.releaseStore.getByName(tabData.releaseName); return this.props.releases.value.get().find(release => release.getName() === tabData.releaseName);
} }
get value() { get value() {
@ -95,7 +103,7 @@ export class NonInjectedUpgradeChart extends React.Component<Props & Dependencie
const releaseName = this.release.getName(); const releaseName = this.release.getName();
const releaseNs = this.release.getNs(); const releaseNs = this.release.getNs();
await this.props.releaseStore.update(releaseName, releaseNs, { await this.props.updateRelease(releaseName, releaseNs, {
chart: this.release.getChart(), chart: this.release.getChart(),
values: this.value, values: this.value,
repo, version, repo, version,
@ -167,7 +175,8 @@ export const UpgradeChart = withInjectables<Dependencies, Props>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
releaseStore: di.inject(releaseStoreInjectable), releases: di.inject(releasesInjectable),
updateRelease: di.inject(updateReleaseInjectable),
upgradeChartStore: di.inject(upgradeChartStoreInjectable), upgradeChartStore: di.inject(upgradeChartStoreInjectable),
...props, ...props,
}), }),

View File

@ -11,12 +11,13 @@ import { createPortal } from "react-dom";
import { cssNames, noop, StorageHelper } from "../../utils"; import { cssNames, noop, StorageHelper } from "../../utils";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Animate, AnimateName } from "../animate"; import { Animate, AnimateName } from "../animate";
import { history } from "../../navigation";
import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor"; import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor";
import drawerStorageInjectable, { import drawerStorageInjectable, {
defaultDrawerWidth, defaultDrawerWidth,
} from "./drawer-storage/drawer-storage.injectable"; } from "./drawer-storage/drawer-storage.injectable";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import historyInjectable from "../../navigation/history.injectable";
import type { History } from "history";
export type DrawerPosition = "top" | "left" | "right" | "bottom"; export type DrawerPosition = "top" | "left" | "right" | "bottom";
@ -59,6 +60,7 @@ resizingAnchorProps.set("top", [ResizeDirection.VERTICAL, ResizeSide.TRAILING, R
resizingAnchorProps.set("bottom", [ResizeDirection.VERTICAL, ResizeSide.LEADING, ResizeGrowthDirection.BOTTOM_TO_TOP]); resizingAnchorProps.set("bottom", [ResizeDirection.VERTICAL, ResizeSide.LEADING, ResizeGrowthDirection.BOTTOM_TO_TOP]);
interface Dependencies { interface Dependencies {
history: History
drawerStorage: StorageHelper<{ width: number }>; drawerStorage: StorageHelper<{ width: number }>;
} }
@ -70,7 +72,7 @@ class NonInjectedDrawer extends React.Component<DrawerProps & Dependencies, Stat
private scrollElem: HTMLElement; private scrollElem: HTMLElement;
private scrollPos = new Map<string, number>(); private scrollPos = new Map<string, number>();
private stopListenLocation = history.listen(() => { private stopListenLocation = this.props.history.listen(() => {
this.restoreScrollPos(); this.restoreScrollPos();
}); });
@ -111,14 +113,14 @@ class NonInjectedDrawer extends React.Component<DrawerProps & Dependencies, Stat
saveScrollPos = () => { saveScrollPos = () => {
if (!this.scrollElem) return; if (!this.scrollElem) return;
const key = history.location.key; const key = this.props.history.location.key;
this.scrollPos.set(key, this.scrollElem.scrollTop); this.scrollPos.set(key, this.scrollElem.scrollTop);
}; };
restoreScrollPos = () => { restoreScrollPos = () => {
if (!this.scrollElem) return; if (!this.scrollElem) return;
const key = history.location.key; const key = this.props.history.location.key;
this.scrollElem.scrollTop = this.scrollPos.get(key) || 0; this.scrollElem.scrollTop = this.scrollPos.get(key) || 0;
}; };
@ -232,6 +234,7 @@ export const Drawer = withInjectables<Dependencies, DrawerProps>(
{ {
getProps: (di, props) => ({ getProps: (di, props) => ({
history: di.inject(historyInjectable),
drawerStorage: di.inject(drawerStorageInjectable), drawerStorage: di.inject(drawerStorageInjectable),
...props, ...props,
}), }),

View File

@ -6,7 +6,6 @@ import React from "react";
import { observable, makeObservable } from "mobx"; import { observable, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { Redirect, Route, Router, Switch } from "react-router"; import { Redirect, Route, Router, Switch } from "react-router";
import { history } from "../../navigation";
import { UserManagement } from "../../components/+user-management/user-management"; import { UserManagement } from "../../components/+user-management/user-management";
import { ConfirmDialog } from "../../components/confirm-dialog"; import { ConfirmDialog } from "../../components/confirm-dialog";
import { ClusterOverview } from "../../components/+cluster/cluster-overview"; import { ClusterOverview } from "../../components/+cluster/cluster-overview";
@ -41,17 +40,18 @@ import { PortForwardDialog } from "../../port-forward";
import { DeleteClusterDialog } from "../../components/delete-cluster-dialog"; import { DeleteClusterDialog } from "../../components/delete-cluster-dialog";
import type { NamespaceStore } from "../../components/+namespaces/namespace-store/namespace.store"; import type { NamespaceStore } from "../../components/+namespaces/namespace-store/namespace.store";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import namespaceStoreInjectable import namespaceStoreInjectable from "../../components/+namespaces/namespace-store/namespace-store.injectable";
from "../../components/+namespaces/namespace-store/namespace-store.injectable";
import type { ClusterId } from "../../../common/cluster-types"; import type { ClusterId } from "../../../common/cluster-types";
import hostedClusterInjectable import hostedClusterInjectable from "../../../common/cluster-store/hosted-cluster/hosted-cluster.injectable";
from "../../../common/cluster-store/hosted-cluster/hosted-cluster.injectable";
import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { KubeObject } from "../../../common/k8s-api/kube-object"; import type { KubeObject } from "../../../common/k8s-api/kube-object";
import type { Disposer } from "../../../common/utils"; import type { Disposer } from "../../../common/utils";
import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable"; import kubeWatchApiInjectable from "../../kube-watch-api/kube-watch-api.injectable";
import historyInjectable from "../../navigation/history.injectable";
import type { History } from "history";
interface Dependencies { interface Dependencies {
history: History,
namespaceStore: NamespaceStore namespaceStore: NamespaceStore
hostedClusterId: ClusterId hostedClusterId: ClusterId
subscribeStores: (stores: KubeObjectStore<KubeObject>[]) => Disposer subscribeStores: (stores: KubeObjectStore<KubeObject>[]) => Disposer
@ -135,10 +135,11 @@ class NonInjectedClusterFrame extends React.Component<Dependencies> {
render() { render() {
return ( return (
<Router history={history}> <Router history={this.props.history}>
<ErrorBoundary> <ErrorBoundary>
<MainLayout sidebar={<Sidebar />} footer={<Dock />}> <MainLayout sidebar={<Sidebar />} footer={<Dock />}>
<Switch> <Switch>
<Route component={ClusterOverview} {...routes.clusterRoute}/> <Route component={ClusterOverview} {...routes.clusterRoute}/>
<Route component={Nodes} {...routes.nodesRoute}/> <Route component={Nodes} {...routes.nodesRoute}/>
<Route component={Workloads} {...routes.workloadsRoute}/> <Route component={Workloads} {...routes.workloadsRoute}/>
@ -181,6 +182,7 @@ class NonInjectedClusterFrame extends React.Component<Dependencies> {
export const ClusterFrame = withInjectables<Dependencies>(NonInjectedClusterFrame, { export const ClusterFrame = withInjectables<Dependencies>(NonInjectedClusterFrame, {
getProps: di => ({ getProps: di => ({
history: di.inject(historyInjectable),
namespaceStore: di.inject(namespaceStoreInjectable), namespaceStore: di.inject(namespaceStoreInjectable),
hostedClusterId: di.inject(hostedClusterInjectable).id, hostedClusterId: di.inject(hostedClusterInjectable).id,
subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores, subscribeStores: di.inject(kubeWatchApiInjectable).subscribeStores,

View File

@ -7,7 +7,6 @@ import { injectSystemCAs } from "../../../common/system-ca";
import React from "react"; import React from "react";
import { Route, Router, Switch } from "react-router"; import { Route, Router, Switch } from "react-router";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { history } from "../../navigation";
import { ClusterManager } from "../../components/cluster-manager"; import { ClusterManager } from "../../components/cluster-manager";
import { ErrorBoundary } from "../../components/error-boundary"; import { ErrorBoundary } from "../../components/error-boundary";
import { Notifications } from "../../components/notifications"; import { Notifications } from "../../components/notifications";
@ -16,14 +15,21 @@ import { CommandContainer } from "../../components/command-palette/command-conta
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { IpcRendererNavigationEvents } from "../../navigation/events"; import { IpcRendererNavigationEvents } from "../../navigation/events";
import { ClusterFrameHandler } from "../../components/cluster-manager/lens-views"; import { ClusterFrameHandler } from "../../components/cluster-manager/lens-views";
import historyInjectable from "../../navigation/history.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import type { History } from "history";
injectSystemCAs(); injectSystemCAs();
interface Dependencies {
history: History
}
@observer @observer
export class RootFrame extends React.Component { class NonInjectedRootFrame extends React.Component<Dependencies> {
static displayName = "RootFrame"; static displayName = "RootFrame";
constructor(props: {}) { constructor(props: Dependencies) {
super(props); super(props);
ClusterFrameHandler.createInstance(); ClusterFrameHandler.createInstance();
@ -35,7 +41,7 @@ export class RootFrame extends React.Component {
render() { render() {
return ( return (
<Router history={history}> <Router history={this.props.history}>
<ErrorBoundary> <ErrorBoundary>
<Switch> <Switch>
<Route component={ClusterManager} /> <Route component={ClusterManager} />
@ -48,3 +54,7 @@ export class RootFrame extends React.Component {
); );
} }
} }
export const RootFrame = withInjectables(NonInjectedRootFrame, {
getProps: (di) => ({ history: di.inject(historyInjectable) }),
});

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, lifecycleEnum } from "@ogre-tools/injectable";
import { history } from "./history";
const historyInjectable = getInjectable({
instantiate: () => history,
lifecycle: lifecycleEnum.singleton,
});
export default historyInjectable;

View File

@ -14,8 +14,14 @@ export const searchParamsOptions: ObservableSearchParamsOptions = {
joinArraysWith: ",", // param values splitter, applicable only with {joinArrays:true} joinArraysWith: ",", // param values splitter, applicable only with {joinArrays:true}
}; };
/**
* @deprecated: Switch to using di.inject(historyInjectable)
*/
export const history = ipcRenderer ? createBrowserHistory() : createMemoryHistory(); export const history = ipcRenderer ? createBrowserHistory() : createMemoryHistory();
/**
* @deprecated: Switch to using di.inject(observableHistoryInjectable)
*/
export const navigation = createObservableHistory(history, { export const navigation = createObservableHistory(history, {
searchParams: searchParamsOptions, searchParams: searchParamsOptions,
}); });

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 { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { navigation as observableHistory } from "./history";
const observableHistoryInjectable = getInjectable({
instantiate: () => observableHistory,
lifecycle: lifecycleEnum.singleton,
});
export default observableHistoryInjectable;

View File

@ -979,19 +979,19 @@
dependencies: dependencies:
lodash "^4.17.21" lodash "^4.17.21"
"@ogre-tools/injectable-react@3.1.1": "@ogre-tools/injectable-react@3.2.0":
version "3.1.1" version "3.2.0"
resolved "https://registry.yarnpkg.com/@ogre-tools/injectable-react/-/injectable-react-3.1.1.tgz#d95ecec518ba798c36fa3a6f651fa52748e72b00" resolved "https://registry.yarnpkg.com/@ogre-tools/injectable-react/-/injectable-react-3.2.0.tgz#468542e846952deb8e7a4f6757da4813ee8f11fa"
integrity sha512-Fhb/51NzrLzkA3G5zCpNOshvm0el1gROWGHkBqq1d/8PEekcEijIL8HZ6B/ylCWjQTJ1MaYViJdzs2iNP1oQxw== integrity sha512-VU5l0uKe86psVzEPbXl1TLlflnoL+uSeOaOCy/mAGzau4nqRb+eA4RzYgzUs/D9tDYzJ7Es+LZWD9uSyXmpyXg==
dependencies: dependencies:
"@ogre-tools/fp" "^3.0.0" "@ogre-tools/fp" "^3.0.0"
"@ogre-tools/injectable" "^3.1.1" "@ogre-tools/injectable" "^3.2.0"
lodash "^4.17.21" lodash "^4.17.21"
"@ogre-tools/injectable@3.1.1", "@ogre-tools/injectable@^3.1.1": "@ogre-tools/injectable@3.2.0", "@ogre-tools/injectable@^3.2.0":
version "3.1.1" version "3.2.0"
resolved "https://registry.yarnpkg.com/@ogre-tools/injectable/-/injectable-3.1.1.tgz#2f293a90e4d3f730ebab2689fd609edc24ffc563" resolved "https://registry.yarnpkg.com/@ogre-tools/injectable/-/injectable-3.2.0.tgz#7d8f653cb3a2c0253a29422bcffd5123308600a9"
integrity sha512-X7cDU2Mkcl2bP8JtR9l/Hx31jmKYEuCVJGjZIYxWlE1Nvd3HGq98oTV5uEGNP6+GjLHhXjzoscT9SKKzexyQWg== integrity sha512-aRlRdvLefJMBvFu1tRlTGNgpMbqE250lwMXT8Y6/ruC88rCL+TygCWcsJELad1OgqX1cfHkCgCYeQeohV+G3Zg==
dependencies: dependencies:
"@ogre-tools/fp" "^3.0.0" "@ogre-tools/fp" "^3.0.0"
lodash "^4.17.21" lodash "^4.17.21"