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

Move ownership and enabling tracking into cluster store

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-03-22 08:06:56 -04:00
parent b066fb3527
commit 29dadd478a
16 changed files with 182 additions and 112 deletions

View File

@ -28,11 +28,7 @@ class KubectlDownloader {
resolveWithFullResponse: true
}).catch((error) => { console.log(error); });
if (response.headers["etag"]) {
return response.headers["etag"].replace(/"/g, "");
}
return "";
return response.headers?.["etag"]?.replace(/"/g, "") ?? "";
}
public async checkBinary() {
@ -87,7 +83,7 @@ class KubectlDownloader {
throw(error);
});
return new Promise((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
file.on("close", () => {
console.log("kubectl binary download closed");
fs.chmod(this.path, 0o755, (err) => {
@ -116,4 +112,3 @@ downloads.forEach((dlOpts) => {
console.log(`Downloading: ${JSON.stringify(dlOpts)}`);
downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete")));
});

View File

@ -362,12 +362,12 @@
"terser-webpack-plugin": "^3.0.3",
"ts-jest": "^26.1.0",
"ts-loader": "^7.0.5",
"ts-node": "^8.10.2",
"ts-node": "^9.1.1",
"type-fest": "^0.18.0",
"typedoc": "0.17.0-3",
"typedoc-plugin-markdown": "^2.4.0",
"typeface-roboto": "^0.0.75",
"typescript": "4.0.2",
"typescript": "^4.2.3",
"url-loader": "^4.1.0",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.11",

View File

@ -88,7 +88,7 @@ describe("empty config", () => {
expect(storedCluster.id).toBe("foo");
expect(storedCluster.preferences.terminalCWD).toBe("/tmp");
expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5");
expect(storedCluster.enabled).toBe(true);
expect(clusterStore.isClusterEnabled(storedCluster)).toBe(true);
});
it("adds cluster to default workspace", () => {
@ -265,8 +265,8 @@ describe("config with existing clusters", () => {
it("marks owned cluster disabled by default", () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters[0].enabled).toBe(true);
expect(storedClusters[2].enabled).toBe(false);
expect(clusterStore.isClusterEnabled(storedClusters[0])).toBe(true);
expect(clusterStore.isClusterEnabled(storedClusters[2])).toBe(false);
});
});
@ -336,9 +336,9 @@ users:
const storedClusters = clusterStore.clustersList;
expect(storedClusters.length).toBe(2);
expect(storedClusters[0].enabled).toBeFalsy;
expect(clusterStore.isClusterEnabled(storedClusters[0])).toBeFalsy;
expect(storedClusters[1].id).toBe("cluster2");
expect(storedClusters[1].enabled).toBeTruthy;
expect(clusterStore.isClusterEnabled(storedClusters[1])).toBeTruthy;
});
});

View File

@ -11,11 +11,12 @@ import { appEventBus } from "./event-bus";
import { dumpConfigYaml } from "./kube-helpers";
import { saveToAppFiles } from "./utils/saveToAppFiles";
import { KubeConfig } from "@kubernetes/client-node";
import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
import { broadcastMessage, handleRequest, InvalidKubeconfigChannel, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
import _ from "lodash";
import move from "array-move";
import type { WorkspaceId } from "./workspace-store";
import { ResourceType } from "../renderer/components/+cluster-settings/components/cluster-metrics-setting";
import { LensExtensionId } from "../extensions/lens-extension";
export interface ClusterIconUpload {
clusterId: string;
@ -34,8 +35,9 @@ export type ClusterPrometheusMetadata = {
};
export interface ClusterStoreModel {
activeCluster?: ClusterId; // last opened cluster
activeClusterId?: ClusterId; // last opened cluster
clusters?: ClusterModel[];
clusterOwners?: [ClusterId, LensExtensionId][];
}
export type ClusterId = string;
@ -59,11 +61,6 @@ export interface ClusterModel {
/** Metadata */
metadata?: ClusterMetadata;
/**
* If extension sets ownerRef it has to explicitly mark a cluster as enabled during onActive (or when cluster is saved)
*/
ownerRef?: string;
/** List of accessible namespaces */
accessibleNamespaces?: string[];
@ -71,6 +68,16 @@ export interface ClusterModel {
kubeConfig?: string; // yaml
}
export interface ClusterManagementRecord {
ownerId: LensExtensionId;
enabled: boolean;
}
export interface GetByWorkspaceIdOptions {
includeDisabled?: boolean; // default false
sortByIconOrder?: boolean; // default true
}
export interface ClusterPreferences extends ClusterPrometheusPreferences {
terminalCWD?: string;
clusterName?: string;
@ -92,6 +99,22 @@ export interface ClusterPrometheusPreferences {
};
}
function splitAddClusterArgs(args: ClusterModel[] | [...ClusterModel[], string]): [ClusterModel[]] | [ClusterModel[], string] {
const lastArg = args.pop();
if (lastArg) {
return [[]];
}
if (typeof lastArg === "string") {
return [args as ClusterModel[], lastArg];
}
args.push(lastArg);
return [args as ClusterModel[]];
}
export class ClusterStore extends BaseStore<ClusterStoreModel> {
static getCustomKubeConfigPath(clusterId: ClusterId): string {
return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs", clusterId);
@ -106,9 +129,11 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return filePath;
}
@observable activeCluster: ClusterId;
@observable activeClusterId: ClusterId;
@observable removedClusters = observable.map<ClusterId, Cluster>();
@observable clusters = observable.map<ClusterId, Cluster>();
@observable clusterManagingInfo = observable.map<ClusterId, ClusterManagementRecord>();
@observable erroredClusterModels = observable.array<ClusterModel>();
private static stateRequestChannel = "cluster:states";
@ -189,28 +214,50 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
});
}
get activeClusterId() {
return this.activeCluster;
}
@computed get clustersList(): Cluster[] {
return Array.from(this.clusters.values());
}
@computed get enabledClustersList(): Cluster[] {
return this.clustersList.filter((c) => c.enabled);
return this.clustersList.filter(c => this.isClusterEnabled(c));
}
@computed get active(): Cluster | null {
return this.getById(this.activeCluster);
return this.getById(this.activeClusterId);
}
@computed get connectedClustersList(): Cluster[] {
return this.clustersList.filter((c) => !c.disconnected);
}
/**
*
* @param clusterOrId The cluster or its ID to check if it is owned
* @returns true if an extension has claimed to be managing a cluster
*/
isClusterManaged(clusterOrId: Cluster | ClusterId): boolean {
const clusterId = typeof clusterOrId === "string"
? clusterOrId
: clusterOrId.id;
return this.clusterManagingInfo.has(clusterId);
}
/**
* Get the enabled status of a cluster
* @param clusterOrId The cluster or its ID to check if it is owned
* @returns true if not managed, else true if owner has marked as enabled
*/
isClusterEnabled(clusterOrId: Cluster | ClusterId): boolean {
const clusterId = typeof clusterOrId === "string"
? clusterOrId
: clusterOrId.id;
return this.clusterManagingInfo.get(clusterId)?.enabled ?? true;
}
isActive(id: ClusterId) {
return this.activeCluster === id;
return this.activeClusterId === id;
}
isMetricHidden(resource: ResourceType) {
@ -221,7 +268,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
setActive(id: ClusterId) {
const clusterId = this.clusters.has(id) ? id : null;
this.activeCluster = clusterId;
this.activeClusterId = clusterId;
workspaceStore.setLastActiveClusterId(clusterId);
}
@ -249,37 +296,50 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return this.clusters.get(id);
}
getByWorkspaceId(workspaceId: string): Cluster[] {
getByWorkspaceId(workspaceId: string, options?: GetByWorkspaceIdOptions): Cluster[] {
const includeDisabled = options?.includeDisabled ?? false;
const sortByIconOrder = options?.sortByIconOrder ?? true;
const clusters = Array.from(this.clusters.values())
.filter(cluster => cluster.workspace === workspaceId);
.filter(cluster => (
cluster.workspace === workspaceId
&& (
includeDisabled
|| this.isClusterEnabled(cluster)
)
));
return _.sortBy(clusters, cluster => cluster.preferences.iconOrder);
}
@action
addClusters(...models: ClusterModel[]): Cluster[] {
const clusters: Cluster[] = [];
models.forEach(model => {
clusters.push(this.addCluster(model));
});
if (sortByIconOrder) {
return _.sortBy(clusters, cluster => cluster.preferences.iconOrder);
}
return clusters;
}
@action
addCluster(model: ClusterModel | Cluster): Cluster {
addClusters<M extends ClusterModel, MM extends M[]>(...args: ([...MM] | [...MM, LensExtensionId])): Cluster[] {
const [models, ownerId] = splitAddClusterArgs(args);
return models.map(model => this.addCluster(model, ownerId));
}
@action
addCluster(clusterOrModel: ClusterModel | Cluster, ownerId?: LensExtensionId): Cluster {
appEventBus.emit({ name: "cluster", action: "add" });
let cluster = model as Cluster;
if (!(model instanceof Cluster)) {
cluster = new Cluster(model);
const cluster = clusterOrModel instanceof Cluster
? clusterOrModel
: new Cluster(clusterOrModel);
if (ownerId) {
if (this.isClusterManaged(cluster)) {
throw new Error("Extension tried to claim an already managed cluster");
}
this.clusterManagingInfo.set(cluster.id, { ownerId, enabled: false });
}
if (!cluster.isManaged) {
cluster.enabled = true;
}
this.clusters.set(model.id, cluster);
this.clusters.set(clusterOrModel.id, cluster);
return cluster;
}
@ -296,7 +356,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
if (cluster) {
this.clusters.delete(clusterId);
if (this.activeCluster === clusterId) {
if (this.activeClusterId === clusterId) {
this.setActive(null);
}
@ -309,31 +369,43 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@action
removeByWorkspaceId(workspaceId: string) {
this.getByWorkspaceId(workspaceId).forEach(cluster => {
this.removeById(cluster.id);
const workspaceClusters = this.getByWorkspaceId(workspaceId, {
includeDisabled: true,
sortByIconOrder: false,
});
for (const cluster of workspaceClusters) {
this.removeById(cluster.id);
}
}
@action
protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) {
protected fromStore({ activeClusterId, clusters = [], clusterOwners = [] }: ClusterStoreModel = {}) {
const currentClusters = this.clusters.toJS();
const newClusters = new Map<ClusterId, Cluster>();
const removedClusters = new Map<ClusterId, Cluster>();
const erroredClusterModels: ClusterModel[] = [];
const clusterOwnersMap = new Map<ClusterId, LensExtensionId>(clusterOwners);
// update new clusters
for (const clusterModel of clusters) {
let cluster = currentClusters.get(clusterModel.id);
if (currentClusters.has(clusterModel.id)) {
const cluster = currentClusters.get(clusterModel.id);
if (cluster) {
cluster.updateModel(clusterModel);
newClusters.set(clusterModel.id, cluster);
} else {
cluster = new Cluster(clusterModel);
try {
newClusters.set(clusterModel.id, new Cluster(clusterModel));
} catch (error) {
const { preferences, contextName: context, kubeConfigPath: kubeconfig } = clusterModel;
const clusterName = preferences?.clusterName || context;
if (!cluster.isManaged && cluster.apiUrl) {
cluster.enabled = true;
logger.error(`[CLUSTER-STORE]: Failed to load kubeconfig for the cluster '${clusterName}'.`, { error, context, kubeconfig });
broadcastMessage(InvalidKubeconfigChannel, clusterModel.id);
erroredClusterModels.push(clusterModel);
}
}
newClusters.set(clusterModel.id, cluster);
}
// update removed clusters
@ -343,15 +415,17 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
});
this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null;
this.activeClusterId = clusterOwnersMap.get(activeClusterId) ? null : activeClusterId;
this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters);
this.erroredClusterModels.replace(erroredClusterModels);
this.clusterManagingInfo.replace(clusterOwnersMap);
}
toJSON(): ClusterStoreModel {
return toJS({
activeCluster: this.activeCluster,
clusters: this.clustersList.map(cluster => cluster.toJSON()),
activeClusterId: this.activeClusterId,
clusters: this.clustersList.map(cluster => cluster.toJSON()).concat(this.erroredClusterModels),
}, {
recurseEverything: true
});

View File

View File

@ -44,7 +44,7 @@ export abstract class ClusterFeature {
*
* @param cluster the cluster that the feature is to be installed on
*/
abstract async install(cluster: Cluster): Promise<void>;
abstract install(cluster: Cluster): Promise<void>;
/**
* to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be upgraded. The implementation
@ -52,7 +52,7 @@ export abstract class ClusterFeature {
*
* @param cluster the cluster that the feature is to be upgraded on
*/
abstract async upgrade(cluster: Cluster): Promise<void>;
abstract upgrade(cluster: Cluster): Promise<void>;
/**
* to be implemented in the derived class, this method is typically called by Lens when a user has indicated that this feature is to be uninstalled. The implementation
@ -60,7 +60,7 @@ export abstract class ClusterFeature {
*
* @param cluster the cluster that the feature is to be uninstalled from
*/
abstract async uninstall(cluster: Cluster): Promise<void>;
abstract uninstall(cluster: Cluster): Promise<void>;
/**
* to be implemented in the derived class, this method is called periodically by Lens to determine details about the feature's current status. The implementation
@ -72,7 +72,7 @@ export abstract class ClusterFeature {
*
* @return a promise, resolved with the updated ClusterFeatureStatus
*/
abstract async updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>;
abstract updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>;
/**
* this is a helper method that conveniently applies kubernetes resources to the cluster.

View File

@ -18,14 +18,14 @@ export class ClusterStore extends Singleton {
* Active cluster id
*/
get activeClusterId(): string {
return internalClusterStore.activeCluster;
return internalClusterStore.activeClusterId;
}
/**
* Set active cluster id
*/
set activeClusterId(id : ClusterId) {
internalClusterStore.activeCluster = id;
internalClusterStore.activeClusterId = id;
}
/**

View File

@ -4,7 +4,7 @@ import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store";
import { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars";
import { broadcastMessage, InvalidKubeconfigChannel } from "../common/ipc";
import { broadcastMessage } from "../common/ipc";
import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { Kubectl } from "./kubectl";
@ -38,7 +38,6 @@ export type ClusterRefreshOptions = {
export interface ClusterState {
initialized: boolean;
enabled: boolean;
apiUrl: string;
online: boolean;
disconnected: boolean;
@ -71,12 +70,6 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal
*/
public contextHandler: ContextHandler;
/**
* Owner reference
*
* If extension sets this it needs to also mark cluster as enabled on activate (or when added to a store)
*/
public ownerRef: string;
protected kubeconfigManager: KubeconfigManager;
protected eventDisposers: Function[] = [];
protected activated = false;
@ -86,7 +79,7 @@ export class Cluster implements ClusterModel, ClusterState {
whenReady = when(() => this.ready);
/**
* Is cluster object initializinng on-going
* Is cluster object initializing on-going
*
* @observable
*/
@ -129,12 +122,6 @@ export class Cluster implements ClusterModel, ClusterState {
* @internal
*/
@observable kubeProxyUrl: string; // lens-proxy to kube-api url
/**
* Is cluster instance enabled (disabled clusters are currently hidden)
*
* @observable
*/
@observable enabled = false; // only enabled clusters are visible to users
/**
* Is cluster online
*
@ -258,23 +245,20 @@ export class Cluster implements ClusterModel, ClusterState {
constructor(model: ClusterModel) {
this.updateModel(model);
try {
const kubeconfig = this.getKubeconfig();
const kubeconfig = this.getKubeconfig();
validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
} catch(err) {
logger.error(err);
logger.error(`[CLUSTER] Failed to load kubeconfig for the cluster '${this.name || this.contextName}' (context: ${this.contextName}, kubeconfig: ${this.kubeConfigPath}).`);
broadcastMessage(InvalidKubeconfigChannel, model.id);
}
validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
}
kubeConfig?: string;
/**
* Is cluster managed by an extension
*
* @deprecated use `clusterStore.isClusterManaged(clusterId)`
*/
get isManaged(): boolean {
return !!this.ownerRef;
return false;
}
/**
@ -612,7 +596,6 @@ export class Cluster implements ClusterModel, ClusterState {
workspace: this.workspace,
preferences: this.preferences,
metadata: this.metadata,
ownerRef: this.ownerRef,
accessibleNamespaces: this.accessibleNamespaces,
};
@ -627,7 +610,6 @@ export class Cluster implements ClusterModel, ClusterState {
getState(): ClusterState {
const state: ClusterState = {
initialized: this.initialized,
enabled: this.enabled,
apiUrl: this.apiUrl,
online: this.online,
ready: this.ready,

View File

@ -273,7 +273,7 @@ export class Kubectl {
logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`);
return new Promise((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
const stream = customRequest({
url: this.url,
gzip: true,

View File

@ -183,7 +183,7 @@ export class LensBinary {
throw(error);
});
return new Promise((resolve, reject) => {
return new Promise<void>((resolve, reject) => {
file.on("close", () => {
this.logger.debug(`${this.originalBinaryName} binary download closed`);
if (!this.tarPath) fs.chmod(binaryPath, 0o755, (err) => {

View File

@ -0,0 +1,12 @@
import { migration } from "../migration-wrapper";
export default migration({
version: "4.2.0",
run(store) {
const activeCluster = store.get("activeCluster");
if (activeCluster) {
store.set("activeClusterId", activeCluster);
}
}
});

View File

@ -7,6 +7,7 @@ import version260Beta3 from "./2.6.0-beta.3";
import version270Beta0 from "./2.7.0-beta.0";
import version270Beta1 from "./2.7.0-beta.1";
import version360Beta1 from "./3.6.0-beta.1";
import version420 from "./4.2.0";
import snap from "./snap";
export default {
@ -17,5 +18,6 @@ export default {
...version270Beta0,
...version270Beta1,
...version360Beta1,
...version420,
...snap
};

View File

@ -56,7 +56,6 @@ export class WorkspaceClusterStore extends ItemStore<ClusterItem> {
() => (
clusterStore
.getByWorkspaceId(this.workspaceId)
.filter(cluster => cluster.enabled)
.map(cluster => new ClusterItem(cluster))
)
);

View File

@ -108,8 +108,8 @@ export class ClustersMenu extends React.Component<Props> {
render() {
const { className } = this.props;
const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId);
const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled);
const activeClusterId = clusterStore.activeCluster;
const clusters = clusterStore.getByWorkspaceId(workspace.id);
const activeClusterId = clusterStore.activeClusterId;
return (
<div className={cssNames("ClustersMenu flex column", className)}>
@ -192,7 +192,7 @@ export class ClustersMenu extends React.Component<Props> {
@observer
export class ChooseCluster extends React.Component {
@computed get options() {
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId).filter(cluster => cluster.enabled);
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
const options = clusters.map((cluster) => {
return { value: cluster.id, label: cluster.name };
});

View File

@ -17,7 +17,7 @@ export class ReadableWebToNodeStream extends Readable {
* Default web API stream reader
* https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader
*/
private reader: ReadableStreamReader;
private reader: ReadableStreamReader<any>;
private pendingRead: Promise<any>;
/**

View File

@ -4052,6 +4052,11 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
create-require@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cross-fetch@^3.0.4:
version "3.0.6"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c"
@ -13460,12 +13465,13 @@ ts-loader@^7.0.5:
micromatch "^4.0.0"
semver "^6.0.0"
ts-node@^8.10.2:
version "8.10.2"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.10.2.tgz#eee03764633b1234ddd37f8db9ec10b75ec7fb8d"
integrity sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==
ts-node@^9.1.1:
version "9.1.1"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-9.1.1.tgz#51a9a450a3e959401bda5f004a72d54b936d376d"
integrity sha512-hPlt7ZACERQGf03M253ytLY3dHbGNGrAq9qIHWUY9XHYl1z7wYngSr3OQ5xmui8o2AaxsONxIzjafLUiWBo1Fg==
dependencies:
arg "^4.1.0"
create-require "^1.1.0"
diff "^4.0.1"
make-error "^1.1.1"
source-map-support "^0.5.17"
@ -13618,16 +13624,16 @@ typeface-roboto@^0.0.75:
resolved "https://registry.yarnpkg.com/typeface-roboto/-/typeface-roboto-0.0.75.tgz#98d5ba35ec234bbc7172374c8297277099cc712b"
integrity sha512-VrR/IiH00Z1tFP4vDGfwZ1esNqTiDMchBEXYY9kilT6wRGgFoCAlgkEUMHb1E3mB0FsfZhv756IF0+R+SFPfdg==
typescript@4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2"
integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ==
typescript@^4.0.3:
version "4.1.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.2.tgz#6369ef22516fe5e10304aae5a5c4862db55380e9"
integrity sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ==
typescript@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3"
integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==
uglify-js@^3.1.4:
version "3.9.4"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.9.4.tgz#867402377e043c1fc7b102253a22b64e5862401b"