From 41f1cdff653025dda71d76596309dad5eb7d95a0 Mon Sep 17 00:00:00 2001 From: Rafed Ramzi <68276604+rafedramzi@users.noreply.github.com> Date: Thu, 18 Feb 2021 21:42:23 +0700 Subject: [PATCH 01/29] Fix Documentation: fix broken link in getting-started page (#2155) Signed-off-by: Rafed Ramzi --- docs/getting-started/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 704e59d97b..bfb990378d 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -5,7 +5,7 @@ Lens is lightweight and simple to install. You'll be up and running in just a fe ## System Requirements -Review the [System Requirements](/supporting/requirements/) to check if your computer configuration is supported. +Review the [System Requirements](../supporting/requirements.md) to check if your computer configuration is supported. ## macOS From 06c131f8605ed7bf197c7d3df6b2d8f15847ded2 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Thu, 18 Feb 2021 19:20:58 +0200 Subject: [PATCH 02/29] Fix duplicate cluster iframes (#2190) Signed-off-by: Jari Kolehmainen --- src/renderer/components/cluster-manager/lens-views.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/cluster-manager/lens-views.ts b/src/renderer/components/cluster-manager/lens-views.ts index 6e9ba6df6a..a3a92e4f64 100644 --- a/src/renderer/components/cluster-manager/lens-views.ts +++ b/src/renderer/components/cluster-manager/lens-views.ts @@ -25,8 +25,6 @@ export async function initView(clusterId: ClusterId) { return; } - logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`); - await cluster.whenReady; logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`); const parentElem = document.getElementById("lens-views"); const iframe = document.createElement("iframe"); @@ -39,6 +37,8 @@ export async function initView(clusterId: ClusterId) { }, { once: true }); lensViews.set(clusterId, { clusterId, view: iframe }); parentElem.appendChild(iframe); + logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`); + await cluster.whenReady; await autoCleanOnRemove(clusterId, iframe); } From e87bb850fb6525967fddce6218a0f77396f47970 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Thu, 18 Feb 2021 19:30:18 +0200 Subject: [PATCH 03/29] Bump master version to v4.2.0-alpha.0 (#2184) Signed-off-by: Jari Kolehmainen --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 793ca9de47..e575e48520 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.1.0", + "version": "4.2.0-alpha.0", "main": "static/build/main.js", "copyright": "Β© 2020, Mirantis, Inc.", "license": "MIT", From 82d8181938c36ec64d6736cb7b62d91d203d9a7e Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 19 Feb 2021 14:21:07 +0200 Subject: [PATCH 04/29] Fix: proper sorting resources by age column (#2182) * - fix: proper sorting for resource age column - use Date.now() instead of new Date().getTime() (#2141 follow-up) Signed-off-by: Roman * fix: proper bar-chart tooltip callback Signed-off-by: Roman * fix: sorting in cronjobs, more Date.now() replacements Signed-off-by: Roman --- src/renderer/api/endpoints/helm-releases.api.ts | 3 +-- src/renderer/api/endpoints/persistent-volume.api.ts | 8 +++++--- src/renderer/api/kube-object.ts | 2 +- .../components/+config-limit-ranges/limit-ranges.tsx | 2 +- src/renderer/components/+config-maps/config-maps.tsx | 2 +- .../+config-resource-quotas/resource-quotas.tsx | 2 +- src/renderer/components/+config-secrets/secrets.tsx | 2 +- .../components/+custom-resources/crd-resources.tsx | 2 +- src/renderer/components/+namespaces/namespaces.tsx | 2 +- .../components/+network-endpoints/endpoints.tsx | 2 +- .../components/+network-ingresses/ingresses.tsx | 2 +- .../components/+network-policies/network-policies.tsx | 2 +- .../components/+network-services/services.tsx | 2 +- src/renderer/components/+nodes/nodes.tsx | 2 +- .../+pod-security-policies/pod-security-policies.tsx | 2 +- .../components/+storage-classes/storage-classes.tsx | 2 +- .../+storage-volume-claims/volume-claims.tsx | 2 +- src/renderer/components/+storage-volumes/volumes.tsx | 4 ++-- .../+user-management-roles-bindings/role-bindings.tsx | 2 +- .../components/+user-management-roles/roles.tsx | 2 +- .../service-accounts.tsx | 2 +- .../components/+workloads-cronjobs/cronjobs.tsx | 2 +- .../components/+workloads-daemonsets/daemonsets.tsx | 2 +- .../components/+workloads-deployments/deployments.tsx | 2 +- src/renderer/components/+workloads-jobs/jobs.tsx | 2 +- src/renderer/components/+workloads-pods/pods.tsx | 2 +- .../components/+workloads-replicasets/replicasets.tsx | 2 +- .../+workloads-statefulsets/statefulsets.tsx | 2 +- src/renderer/components/chart/bar-chart.tsx | 11 ++++++----- .../kube-object-status-icon.tsx | 2 +- src/renderer/utils/__tests__/formatDuration.test.ts | 4 ++-- 31 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/renderer/api/endpoints/helm-releases.api.ts b/src/renderer/api/endpoints/helm-releases.api.ts index 6831c508cb..fb3eae1a79 100644 --- a/src/renderer/api/endpoints/helm-releases.api.ts +++ b/src/renderer/api/endpoints/helm-releases.api.ts @@ -198,10 +198,9 @@ export class HelmRelease implements ItemObject { } getUpdated(humanize = true, compact = true) { - const now = new Date().getTime(); const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date() const updatedDate = new Date(updated).getTime(); - const diff = now - updatedDate; + const diff = Date.now() - updatedDate; if (humanize) { return formatDuration(diff, compact); diff --git a/src/renderer/api/endpoints/persistent-volume.api.ts b/src/renderer/api/endpoints/persistent-volume.api.ts index dd5dbb616e..27a0279a0b 100644 --- a/src/renderer/api/endpoints/persistent-volume.api.ts +++ b/src/renderer/api/endpoints/persistent-volume.api.ts @@ -63,10 +63,12 @@ export class PersistentVolume extends KubeObject { return this.status.phase || "-"; } - getClaimRefName() { - const { claimRef } = this.spec; + getStorageClass(): string { + return this.spec.storageClassName; + } - return claimRef ? claimRef.name : ""; + getClaimRefName(): string { + return this.spec.claimRef?.name ?? ""; } } diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index 4ffd464d91..7d0c34de33 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -123,7 +123,7 @@ export class KubeObject implements ItemObject { } getTimeDiffFromNow(): number { - return new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime(); + return Date.now() - new Date(this.metadata.creationTimestamp).getTime(); } getAge(humanize = true, compact = true, fromNow = false): string | number { diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx index a3b111929a..715bc4430b 100644 --- a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx @@ -30,7 +30,7 @@ export class LimitRanges extends React.Component { sortingCallbacks={{ [columnId.name]: (item: LimitRange) => item.getName(), [columnId.namespace]: (item: LimitRange) => item.getNs(), - [columnId.age]: (item: LimitRange) => item.metadata.creationTimestamp, + [columnId.age]: (item: LimitRange) => item.getTimeDiffFromNow(), }} searchFilters={[ (item: LimitRange) => item.getName(), diff --git a/src/renderer/components/+config-maps/config-maps.tsx b/src/renderer/components/+config-maps/config-maps.tsx index 532532bf53..4d68319147 100644 --- a/src/renderer/components/+config-maps/config-maps.tsx +++ b/src/renderer/components/+config-maps/config-maps.tsx @@ -31,7 +31,7 @@ export class ConfigMaps extends React.Component { [columnId.name]: (item: ConfigMap) => item.getName(), [columnId.namespace]: (item: ConfigMap) => item.getNs(), [columnId.keys]: (item: ConfigMap) => item.getKeys(), - [columnId.age]: (item: ConfigMap) => item.metadata.creationTimestamp, + [columnId.age]: (item: ConfigMap) => item.getTimeDiffFromNow(), }} searchFilters={[ (item: ConfigMap) => item.getSearchFields(), diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx index 5adfef2edc..3414b74d6a 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx +++ b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx @@ -31,7 +31,7 @@ export class ResourceQuotas extends React.Component { sortingCallbacks={{ [columnId.name]: (item: ResourceQuota) => item.getName(), [columnId.namespace]: (item: ResourceQuota) => item.getNs(), - [columnId.age]: (item: ResourceQuota) => item.metadata.creationTimestamp, + [columnId.age]: (item: ResourceQuota) => item.getTimeDiffFromNow(), }} searchFilters={[ (item: ResourceQuota) => item.getSearchFields(), diff --git a/src/renderer/components/+config-secrets/secrets.tsx b/src/renderer/components/+config-secrets/secrets.tsx index 60158cfb55..e660db3a09 100644 --- a/src/renderer/components/+config-secrets/secrets.tsx +++ b/src/renderer/components/+config-secrets/secrets.tsx @@ -38,7 +38,7 @@ export class Secrets extends React.Component { [columnId.labels]: (item: Secret) => item.getLabels(), [columnId.keys]: (item: Secret) => item.getKeys(), [columnId.type]: (item: Secret) => item.type, - [columnId.age]: (item: Secret) => item.metadata.creationTimestamp, + [columnId.age]: (item: Secret) => item.getTimeDiffFromNow(), }} searchFilters={[ (item: Secret) => item.getSearchFields(), diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index afc6dd87c2..c60a93f063 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -57,7 +57,7 @@ export class CrdResources extends React.Component { const sortingCallbacks: { [sortBy: string]: TableSortCallback } = { [columnId.name]: (item: KubeObject) => item.getName(), [columnId.namespace]: (item: KubeObject) => item.getNs(), - [columnId.age]: (item: KubeObject) => item.metadata.creationTimestamp, + [columnId.age]: (item: KubeObject) => item.getTimeDiffFromNow(), }; extraColumns.forEach(column => { diff --git a/src/renderer/components/+namespaces/namespaces.tsx b/src/renderer/components/+namespaces/namespaces.tsx index 3972f3d180..1aeec0fb44 100644 --- a/src/renderer/components/+namespaces/namespaces.tsx +++ b/src/renderer/components/+namespaces/namespaces.tsx @@ -33,7 +33,7 @@ export class Namespaces extends React.Component { sortingCallbacks={{ [columnId.name]: (ns: Namespace) => ns.getName(), [columnId.labels]: (ns: Namespace) => ns.getLabels(), - [columnId.age]: (ns: Namespace) => ns.metadata.creationTimestamp, + [columnId.age]: (ns: Namespace) => ns.getTimeDiffFromNow(), [columnId.status]: (ns: Namespace) => ns.getStatus(), }} searchFilters={[ diff --git a/src/renderer/components/+network-endpoints/endpoints.tsx b/src/renderer/components/+network-endpoints/endpoints.tsx index ce87c14a4a..ae37fd10ae 100644 --- a/src/renderer/components/+network-endpoints/endpoints.tsx +++ b/src/renderer/components/+network-endpoints/endpoints.tsx @@ -30,7 +30,7 @@ export class Endpoints extends React.Component { sortingCallbacks={{ [columnId.name]: (endpoint: Endpoint) => endpoint.getName(), [columnId.namespace]: (endpoint: Endpoint) => endpoint.getNs(), - [columnId.age]: (endpoint: Endpoint) => endpoint.metadata.creationTimestamp, + [columnId.age]: (endpoint: Endpoint) => endpoint.getTimeDiffFromNow(), }} searchFilters={[ (endpoint: Endpoint) => endpoint.getSearchFields() diff --git a/src/renderer/components/+network-ingresses/ingresses.tsx b/src/renderer/components/+network-ingresses/ingresses.tsx index 945f2b8f0a..d934127bca 100644 --- a/src/renderer/components/+network-ingresses/ingresses.tsx +++ b/src/renderer/components/+network-ingresses/ingresses.tsx @@ -31,7 +31,7 @@ export class Ingresses extends React.Component { sortingCallbacks={{ [columnId.name]: (ingress: Ingress) => ingress.getName(), [columnId.namespace]: (ingress: Ingress) => ingress.getNs(), - [columnId.age]: (ingress: Ingress) => ingress.metadata.creationTimestamp, + [columnId.age]: (ingress: Ingress) => ingress.getTimeDiffFromNow(), }} searchFilters={[ (ingress: Ingress) => ingress.getSearchFields(), diff --git a/src/renderer/components/+network-policies/network-policies.tsx b/src/renderer/components/+network-policies/network-policies.tsx index 6899c14558..62b298edcd 100644 --- a/src/renderer/components/+network-policies/network-policies.tsx +++ b/src/renderer/components/+network-policies/network-policies.tsx @@ -30,7 +30,7 @@ export class NetworkPolicies extends React.Component { sortingCallbacks={{ [columnId.name]: (item: NetworkPolicy) => item.getName(), [columnId.namespace]: (item: NetworkPolicy) => item.getNs(), - [columnId.age]: (item: NetworkPolicy) => item.metadata.creationTimestamp, + [columnId.age]: (item: NetworkPolicy) => item.getTimeDiffFromNow(), }} searchFilters={[ (item: NetworkPolicy) => item.getSearchFields(), diff --git a/src/renderer/components/+network-services/services.tsx b/src/renderer/components/+network-services/services.tsx index 740e0bfdf1..c250f7736c 100644 --- a/src/renderer/components/+network-services/services.tsx +++ b/src/renderer/components/+network-services/services.tsx @@ -40,7 +40,7 @@ export class Services extends React.Component { [columnId.ports]: (service: Service) => (service.spec.ports || []).map(({ port }) => port)[0], [columnId.clusterIp]: (service: Service) => service.getClusterIp(), [columnId.type]: (service: Service) => service.getType(), - [columnId.age]: (service: Service) => service.metadata.creationTimestamp, + [columnId.age]: (service: Service) => service.getTimeDiffFromNow(), [columnId.status]: (service: Service) => service.getStatus(), }} searchFilters={[ diff --git a/src/renderer/components/+nodes/nodes.tsx b/src/renderer/components/+nodes/nodes.tsx index 32da6a13db..6237bf4056 100644 --- a/src/renderer/components/+nodes/nodes.tsx +++ b/src/renderer/components/+nodes/nodes.tsx @@ -150,7 +150,7 @@ export class Nodes extends React.Component { [columnId.conditions]: (node: Node) => node.getNodeConditionText(), [columnId.taints]: (node: Node) => node.getTaints().length, [columnId.roles]: (node: Node) => node.getRoleLabels(), - [columnId.age]: (node: Node) => node.metadata.creationTimestamp, + [columnId.age]: (node: Node) => node.getTimeDiffFromNow(), [columnId.version]: (node: Node) => node.getKubeletVersion(), }} searchFilters={[ diff --git a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx index a91e0114d6..affd9f13eb 100644 --- a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx +++ b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx @@ -28,7 +28,7 @@ export class PodSecurityPolicies extends React.Component { [columnId.name]: (item: PodSecurityPolicy) => item.getName(), [columnId.volumes]: (item: PodSecurityPolicy) => item.getVolumes(), [columnId.privileged]: (item: PodSecurityPolicy) => +item.isPrivileged(), - [columnId.age]: (item: PodSecurityPolicy) => item.metadata.creationTimestamp, + [columnId.age]: (item: PodSecurityPolicy) => item.getTimeDiffFromNow(), }} searchFilters={[ (item: PodSecurityPolicy) => item.getSearchFields(), diff --git a/src/renderer/components/+storage-classes/storage-classes.tsx b/src/renderer/components/+storage-classes/storage-classes.tsx index 1a8ed346fd..f4f0432afd 100644 --- a/src/renderer/components/+storage-classes/storage-classes.tsx +++ b/src/renderer/components/+storage-classes/storage-classes.tsx @@ -31,7 +31,7 @@ export class StorageClasses extends React.Component { store={storageClassStore} isClusterScoped sortingCallbacks={{ [columnId.name]: (item: StorageClass) => item.getName(), - [columnId.age]: (item: StorageClass) => item.metadata.creationTimestamp, + [columnId.age]: (item: StorageClass) => item.getTimeDiffFromNow(), [columnId.provisioner]: (item: StorageClass) => item.provisioner, [columnId.reclaimPolicy]: (item: StorageClass) => item.reclaimPolicy, }} diff --git a/src/renderer/components/+storage-volume-claims/volume-claims.tsx b/src/renderer/components/+storage-volume-claims/volume-claims.tsx index e93529b8d2..8a69f6ee60 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claims.tsx +++ b/src/renderer/components/+storage-volume-claims/volume-claims.tsx @@ -43,7 +43,7 @@ export class PersistentVolumeClaims extends React.Component { [columnId.status]: (pvc: PersistentVolumeClaim) => pvc.getStatus(), [columnId.size]: (pvc: PersistentVolumeClaim) => unitsToBytes(pvc.getStorage()), [columnId.storageClass]: (pvc: PersistentVolumeClaim) => pvc.spec.storageClassName, - [columnId.age]: (pvc: PersistentVolumeClaim) => pvc.metadata.creationTimestamp, + [columnId.age]: (pvc: PersistentVolumeClaim) => pvc.getTimeDiffFromNow(), }} searchFilters={[ (item: PersistentVolumeClaim) => item.getSearchFields(), diff --git a/src/renderer/components/+storage-volumes/volumes.tsx b/src/renderer/components/+storage-volumes/volumes.tsx index 6822e3d4f7..55a9eb753e 100644 --- a/src/renderer/components/+storage-volumes/volumes.tsx +++ b/src/renderer/components/+storage-volumes/volumes.tsx @@ -34,10 +34,10 @@ export class PersistentVolumes extends React.Component { store={volumesStore} isClusterScoped sortingCallbacks={{ [columnId.name]: (item: PersistentVolume) => item.getName(), - [columnId.storageClass]: (item: PersistentVolume) => item.spec.storageClassName, + [columnId.storageClass]: (item: PersistentVolume) => item.getStorageClass(), [columnId.capacity]: (item: PersistentVolume) => item.getCapacity(true), [columnId.status]: (item: PersistentVolume) => item.getStatus(), - [columnId.age]: (item: PersistentVolume) => item.metadata.creationTimestamp, + [columnId.age]: (item: PersistentVolume) => item.getTimeDiffFromNow(), }} searchFilters={[ (item: PersistentVolume) => item.getSearchFields(), diff --git a/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx b/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx index f55e781e0e..1cb89b8f9f 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx +++ b/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx @@ -33,7 +33,7 @@ export class RoleBindings extends React.Component { [columnId.name]: (binding: RoleBinding) => binding.getName(), [columnId.namespace]: (binding: RoleBinding) => binding.getNs(), [columnId.bindings]: (binding: RoleBinding) => binding.getSubjectNames(), - [columnId.age]: (binding: RoleBinding) => binding.metadata.creationTimestamp, + [columnId.age]: (binding: RoleBinding) => binding.getTimeDiffFromNow(), }} searchFilters={[ (binding: RoleBinding) => binding.getSearchFields(), diff --git a/src/renderer/components/+user-management-roles/roles.tsx b/src/renderer/components/+user-management-roles/roles.tsx index a990cbfa7e..8d916c9d42 100644 --- a/src/renderer/components/+user-management-roles/roles.tsx +++ b/src/renderer/components/+user-management-roles/roles.tsx @@ -32,7 +32,7 @@ export class Roles extends React.Component { sortingCallbacks={{ [columnId.name]: (role: Role) => role.getName(), [columnId.namespace]: (role: Role) => role.getNs(), - [columnId.age]: (role: Role) => role.metadata.creationTimestamp, + [columnId.age]: (role: Role) => role.getTimeDiffFromNow(), }} searchFilters={[ (role: Role) => role.getSearchFields(), diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx b/src/renderer/components/+user-management-service-accounts/service-accounts.tsx index 4ea78904c0..34b3fc8d0a 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx +++ b/src/renderer/components/+user-management-service-accounts/service-accounts.tsx @@ -36,7 +36,7 @@ export class ServiceAccounts extends React.Component { sortingCallbacks={{ [columnId.name]: (account: ServiceAccount) => account.getName(), [columnId.namespace]: (account: ServiceAccount) => account.getNs(), - [columnId.age]: (account: ServiceAccount) => account.metadata.creationTimestamp, + [columnId.age]: (account: ServiceAccount) => account.getTimeDiffFromNow(), }} searchFilters={[ (account: ServiceAccount) => account.getSearchFields(), diff --git a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx index 08b84c0671..7c06cfbfdc 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx +++ b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx @@ -46,7 +46,7 @@ export class CronJobs extends React.Component { [columnId.suspend]: (cronJob: CronJob) => cronJob.getSuspendFlag(), [columnId.active]: (cronJob: CronJob) => cronJobStore.getActiveJobsNum(cronJob), [columnId.lastSchedule]: (cronJob: CronJob) => cronJob.getLastScheduleTime(), - [columnId.age]: (cronJob: CronJob) => cronJob.metadata.creationTimestamp, + [columnId.age]: (cronJob: CronJob) => cronJob.getTimeDiffFromNow(), }} searchFilters={[ (cronJob: CronJob) => cronJob.getSearchFields(), diff --git a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx b/src/renderer/components/+workloads-daemonsets/daemonsets.tsx index e2d1e30e17..866d561036 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx +++ b/src/renderer/components/+workloads-daemonsets/daemonsets.tsx @@ -47,7 +47,7 @@ export class DaemonSets extends React.Component { [columnId.name]: (daemonSet: DaemonSet) => daemonSet.getName(), [columnId.namespace]: (daemonSet: DaemonSet) => daemonSet.getNs(), [columnId.pods]: (daemonSet: DaemonSet) => this.getPodsLength(daemonSet), - [columnId.age]: (daemonSet: DaemonSet) => daemonSet.metadata.creationTimestamp, + [columnId.age]: (daemonSet: DaemonSet) => daemonSet.getTimeDiffFromNow(), }} searchFilters={[ (daemonSet: DaemonSet) => daemonSet.getSearchFields(), diff --git a/src/renderer/components/+workloads-deployments/deployments.tsx b/src/renderer/components/+workloads-deployments/deployments.tsx index 0147c238b9..18b9f7f29c 100644 --- a/src/renderer/components/+workloads-deployments/deployments.tsx +++ b/src/renderer/components/+workloads-deployments/deployments.tsx @@ -64,7 +64,7 @@ export class Deployments extends React.Component { [columnId.name]: (deployment: Deployment) => deployment.getName(), [columnId.namespace]: (deployment: Deployment) => deployment.getNs(), [columnId.replicas]: (deployment: Deployment) => deployment.getReplicas(), - [columnId.age]: (deployment: Deployment) => deployment.metadata.creationTimestamp, + [columnId.age]: (deployment: Deployment) => deployment.getTimeDiffFromNow(), [columnId.condition]: (deployment: Deployment) => deployment.getConditionsText(), }} searchFilters={[ diff --git a/src/renderer/components/+workloads-jobs/jobs.tsx b/src/renderer/components/+workloads-jobs/jobs.tsx index 6301c287d0..5961c17376 100644 --- a/src/renderer/components/+workloads-jobs/jobs.tsx +++ b/src/renderer/components/+workloads-jobs/jobs.tsx @@ -36,7 +36,7 @@ export class Jobs extends React.Component { [columnId.name]: (job: Job) => job.getName(), [columnId.namespace]: (job: Job) => job.getNs(), [columnId.conditions]: (job: Job) => job.getCondition() != null ? job.getCondition().type : "", - [columnId.age]: (job: Job) => job.metadata.creationTimestamp, + [columnId.age]: (job: Job) => job.getTimeDiffFromNow(), }} searchFilters={[ (job: Job) => job.getSearchFields(), diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx index a59c9d79d2..fc2a0930af 100644 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ b/src/renderer/components/+workloads-pods/pods.tsx @@ -83,7 +83,7 @@ export class Pods extends React.Component { [columnId.owners]: (pod: Pod) => pod.getOwnerRefs().map(ref => ref.kind), [columnId.qos]: (pod: Pod) => pod.getQosClass(), [columnId.node]: (pod: Pod) => pod.getNodeName(), - [columnId.age]: (pod: Pod) => pod.metadata.creationTimestamp, + [columnId.age]: (pod: Pod) => pod.getTimeDiffFromNow(), [columnId.status]: (pod: Pod) => pod.getStatusMessage(), }} searchFilters={[ diff --git a/src/renderer/components/+workloads-replicasets/replicasets.tsx b/src/renderer/components/+workloads-replicasets/replicasets.tsx index fa6ee5cef4..fc746f1a0d 100644 --- a/src/renderer/components/+workloads-replicasets/replicasets.tsx +++ b/src/renderer/components/+workloads-replicasets/replicasets.tsx @@ -40,7 +40,7 @@ export class ReplicaSets extends React.Component { [columnId.desired]: (replicaSet: ReplicaSet) => replicaSet.getDesired(), [columnId.current]: (replicaSet: ReplicaSet) => replicaSet.getCurrent(), [columnId.ready]: (replicaSet: ReplicaSet) => replicaSet.getReady(), - [columnId.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp, + [columnId.age]: (replicaSet: ReplicaSet) => replicaSet.getTimeDiffFromNow(), }} searchFilters={[ (replicaSet: ReplicaSet) => replicaSet.getSearchFields(), diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx index 7c91c9905c..566bf7ed88 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx @@ -46,7 +46,7 @@ export class StatefulSets extends React.Component { sortingCallbacks={{ [columnId.name]: (statefulSet: StatefulSet) => statefulSet.getName(), [columnId.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(), - [columnId.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp, + [columnId.age]: (statefulSet: StatefulSet) => statefulSet.getTimeDiffFromNow(), [columnId.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(), }} searchFilters={[ diff --git a/src/renderer/components/chart/bar-chart.tsx b/src/renderer/components/chart/bar-chart.tsx index 5c3e134664..74ee203d4c 100644 --- a/src/renderer/components/chart/bar-chart.tsx +++ b/src/renderer/components/chart/bar-chart.tsx @@ -3,7 +3,7 @@ import merge from "lodash/merge"; import moment from "moment"; import Color from "color"; import { observer } from "mobx-react"; -import { ChartData, ChartOptions, ChartPoint, Scriptable } from "chart.js"; +import { ChartData, ChartOptions, ChartPoint, ChartTooltipItem, Scriptable } from "chart.js"; import { Chart, ChartKind, ChartProps } from "./chart"; import { bytesToUnits, cssNames } from "../../utils"; import { ZebraStripes } from "./zebra-stripes.plugin"; @@ -115,12 +115,13 @@ export class BarChart extends React.Component { mode: "index", position: "cursor", callbacks: { - title: tooltipItems => { - const now = new Date().getTime(); + title([tooltip]: ChartTooltipItem[]) { + const xLabel = tooltip?.xLabel; + const skipLabel = xLabel == null || new Date(xLabel).getTime() > Date.now(); - if (new Date(tooltipItems[0].xLabel).getTime() > now) return ""; + if (skipLabel) return ""; - return `${tooltipItems[0].xLabel}`; + return String(xLabel); }, labelColor: ({ datasetIndex }) => { return { diff --git a/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx b/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx index 2fdb9bff6f..386255d1eb 100644 --- a/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx +++ b/src/renderer/components/kube-object-status-icon/kube-object-status-icon.tsx @@ -48,7 +48,7 @@ export class KubeObjectStatusIcon extends React.Component { getAge(timestamp: string) { if (!timestamp) return ""; - const diff = new Date().getTime() - new Date(timestamp).getTime(); + const diff = Date.now() - new Date(timestamp).getTime(); return formatDuration(diff, true); } diff --git a/src/renderer/utils/__tests__/formatDuration.test.ts b/src/renderer/utils/__tests__/formatDuration.test.ts index 7c19ba93f9..30dc81a6fc 100644 --- a/src/renderer/utils/__tests__/formatDuration.test.ts +++ b/src/renderer/utils/__tests__/formatDuration.test.ts @@ -50,7 +50,7 @@ describe("human format durations", () => { }); test("durations less than 8 years returns years and days", () => { - const timeValue = new Date().getTime() - new Date(moment().subtract(2, "years").subtract(5, "days").subtract(2, "hours").toDate()).getTime(); + const timeValue = Date.now() - new Date(moment().subtract(2, "years").subtract(5, "days").subtract(2, "hours").toDate()).getTime(); const res = formatDuration(timeValue); @@ -58,7 +58,7 @@ describe("human format durations", () => { }); test("durations more than 8 years returns years", () => { - const timeValue = new Date().getTime() - new Date(moment().subtract(9, "years").subtract(5, "days").toDate()).getTime(); + const timeValue = Date.now() - new Date(moment().subtract(9, "years").subtract(5, "days").toDate()).getTime(); const res = formatDuration(timeValue); From 25f48331179bc1b7b7ec1ea1148b56f6d58e7634 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 22 Feb 2021 14:44:21 +0200 Subject: [PATCH 05/29] - fix: don't reset selected "all namespaces" on page reload (#2185) - fix: don't reload namespaces on every page visit / NamespaceSelect.render() Signed-off-by: Roman --- .../+namespaces/namespace-select-filter.tsx | 2 +- .../components/+namespaces/namespace-select.tsx | 1 + .../components/+namespaces/namespace.store.ts | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index 453849e534..85a6299b17 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -56,7 +56,7 @@ export class NamespaceSelectFilter extends React.Component { if (namespace) { namespaceStore.toggleContext(namespace); } else { - namespaceStore.resetContext(); // "All namespaces" clicked, empty list considered as "all" + namespaceStore.toggleAll(false); // "All namespaces" clicked } } diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 207a274d2c..775cace4d3 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -29,6 +29,7 @@ export class NamespaceSelect extends React.Component { disposeOnUnmount(this, [ kubeWatchApi.subscribeStores([namespaceStore], { preload: true, + loadOnce: true, // skip reloading namespaces on every render / page visit }) ]); } diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 1f928fe2f3..ad271d1302 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -5,7 +5,7 @@ import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api"; import { createPageParam } from "../../navigation"; import { apiManager } from "../../api/api-manager"; -const storage = createStorage("context_namespaces", []); +const storage = createStorage("context_namespaces"); export const namespaceUrlParam = createPageParam({ name: "namespaces", @@ -74,11 +74,11 @@ export class NamespaceStore extends KubeObjectStore { @computed private get initialNamespaces(): string[] { const namespaces = new Set(this.allowedNamespaces); - const prevSelected = storage.get().filter(namespace => namespaces.has(namespace)); + const prevSelectedNamespaces = storage.get(); - // return previously saved namespaces from local-storage - if (prevSelected.length > 0) { - return prevSelected; + // return previously saved namespaces from local-storage (if any) + if (prevSelectedNamespaces) { + return prevSelectedNamespaces.filter(namespace => namespaces.has(namespace)); } // otherwise select "default" or first allowed namespace @@ -166,7 +166,7 @@ export class NamespaceStore extends KubeObjectStore { if (showAll) { this.setContext(this.allowedNamespaces); } else { - this.contextNs.clear(); + this.resetContext(); // empty context considered as "All namespaces" } } else { this.toggleAll(!this.hasAllContexts); From b2d4311e94a32b4752bda39e46a3e31afc8fe1a9 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 22 Feb 2021 12:18:48 -0500 Subject: [PATCH 06/29] fix RoleBindings Namespace and Bindings field not displaying the correct data (#2203) Signed-off-by: Sebastian Malton --- .../+user-management-roles-bindings/role-bindings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx b/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx index 1cb89b8f9f..6eddb7eb5a 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx +++ b/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx @@ -50,8 +50,8 @@ export class RoleBindings extends React.Component { renderTableContents={(binding: RoleBinding) => [ binding.getName(), , - binding.getSubjectNames(), binding.getNs() || "-", + binding.getSubjectNames(), binding.getAge(), ]} addRemoveButtons={{ From 9bb9e23d550dce85f03fed9b592667c136612655 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 22 Feb 2021 20:29:26 +0200 Subject: [PATCH 07/29] fix: allow scrolling in main-layout's content area (#2201) Signed-off-by: Roman --- src/renderer/components/layout/main-layout.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/renderer/components/layout/main-layout.scss b/src/renderer/components/layout/main-layout.scss index 92f1173b7a..4e456e357f 100755 --- a/src/renderer/components/layout/main-layout.scss +++ b/src/renderer/components/layout/main-layout.scss @@ -44,6 +44,11 @@ > main { display: contents; + + > * { + grid-area: main; + overflow: auto; + } } footer { From 5559c166d03605f01d9759e6d4e8602389db60e6 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 22 Feb 2021 16:53:34 -0500 Subject: [PATCH 08/29] Fix auto update on quit with newer version (#2128) - Fix auto update on quit with newer version - If the user specifies that Lens should auto update on quit to a specific version, and before they quit the auto-updater finds a newer version. Then disregard the previous request to update - reset broadcast-ed version if failed to broadcast Signed-off-by: Sebastian Malton --- src/main/app-updater.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 5d9509c6e4..ed2bf4250b 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -5,6 +5,8 @@ import { delay } from "../common/utils"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; import { ipcMain } from "electron"; +let installVersion: null | string = null; + function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: UpdateAvailableToBackchannel) { if (arg.doUpdate) { if (arg.now) { @@ -37,6 +39,22 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { autoUpdater .on("update-available", (args: UpdateInfo) => { + if (autoUpdater.autoInstallOnAppQuit) { + // a previous auto-update loop was completed with YES+LATER, check if same version + if (installVersion === args.version) { + // same version, don't broadcast + return; + } + } + + /** + * This should be always set to false here because it is the reasonable + * default. Namely, if a don't auto update to a version that the user + * didn't ask for. + */ + autoUpdater.autoInstallOnAppQuit = false; + installVersion = args.version; + try { const backchannel = `auto-update:${args.version}`; @@ -53,6 +71,7 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { broadcastMessage(UpdateAvailableChannel, backchannel, args); } catch (error) { logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); + installVersion = undefined; } }); From 83d7e4550cbf846ad471711a2cb7a80c9246350c Mon Sep 17 00:00:00 2001 From: Steven Martin Date: Tue, 23 Feb 2021 07:05:44 -0500 Subject: [PATCH 09/29] Fix Heading spelling (#2202) Repositories was misspelled in Managing Helm Repositories Signed-off-by: stevenGravy --- docs/helm/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/helm/README.md b/docs/helm/README.md index b46bead65c..f07bced7f0 100644 --- a/docs/helm/README.md +++ b/docs/helm/README.md @@ -4,7 +4,7 @@ Lens has integration to Helm making it easy to install and manage Helm charts an ![Helm Charts](images/helm-charts.png) -## Managing Helm Reporistories +## Managing Helm Repositories Used Helm repositories are possible to configure in the [Preferences](/getting-started/preferences). Lens app will fetch available Helm repositories from the [Artifact HUB](https://artifacthub.io/) and automatically add `bitnami` repository by default if no other repositories are already configured. If any other repositories are needed to add, those can be added manually via command line. **Note!** Configured Helm repositories are added globally to user's computer, so other processes can see those as well. @@ -18,4 +18,4 @@ Lens will list all charts from configured Helm repositries on Apps section. To i To update a Helm release, you can open the release details and modify the release values and click "Save" button. To upgrade or downgrade the release, click "Upgrade" button in the release details. In the release editor you can select a new chart version and edit the release values if needed and then click "Upgrade" or "Upgrade and Close" button. ## Deleting a Helm Release -To delete existing Helm release open the release details and click trash can icon on the top of the panel. Deletion removes all Kubernetes resources created by the Helm release. **Note!** If the release included any persistent volumes, those are required to remove manually! \ No newline at end of file +To delete existing Helm release open the release details and click trash can icon on the top of the panel. Deletion removes all Kubernetes resources created by the Helm release. **Note!** If the release included any persistent volumes, those are required to remove manually! From 5258aab0d2a318bc6d46849c83675f8a993c9c4f Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 23 Feb 2021 08:13:46 -0500 Subject: [PATCH 10/29] Fix removal of langci from repo (#2204) - Move all strings of the form {`...`} to use just "" - Fix RoleBindingDetails not rendering the name of the role binding Signed-off-by: Sebastian Malton --- .../+apps-helm-charts/helm-chart-details.tsx | 10 +++++----- .../components/+apps-helm-charts/helm-charts.tsx | 2 +- .../+apps-releases/release-details.tsx | 10 +++++----- .../components/+apps-releases/release-menu.tsx | 2 +- .../components/+cluster/cluster-pie-charts.tsx | 6 +++--- .../+config-limit-ranges/limit-range-details.tsx | 6 +++--- .../+config-resource-quotas/add-quota-dialog.tsx | 10 +++++----- .../+config-secrets/add-secret-dialog.tsx | 8 ++++---- .../+config-secrets/secret-details.tsx | 4 ++-- .../+namespaces/add-namespace-dialog.tsx | 2 +- .../components/+namespaces/namespace-details.tsx | 2 +- .../+network-policies/network-policy-details.tsx | 2 +- .../+network-services/service-details.tsx | 4 ++-- .../+network-services/service-port-component.tsx | 2 +- .../+preferences/add-helm-repo-dialog.tsx | 12 ++++++------ .../components/+preferences/preferences.tsx | 2 +- .../+storage-classes/storage-class-details.tsx | 2 +- .../volume-claim-details.tsx | 2 +- .../add-role-binding-dialog.tsx | 6 +++--- .../role-binding-details.tsx | 4 ++-- .../+user-management-roles/add-role-dialog.tsx | 2 +- .../create-service-account-dialog.tsx | 2 +- .../components/+workloads-cronjobs/cronjobs.tsx | 6 +++--- .../+workloads-deployments/deployments.tsx | 4 ++-- .../+workloads-pods/pod-container-port.tsx | 2 +- .../+workloads-replicasets/replicasets.tsx | 2 +- .../+workloads-statefulsets/statefulsets.tsx | 2 +- src/renderer/components/dock/create-resource.tsx | 2 +- src/renderer/components/dock/dock-tab.tsx | 2 +- src/renderer/components/dock/edit-resource.tsx | 4 ++-- src/renderer/components/dock/install-chart.tsx | 16 ++++++++-------- src/renderer/components/dock/log-search.tsx | 4 ++-- src/renderer/components/dock/upgrade-chart.tsx | 4 ++-- .../components/error-boundary/error-boundary.tsx | 2 +- src/renderer/components/menu/menu-actions.tsx | 4 ++-- 35 files changed, 78 insertions(+), 78 deletions(-) diff --git a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx index a33e9d2503..d31efc5438 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-chart-details.tsx @@ -82,9 +82,9 @@ export class HelmChartDetails extends Component {
{selectedChart.getDescription()} -
- + this.quotaName = v.toLowerCase()} className="box grow" @@ -156,7 +156,7 @@ export class AddQuotaDialog extends React.Component { this.namespace = value} @@ -167,14 +167,14 @@ export class AddQuotaDialog extends React.Component { this.quotaInputValue = v} onKeyDown={this.onInputQuota} @@ -183,7 +183,7 @@ export class AddQuotaDialog extends React.Component {
diff --git a/src/renderer/components/+config-secrets/add-secret-dialog.tsx b/src/renderer/components/+config-secrets/add-secret-dialog.tsx index 5071c908bf..2fae17d30d 100644 --- a/src/renderer/components/+config-secrets/add-secret-dialog.tsx +++ b/src/renderer/components/+config-secrets/add-secret-dialog.tsx @@ -133,7 +133,7 @@ export class AddSecretDialog extends React.Component { this.addField(field)} /> @@ -146,7 +146,7 @@ export class AddSecretDialog extends React.Component {
{ multiLine maxRows={5} required={required} className="value" - placeholder={`Value`} + placeholder="Value" value={value} onChange={v => item.value = v} /> { this.name = v} /> diff --git a/src/renderer/components/+config-secrets/secret-details.tsx b/src/renderer/components/+config-secrets/secret-details.tsx index 92a58141ac..ab7bc59e46 100644 --- a/src/renderer/components/+config-secrets/secret-details.tsx +++ b/src/renderer/components/+config-secrets/secret-details.tsx @@ -69,7 +69,7 @@ export class SecretDetails extends React.Component { {!isEmpty(this.data) && ( <> - + { Object.entries(this.data).map(([name, value]) => { const revealSecret = this.revealSecret[name]; @@ -107,7 +107,7 @@ export class SecretDetails extends React.Component { }
diff --git a/src/renderer/components/+network-services/service-port-component.tsx b/src/renderer/components/+network-services/service-port-component.tsx index 6946137bde..77070390e0 100644 --- a/src/renderer/components/+network-services/service-port-component.tsx +++ b/src/renderer/components/+network-services/service-port-component.tsx @@ -37,7 +37,7 @@ export class ServicePortComponent extends React.Component { return (
- this.portForward() }> + this.portForward() }> {port.toString()} {this.waiting && ( diff --git a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx index f2c8388247..a0ede38ece 100644 --- a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx +++ b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx @@ -111,7 +111,7 @@ export class AddHelmRepoDialog extends React.Component { <> this.helmRepo.insecureSkipTlsVerify = v} /> @@ -120,12 +120,12 @@ export class AddHelmRepoDialog extends React.Component { {this.renderFileInput(`Cerificate file`, FileType.CertFile, AddHelmRepoDialog.certExtensions)} this.helmRepo.username = v} /> this.helmRepo.password = v} /> ); @@ -148,13 +148,13 @@ export class AddHelmRepoDialog extends React.Component {
this.helmRepo.name = v} /> this.helmRepo.url = v} /> @@ -162,7 +162,7 @@ export class AddHelmRepoDialog extends React.Component { More diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx index 56e881d9f5..b0442f45b6 100644 --- a/src/renderer/components/+preferences/preferences.tsx +++ b/src/renderer/components/+preferences/preferences.tsx @@ -122,7 +122,7 @@ export class Preferences extends React.Component {

HTTP Proxy

this.httpProxy = v} onBlur={() => preferences.httpsProxy = this.httpProxy} diff --git a/src/renderer/components/+storage-classes/storage-class-details.tsx b/src/renderer/components/+storage-classes/storage-class-details.tsx index 95f09bc6bf..4ee8a197f2 100644 --- a/src/renderer/components/+storage-classes/storage-class-details.tsx +++ b/src/renderer/components/+storage-classes/storage-class-details.tsx @@ -45,7 +45,7 @@ export class StorageClassDetails extends React.Component { )} {parameters && ( <> - + { Object.entries(parameters).map(([name, value]) => ( diff --git a/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx b/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx index ea1ec71778..cbf8a9d4fc 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx +++ b/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx @@ -71,7 +71,7 @@ export class PersistentVolumeClaimDetails extends React.Component { {volumeClaim.getStatus()} - + {volumeClaim.getMatchLabels().map(label => )} diff --git a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx index 85d27243c2..e7a676365c 100644 --- a/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx +++ b/src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx @@ -205,7 +205,7 @@ export class AddRoleBindingDialog extends React.Component { this.bindingName = v} @@ -239,7 +239,7 @@ export class AddRoleBindingDialog extends React.Component { this.roleName = v} diff --git a/src/renderer/components/+user-management-service-accounts/create-service-account-dialog.tsx b/src/renderer/components/+user-management-service-accounts/create-service-account-dialog.tsx index c56888f6f4..e9a27979cf 100644 --- a/src/renderer/components/+user-management-service-accounts/create-service-account-dialog.tsx +++ b/src/renderer/components/+user-management-service-accounts/create-service-account-dialog.tsx @@ -66,7 +66,7 @@ export class CreateServiceAccountDialog extends React.Component { this.name = v.toLowerCase()} /> diff --git a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx index 7c06cfbfdc..44c87cfba3 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx +++ b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx @@ -87,7 +87,7 @@ export function CronJobMenu(props: KubeObjectMenuProps) { return ( <> CronJobTriggerDialog.open(object)}> - + Trigger @@ -106,7 +106,7 @@ export function CronJobMenu(props: KubeObjectMenuProps) { Resume CronJob {object.getName()}?

), })}> - + Resume @@ -124,7 +124,7 @@ export function CronJobMenu(props: KubeObjectMenuProps) { Suspend CronJob {object.getName()}?

), })}> - + Suspend } diff --git a/src/renderer/components/+workloads-deployments/deployments.tsx b/src/renderer/components/+workloads-deployments/deployments.tsx index 18b9f7f29c..a2510327ee 100644 --- a/src/renderer/components/+workloads-deployments/deployments.tsx +++ b/src/renderer/components/+workloads-deployments/deployments.tsx @@ -104,7 +104,7 @@ export function DeploymentMenu(props: KubeObjectMenuProps) { return ( <> DeploymentScaleDialog.open(object)}> - + Scale ConfirmDialog.open({ @@ -126,7 +126,7 @@ export function DeploymentMenu(props: KubeObjectMenuProps) {

), })}> - + Restart
diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx index ce5c27c462..4f2124ec3d 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -43,7 +43,7 @@ export class PodContainerPort extends React.Component { return (
- this.portForward() }> + this.portForward() }> {text} {this.waiting && ( diff --git a/src/renderer/components/+workloads-replicasets/replicasets.tsx b/src/renderer/components/+workloads-replicasets/replicasets.tsx index fc746f1a0d..1caa394df6 100644 --- a/src/renderer/components/+workloads-replicasets/replicasets.tsx +++ b/src/renderer/components/+workloads-replicasets/replicasets.tsx @@ -78,7 +78,7 @@ export function ReplicaSetMenu(props: KubeObjectMenuProps) { return ( <> ReplicaSetScaleDialog.open(object)}> - + Scale diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx index 566bf7ed88..868a6afc45 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx @@ -83,7 +83,7 @@ export function StatefulSetMenu(props: KubeObjectMenuProps) { return ( <> StatefulSetScaleDialog.open(object)}> - + Scale diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx index 100ef6a3ae..8ee859d2cf 100644 --- a/src/renderer/components/dock/create-resource.tsx +++ b/src/renderer/components/dock/create-resource.tsx @@ -77,7 +77,7 @@ export class CreateResource extends React.Component { tabId={tabId} error={error} submit={create} - submitLabel={`Create`} + submitLabel="Create" showNotifications={false} /> { {!pinned && ( )} diff --git a/src/renderer/components/dock/edit-resource.tsx b/src/renderer/components/dock/edit-resource.tsx index e33e379620..104f8f1ea3 100644 --- a/src/renderer/components/dock/edit-resource.tsx +++ b/src/renderer/components/dock/edit-resource.tsx @@ -98,8 +98,8 @@ export class EditResource extends React.Component { tabId={tabId} error={error} submit={save} - submitLabel={`Save`} - submittingMessage={`Applying..`} + submitLabel="Save" + submittingMessage="Applying.." controls={(
Kind: diff --git a/src/renderer/components/dock/install-chart.tsx b/src/renderer/components/dock/install-chart.tsx index b433874a75..4f651f7a3a 100644 --- a/src/renderer/components/dock/install-chart.tsx +++ b/src/renderer/components/dock/install-chart.tsx @@ -125,17 +125,17 @@ export class InstallChart extends Component {
this.showNotes = false} logs={this.releaseDetails.log} @@ -148,7 +148,7 @@ export class InstallChart extends Component { const panelControls = (
Chart - + Version { controls={panelControls} error={this.error} submit={install} - submitLabel={`Install`} - submittingMessage={`Installing...`} + submitLabel="Install" + submittingMessage="Installing..." showSubmitClose={false} /> { /> diff --git a/src/renderer/components/dock/upgrade-chart.tsx b/src/renderer/components/dock/upgrade-chart.tsx index 41ce5d295e..c5253cb5b1 100644 --- a/src/renderer/components/dock/upgrade-chart.tsx +++ b/src/renderer/components/dock/upgrade-chart.tsx @@ -123,8 +123,8 @@ export class UpgradeChart extends React.Component { tabId={tabId} error={error} submit={upgrade} - submitLabel={`Upgrade`} - submittingMessage={`Updating..`} + submitLabel="Upgrade" + submittingMessage="Updating.." controls={controlsAndInfo} /> {
diff --git a/src/renderer/components/menu/menu-actions.tsx b/src/renderer/components/menu/menu-actions.tsx index 4e46935aa4..96a88bdf88 100644 --- a/src/renderer/components/menu/menu-actions.tsx +++ b/src/renderer/components/menu/menu-actions.tsx @@ -106,13 +106,13 @@ export class MenuActions extends React.Component { {children} {updateAction && ( - + Edit )} {removeAction && ( - + Remove )} From 103467d31b3b43cb151bdc235f688a7675f07f42 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Tue, 23 Feb 2021 15:47:40 +0200 Subject: [PATCH 11/29] Display environment variables coming from secret in pod details (#2167) Signed-off-by: Lauri Nevala --- .../+workloads-pods/pod-container-env.tsx | 59 +++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/+workloads-pods/pod-container-env.tsx b/src/renderer/components/+workloads-pods/pod-container-env.tsx index 38af50a457..2e96b142f9 100644 --- a/src/renderer/components/+workloads-pods/pod-container-env.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-env.tsx @@ -30,7 +30,11 @@ export const ContainerEnvironment = observer((props: Props) => { } }); envFrom && envFrom.forEach(item => { - const { configMapRef } = item; + const { configMapRef, secretRef } = item; + + if (secretRef && secretRef.name) { + secretsStore.load({ name: secretRef.name, namespace }); + } if (configMapRef && configMapRef.name) { configMapsStore.load({ name: configMapRef.name, namespace }); @@ -89,21 +93,54 @@ export const ContainerEnvironment = observer((props: Props) => { const renderEnvFrom = () => { const envVars = envFrom.map(vars => { - if (!vars.configMapRef || !vars.configMapRef.name) return; - const configMap = configMapsStore.getByName(vars.configMapRef.name, namespace); - - if (!configMap) return; - - return Object.entries(configMap.data).map(([name, value]) => ( -
- {name}: {value} -
- )); + if (vars.configMapRef?.name) { + return renderEnvFromConfigMap(vars.configMapRef.name); + } else if (vars.secretRef?.name ) { + return renderEnvFromSecret(vars.secretRef.name); + } }); return _.flatten(envVars); }; + const renderEnvFromConfigMap = (configMapName: string) => { + const configMap = configMapsStore.getByName(configMapName, namespace); + + if (!configMap) return; + + return Object.entries(configMap.data).map(([name, value]) => ( +
+ {name}: {value} +
+ )); + }; + + const renderEnvFromSecret = (secretName: string) => { + const secret = secretsStore.getByName(secretName, namespace); + + if (!secret) return; + + return Object.keys(secret.data).map(key => { + const secretKeyRef = { + name: secret.getName(), + key + }; + + const value = ( + + ); + + return ( +
+ {key}: {value} +
+ ); + }); + }; + return ( {env && renderEnv()} From 6876d774a545de7af8b39ce43b4c7f1efa28f7e4 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Wed, 24 Feb 2021 10:20:06 +0300 Subject: [PATCH 12/29] Fix: deprecated helm chart filtering (#2158) * Refactor of excludeDeprecated helm service method Signed-off-by: Alex Andreev * Pick first helm chart from the list on load Signed-off-by: Alex Andreev * Removing helm filtering in UI Signed-off-by: Alex Andreev * Cleaning up Signed-off-by: Alex Andreev * Cleaning up type definitions Signed-off-by: Alex Andreev * Adding sorting charts by version Signed-off-by: Alex Andreev * Adding tests for methods that manipute chart listing Signed-off-by: Alex Andreev * Cleaning up tests a bit Signed-off-by: Alex Andreev * Adding semver coercion before comparing versions Signed-off-by: Alex Andreev --- src/main/helm/__mocks__/helm-chart-manager.ts | 108 ++++++++++++++++++ src/main/helm/__tests__/helm-service.test.ts | 104 +++++++++++++++++ src/main/helm/helm-chart-manager.ts | 9 +- src/main/helm/helm-service.ts | 42 ++++--- src/renderer/api/endpoints/helm-charts.api.ts | 11 +- .../+apps-helm-charts/helm-charts.tsx | 3 - 6 files changed, 246 insertions(+), 31 deletions(-) create mode 100644 src/main/helm/__mocks__/helm-chart-manager.ts create mode 100644 src/main/helm/__tests__/helm-service.test.ts diff --git a/src/main/helm/__mocks__/helm-chart-manager.ts b/src/main/helm/__mocks__/helm-chart-manager.ts new file mode 100644 index 0000000000..e832a937cb --- /dev/null +++ b/src/main/helm/__mocks__/helm-chart-manager.ts @@ -0,0 +1,108 @@ +import { HelmRepo, HelmRepoManager } from "../helm-repo-manager"; + +export class HelmChartManager { + private cache: any = {}; + private repo: HelmRepo; + + constructor(repo: HelmRepo){ + this.cache = HelmRepoManager.cache; + this.repo = repo; + } + + public async charts(): Promise { + switch (this.repo.name) { + case "stable": + return Promise.resolve({ + "apm-server": [ + { + apiVersion: "3.0.0", + name: "apm-server", + version: "2.1.7", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "apm-server", + version: "2.1.6", + repo: "stable", + digest: "test" + } + ], + "redis": [ + { + apiVersion: "3.0.0", + name: "apm-server", + version: "1.0.0", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "apm-server", + version: "0.0.9", + repo: "stable", + digest: "test" + } + ] + }); + case "experiment": + return Promise.resolve({ + "fairwind": [ + { + apiVersion: "3.0.0", + name: "fairwind", + version: "0.0.1", + repo: "experiment", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "fairwind", + version: "0.0.2", + repo: "experiment", + digest: "test", + deprecated: true + } + ] + }); + case "bitnami": + return Promise.resolve({ + "hotdog": [ + { + apiVersion: "3.0.0", + name: "hotdog", + version: "1.0.1", + repo: "bitnami", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "hotdog", + version: "1.0.2", + repo: "bitnami", + digest: "test", + } + ], + "pretzel": [ + { + apiVersion: "3.0.0", + name: "pretzel", + version: "1.0", + repo: "bitnami", + digest: "test", + }, + { + apiVersion: "3.0.0", + name: "pretzel", + version: "1.0.1", + repo: "bitnami", + digest: "test" + } + ] + }); + default: + return Promise.resolve({}); + } + } +} diff --git a/src/main/helm/__tests__/helm-service.test.ts b/src/main/helm/__tests__/helm-service.test.ts new file mode 100644 index 0000000000..8c1e82ef0a --- /dev/null +++ b/src/main/helm/__tests__/helm-service.test.ts @@ -0,0 +1,104 @@ +import { helmService } from "../helm-service"; +import { repoManager } from "../helm-repo-manager"; + +jest.spyOn(repoManager, "init").mockImplementation(); + +jest.mock("../helm-chart-manager"); + +describe("Helm Service tests", () => { + test("list charts without deprecated ones", async () => { + jest.spyOn(repoManager, "repositories").mockImplementation(async () => { + return [ + { name: "stable", url: "stableurl" }, + { name: "experiment", url: "experimenturl" } + ]; + }); + + const charts = await helmService.listCharts(); + + expect(charts).toEqual({ + stable: { + "apm-server": [ + { + apiVersion: "3.0.0", + name: "apm-server", + version: "2.1.7", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "apm-server", + version: "2.1.6", + repo: "stable", + digest: "test" + } + ], + "redis": [ + { + apiVersion: "3.0.0", + name: "apm-server", + version: "1.0.0", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "apm-server", + version: "0.0.9", + repo: "stable", + digest: "test" + } + ] + }, + experiment: {} + }); + }); + + test("list charts sorted by version in descending order", async () => { + jest.spyOn(repoManager, "repositories").mockImplementation(async () => { + return [ + { name: "bitnami", url: "bitnamiurl" } + ]; + }); + + const charts = await helmService.listCharts(); + + expect(charts).toEqual({ + bitnami: { + "hotdog": [ + { + apiVersion: "3.0.0", + name: "hotdog", + version: "1.0.2", + repo: "bitnami", + digest: "test", + }, + { + apiVersion: "3.0.0", + name: "hotdog", + version: "1.0.1", + repo: "bitnami", + digest: "test" + }, + ], + "pretzel": [ + { + apiVersion: "3.0.0", + name: "pretzel", + version: "1.0.1", + repo: "bitnami", + digest: "test", + }, + { + apiVersion: "3.0.0", + name: "pretzel", + version: "1.0", + repo: "bitnami", + digest: "test" + } + ] + } + }); + }); +}); diff --git a/src/main/helm/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts index cf4a8e5ace..69619a56d4 100644 --- a/src/main/helm/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -4,9 +4,10 @@ import { HelmRepo, HelmRepoManager } from "./helm-repo-manager"; import logger from "../logger"; import { promiseExec } from "../promise-exec"; import { helmCli } from "./helm-cli"; +import type { RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api"; type CachedYaml = { - entries: any; // todo: types + entries: RepoHelmChartList }; export class HelmChartManager { @@ -24,15 +25,15 @@ export class HelmChartManager { return charts[name]; } - public async charts(): Promise { + public async charts(): Promise { try { const cachedYaml = await this.cachedYaml(); return cachedYaml["entries"]; } catch(error) { - logger.error(error); + logger.error("HELM-CHART-MANAGER]: failed to list charts", { error }); - return []; + return {}; } } diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index 1918268075..f7445cebd4 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -1,8 +1,10 @@ +import semver from "semver"; import { Cluster } from "../cluster"; import logger from "../logger"; import { repoManager } from "./helm-repo-manager"; import { HelmChartManager } from "./helm-chart-manager"; import { releaseManager } from "./helm-release-manager"; +import { HelmChartList, RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api"; class HelmService { public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) { @@ -10,7 +12,7 @@ class HelmService { } public async listCharts() { - const charts: any = {}; + const charts: HelmChartList = {}; await repoManager.init(); const repositories = await repoManager.repositories(); @@ -18,14 +20,10 @@ class HelmService { for (const repo of repositories) { charts[repo.name] = {}; const manager = new HelmChartManager(repo); - let entries = await manager.charts(); + const sortedCharts = this.sortChartsByVersion(await manager.charts()); + const enabledCharts = this.excludeDeprecatedChartGroups(sortedCharts); - entries = this.excludeDeprecated(entries); - - for (const key in entries) { - entries[key] = entries[key][0]; - } - charts[repo.name] = entries; + charts[repo.name] = enabledCharts; } return charts; @@ -96,20 +94,30 @@ class HelmService { return { message: output }; } - protected excludeDeprecated(entries: any) { - for (const key in entries) { - entries[key] = entries[key].filter((entry: any) => { - if (Array.isArray(entry)) { - return entry[0]["deprecated"] != true; - } + private excludeDeprecatedChartGroups(chartGroups: RepoHelmChartList) { + const groups = new Map(Object.entries(chartGroups)); - return entry["deprecated"] != true; + for (const [chartName, charts] of groups) { + if (charts[0].deprecated) { + groups.delete(chartName); + } + } + + return Object.fromEntries(groups); + } + + private sortChartsByVersion(chartGroups: RepoHelmChartList) { + for (const key in chartGroups) { + chartGroups[key] = chartGroups[key].sort((first, second) => { + const firstVersion = semver.coerce(first.version || 0); + const secondVersion = semver.coerce(second.version || 0); + + return semver.compare(secondVersion, firstVersion); }); } - return entries; + return chartGroups; } - } export const helmService = new HelmService(); diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index 8beff01779..02b5b0dbee 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -3,11 +3,8 @@ import { apiBase } from "../index"; import { stringify } from "querystring"; import { autobind } from "../../utils"; -interface IHelmChartList { - [repo: string]: { - [name: string]: HelmChart; - }; -} +export type RepoHelmChartList = Record; +export type HelmChartList = Record; export interface IHelmChartDetails { readme: string; @@ -22,12 +19,12 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: { export const helmChartsApi = { list() { return apiBase - .get(endpoint()) + .get(endpoint()) .then(data => { return Object .values(data) .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) - .map(HelmChart.create); + .map(([chart]) => HelmChart.create(chart)); }); }, diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index 22d5ba3a2f..8e4afe8d83 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -72,9 +72,6 @@ export class HelmCharts extends Component { (chart: HelmChart) => chart.getAppVersion(), (chart: HelmChart) => chart.getKeywords(), ]} - filterItems={[ - (items: HelmChart[]) => items.filter(item => !item.deprecated) - ]} customizeHeader={() => ( )} From 0d3505cfac2712cc699c3609e9fde58b1aa165b7 Mon Sep 17 00:00:00 2001 From: Violetta <38247153+vshakirova@users.noreply.github.com> Date: Wed, 24 Feb 2021 12:02:39 +0400 Subject: [PATCH 13/29] Add persistent volumes info to storage class submenu (#2160) Signed-off-by: vshakirova --- .../api/endpoints/persistent-volume.api.ts | 4 + .../storage-class-details.tsx | 9 ++ .../+storage-classes/storage-class.store.ts | 5 ++ .../+storage-volumes/volume-details-list.scss | 31 +++++++ .../+storage-volumes/volume-details-list.tsx | 88 +++++++++++++++++++ .../components/+storage-volumes/volumes.scss | 2 +- .../+storage-volumes/volumes.store.ts | 7 ++ 7 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/renderer/components/+storage-volumes/volume-details-list.scss create mode 100644 src/renderer/components/+storage-volumes/volume-details-list.tsx diff --git a/src/renderer/api/endpoints/persistent-volume.api.ts b/src/renderer/api/endpoints/persistent-volume.api.ts index 27a0279a0b..db286db062 100644 --- a/src/renderer/api/endpoints/persistent-volume.api.ts +++ b/src/renderer/api/endpoints/persistent-volume.api.ts @@ -70,6 +70,10 @@ export class PersistentVolume extends KubeObject { getClaimRefName(): string { return this.spec.claimRef?.name ?? ""; } + + getStorageClassName() { + return this.spec.storageClassName || ""; + } } export const persistentVolumeApi = new KubeApi({ diff --git a/src/renderer/components/+storage-classes/storage-class-details.tsx b/src/renderer/components/+storage-classes/storage-class-details.tsx index 4ee8a197f2..1d47df8ecb 100644 --- a/src/renderer/components/+storage-classes/storage-class-details.tsx +++ b/src/renderer/components/+storage-classes/storage-class-details.tsx @@ -10,14 +10,22 @@ import { KubeObjectDetailsProps } from "../kube-object"; import { StorageClass } from "../../api/endpoints"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import { storageClassStore } from "./storage-class.store"; +import { VolumeDetailsList } from "../+storage-volumes/volume-details-list"; +import { volumesStore } from "../+storage-volumes/volumes.store"; interface Props extends KubeObjectDetailsProps { } @observer export class StorageClassDetails extends React.Component { + async componentDidMount() { + volumesStore.reloadAll(); + } + render() { const { object: storageClass } = this.props; + const persistentVolumes = storageClassStore.getPersistentVolumes(storageClass); if (!storageClass) return null; const { provisioner, parameters, mountOptions } = storageClass; @@ -55,6 +63,7 @@ export class StorageClassDetails extends React.Component { } )} +
); } diff --git a/src/renderer/components/+storage-classes/storage-class.store.ts b/src/renderer/components/+storage-classes/storage-class.store.ts index 9e388456a5..0050c432b5 100644 --- a/src/renderer/components/+storage-classes/storage-class.store.ts +++ b/src/renderer/components/+storage-classes/storage-class.store.ts @@ -2,10 +2,15 @@ import { KubeObjectStore } from "../../kube-object.store"; import { autobind } from "../../utils"; import { StorageClass, storageClassApi } from "../../api/endpoints/storage-class.api"; import { apiManager } from "../../api/api-manager"; +import { volumesStore } from "../+storage-volumes/volumes.store"; @autobind() export class StorageClassStore extends KubeObjectStore { api = storageClassApi; + + getPersistentVolumes(storageClass: StorageClass) { + return volumesStore.getByStorageClass(storageClass); + } } export const storageClassStore = new StorageClassStore(); diff --git a/src/renderer/components/+storage-volumes/volume-details-list.scss b/src/renderer/components/+storage-volumes/volume-details-list.scss new file mode 100644 index 0000000000..aed61b4b5f --- /dev/null +++ b/src/renderer/components/+storage-volumes/volume-details-list.scss @@ -0,0 +1,31 @@ +@import "../+storage/storage-mixins"; + +.VolumeDetailsList { + position: relative; + + .Table { + margin: 0 (-$margin * 3); + + &.virtual { + height: 500px; // applicable for 100+ items + } + } + + .TableCell { + &:first-child { + margin-left: $margin; + } + + &:last-child { + margin-right: $margin; + } + + &.name { + flex-grow: 2; + } + + &.status { + @include pv-status-colors; + } + } +} diff --git a/src/renderer/components/+storage-volumes/volume-details-list.tsx b/src/renderer/components/+storage-volumes/volume-details-list.tsx new file mode 100644 index 0000000000..91dbcf3dd1 --- /dev/null +++ b/src/renderer/components/+storage-volumes/volume-details-list.tsx @@ -0,0 +1,88 @@ +import "./volume-details-list.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { PersistentVolume } from "../../api/endpoints/persistent-volume.api"; +import { autobind } from "../../../common/utils/autobind"; +import { TableRow } from "../table/table-row"; +import { cssNames, prevDefault } from "../../utils"; +import { showDetails } from "../kube-object/kube-object-details"; +import { TableCell } from "../table/table-cell"; +import { Spinner } from "../spinner/spinner"; +import { DrawerTitle } from "../drawer/drawer-title"; +import { Table } from "../table/table"; +import { TableHead } from "../table/table-head"; +import { volumesStore } from "./volumes.store"; +import kebabCase from "lodash/kebabCase"; + +interface Props { + persistentVolumes: PersistentVolume[]; +} + +enum sortBy { + name = "name", + status = "status", + capacity = "capacity", +} + +@observer +export class VolumeDetailsList extends React.Component { + private sortingCallbacks = { + [sortBy.name]: (volume: PersistentVolume) => volume.getName(), + [sortBy.capacity]: (volume: PersistentVolume) => volume.getCapacity(), + [sortBy.status]: (volume: PersistentVolume) => volume.getStatus(), + }; + + @autobind() + getTableRow(uid: string) { + const { persistentVolumes } = this.props; + const volume = persistentVolumes.find(volume => volume.getId() === uid); + + return ( + showDetails(volume.selfLink, false))} + > + {volume.getName()} + {volume.getCapacity()} + {volume.getStatus()} + + ); + } + + render() { + const { persistentVolumes } = this.props; + const virtual = persistentVolumes.length > 100; + + if (!persistentVolumes.length) { + return !volumesStore.isLoaded && ; + } + + return ( +
+ + + + Name + Capacity + Status + + { + !virtual && persistentVolumes.map(volume => this.getTableRow(volume.getId())) + } +
+
+ ); + } +} diff --git a/src/renderer/components/+storage-volumes/volumes.scss b/src/renderer/components/+storage-volumes/volumes.scss index 272aa3fd89..9aa1e616b1 100644 --- a/src/renderer/components/+storage-volumes/volumes.scss +++ b/src/renderer/components/+storage-volumes/volumes.scss @@ -26,7 +26,7 @@ flex-grow: 3; } - .status { + &.status { @include pv-status-colors; } diff --git a/src/renderer/components/+storage-volumes/volumes.store.ts b/src/renderer/components/+storage-volumes/volumes.store.ts index ea61525735..b2601f9b12 100644 --- a/src/renderer/components/+storage-volumes/volumes.store.ts +++ b/src/renderer/components/+storage-volumes/volumes.store.ts @@ -2,10 +2,17 @@ import { KubeObjectStore } from "../../kube-object.store"; import { autobind } from "../../utils"; import { PersistentVolume, persistentVolumeApi } from "../../api/endpoints/persistent-volume.api"; import { apiManager } from "../../api/api-manager"; +import { StorageClass } from "../../api/endpoints/storage-class.api"; @autobind() export class PersistentVolumesStore extends KubeObjectStore { api = persistentVolumeApi; + + getByStorageClass(storageClass: StorageClass): PersistentVolume[] { + return this.items.filter(volume => + volume.getStorageClassName() === storageClass.getName() + ); + } } export const volumesStore = new PersistentVolumesStore(); From 92dfd80889eb1800d354f310b0ffa555ceac96f3 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 24 Feb 2021 09:05:14 -0500 Subject: [PATCH 14/29] StatusBarRegistration's component field must be optional for backwards compatability (#2211) Signed-off-by: Sebastian Malton --- src/extensions/registries/status-bar-registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/registries/status-bar-registry.ts b/src/extensions/registries/status-bar-registry.ts index 698c5f5f24..e0454fe77e 100644 --- a/src/extensions/registries/status-bar-registry.ts +++ b/src/extensions/registries/status-bar-registry.ts @@ -8,7 +8,7 @@ interface StatusBarComponents { } interface StatusBarRegistrationV2 { - components: StatusBarComponents; + components?: StatusBarComponents; // has to be optional for backwards compatability } export interface StatusBarRegistration extends StatusBarRegistrationV2 { From f18d8618cd1a8081203c62bf901c1dbe626713a1 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Wed, 24 Feb 2021 16:10:47 +0200 Subject: [PATCH 15/29] Fix loading all namespaces for users with limited cluster access (#2217) Signed-off-by: Lauri Nevala --- src/renderer/api/kube-api.ts | 1 + src/renderer/components/+namespaces/namespace.store.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index a880cc2406..448cd9da8f 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -272,6 +272,7 @@ export class KubeApi { } protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { + if (!data) return; const KubeObjectConstructor = this.objectConstructor; if (KubeObject.isJsonApiData(data)) { diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index ad271d1302..9995fbb7e5 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -120,7 +120,7 @@ export class NamespaceStore extends KubeObjectStore { protected async loadItems(params: KubeObjectStoreLoadingParams) { const { allowedNamespaces } = this; - let namespaces = await super.loadItems(params); + let namespaces = (await super.loadItems(params)) || []; namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName())); From 38c3734d5ccd5f874648481fe6e56f0646af829a Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Thu, 25 Feb 2021 09:09:39 -0500 Subject: [PATCH 16/29] the select all checkbox should not select disabled items (#2151) * the select all checkbox should not select disabled items Signed-off-by: Jim Ehrismann * remove improper bullet-proofing Signed-off-by: Jim Ehrismann * added default for customizeTableRowProps Signed-off-by: Jim Ehrismann --- .../item-object-list/item-list-layout.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 895c275fbb..adec4bef27 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -89,7 +89,8 @@ const defaultProps: Partial = { filterItems: [], hasDetailsView: true, onDetails: noop, - virtual: true + virtual: true, + customizeTableRowProps: () => ({} as TableRowProps), }; interface ItemListLayoutUserSettings { @@ -241,7 +242,7 @@ export class ItemListLayout extends React.Component { sortItem={item} selected={detailsItem && detailsItem.getId() === itemId} onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined} - {...(customizeTableRowProps ? customizeTableRowProps(item) : {})} + {...customizeTableRowProps(item)} > {isSelectable && ( { } renderTableHeader() { - const { renderTableHeader, isSelectable, isConfigurable, store } = this.props; + const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props; if (!renderTableHeader) { return; } + const enabledItems = this.items.filter(item => !customizeTableRowProps(item).disabled); + return ( {isSelectable && ( store.toggleSelectionAll(this.items))} + isChecked={store.isSelectedAll(enabledItems)} + onClick={prevDefault(() => store.toggleSelectionAll(enabledItems))} /> )} {renderTableHeader.map((cellProps, index) => { From 1470103fd486a269ad9c4fcccd3373275dba83b9 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 25 Feb 2021 09:32:40 -0500 Subject: [PATCH 17/29] Add lens:// protocol handling with a routing mechanism (#1949) - Add lens:// protocol handling with a routing mechanism - document the methods in an extension guide - remove handlers when an extension is deactivated or removed - make sure that the found extension when routing a request is currently enabled (as a backup) - added documentation about the above behaviour to the guide - tweaked the naming convention so that it is clearer that the router uses extension names as not IDs (which currently are folder paths) - Convert the extension API to use an array for registering handlers - switch design to execute both main and renderer handlers simultaneously, without any overlap checking - change open to be a dev dep - improve docs, export types for extensions, skip integration tests - switch to event emitting renderer being ready - Add logging and fix renderer:loaded send to main Signed-off-by: Sebastian Malton --- Makefile | 15 +- docs/extensions/guides/README.md | 3 +- .../extensions/guides/images/routing-diag.png | Bin 0 -> 25838 bytes docs/extensions/guides/protocol-handlers.md | 83 ++++++ docs/getting-started/README.md | 23 +- package.json | 17 +- scripts/test.sh | 1 + src/common/ipc/ipc.ts | 2 +- src/common/protocol-handler/error.ts | 36 +++ src/common/protocol-handler/index.ts | 2 + src/common/protocol-handler/router.ts | 218 +++++++++++++++ src/common/utils/delay.ts | 17 +- src/common/utils/index.ts | 2 +- src/common/utils/type-narrowing.ts | 13 + src/extensions/extension-loader.ts | 27 +- src/extensions/extensions-store.ts | 2 +- src/extensions/interfaces/registrations.ts | 1 + src/extensions/lens-extension.ts | 3 + src/extensions/lens-renderer-extension.ts | 3 +- .../registries/protocol-handler-registry.ts | 44 +++ src/main/app-updater.ts | 5 +- src/main/index.ts | 69 ++++- .../protocol-handler/__test__/router.test.ts | 259 ++++++++++++++++++ src/main/protocol-handler/index.ts | 1 + src/main/protocol-handler/router.ts | 112 ++++++++ src/main/window-manager.ts | 6 +- src/renderer/lens-app.tsx | 4 + src/renderer/navigation/index.ts | 2 + src/renderer/navigation/protocol-handlers.ts | 10 + src/renderer/protocol-handler/index.ts | 1 + src/renderer/protocol-handler/router.ts | 40 +++ src/renderer/utils/index.ts | 2 - yarn.lock | 27 +- 33 files changed, 1010 insertions(+), 40 deletions(-) create mode 100644 docs/extensions/guides/images/routing-diag.png create mode 100644 docs/extensions/guides/protocol-handlers.md create mode 100755 scripts/test.sh create mode 100644 src/common/protocol-handler/error.ts create mode 100644 src/common/protocol-handler/index.ts create mode 100644 src/common/protocol-handler/router.ts create mode 100644 src/common/utils/type-narrowing.ts create mode 100644 src/extensions/registries/protocol-handler-registry.ts create mode 100644 src/main/protocol-handler/__test__/router.test.ts create mode 100644 src/main/protocol-handler/index.ts create mode 100644 src/main/protocol-handler/router.ts create mode 100644 src/renderer/navigation/protocol-handlers.ts create mode 100644 src/renderer/protocol-handler/index.ts create mode 100644 src/renderer/protocol-handler/router.ts diff --git a/Makefile b/Makefile index 000682e039..ea7c84d018 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ else DETECTED_OS := $(shell uname) endif -binaries/client: +binaries/client: node_modules yarn download-bins node_modules: yarn.lock @@ -37,17 +37,24 @@ test: binaries/client yarn test .PHONY: integration-linux -integration-linux: build-extension-types build-extensions +integration-linux: binaries/client build-extension-types build-extensions +# ifdef XDF_CONFIG_HOME +# rm -rf ${XDG_CONFIG_HOME}/.config/Lens +# else +# rm -rf ${HOME}/.config/Lens +# endif yarn build:linux yarn integration .PHONY: integration-mac -integration-mac: build-extension-types build-extensions +integration-mac: binaries/client build-extension-types build-extensions + # rm ${HOME}/Library/Application\ Support/Lens yarn build:mac yarn integration .PHONY: integration-win -integration-win: build-extension-types build-extensions +integration-win: binaries/client build-extension-types build-extensions + # rm %APPDATA%/Lens yarn build:win yarn integration diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index 8db209dc82..d288f0cd64 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -21,12 +21,13 @@ Each guide or code sample includes the following: | [Components](components.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | | | [Working with mobx](working-with-mobx.md) | | +| [Protocol Handlers](protocol-handlers.md) | | ## Samples | Sample | APIs | | ----- | ----- | -[helloworld](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | +[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | [minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension
Store.clusterStore
Store.workspaceStore | [styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | [styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | diff --git a/docs/extensions/guides/images/routing-diag.png b/docs/extensions/guides/images/routing-diag.png new file mode 100644 index 0000000000000000000000000000000000000000..9185ce94d8bfa105e27df0b647f3c43382fb7aed GIT binary patch literal 25838 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

d#<2>jMen+c&Kq#EHyv-PUGDM_etgI zIR0w98Nm354>!uGH?OsXXOb|F!ia}7+f#0kcIx6AORFoIO3yU@YSg|G|L{ z_qIcs1P$yeW>SI@M>InBd=9;+pr|S2XA`n<5Fv1&YSTrqT^IitrR*R38VSqEX_$c% z1p9h>k0R6{ehWcV#SkHZHd%D_po2eZpJ+rk@<7SAV?r`DERoY^I6z&U3E};!vojT6 zKzJ)%uxwjxFnSRGInqt>oD2+!@5ZVCW^x) zU{L3N4a7-%ww&HmK4+S<0JkjSj^ABc3I;=z3c}18+f{vzN3AJi|46tOrTLIPDF zf*%CHxMTnHA|aW%xw%2222-`1xD+}X1R4p*N4Ko*iLV{TU6Md5Jq#YTC5C!HK*E_w znwXf_eW>&jOjYk-C{8Y(ho2gzAt^jW*}`wH-C|-qa2%5e#$tp!;#c1aP#Dku8q*#D z2>?EEva?s95XWGyfAO4zpvl1qA-eN3Y7_Wn7dUcYG)Y6X^TVKLn5h(ta*0Zt?NA

r@7sC59Q|<%IMu9(H!#{z$E@BpDqG5-#4l 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 literal 0 HcmV?d00001 diff --git a/docs/extensions/guides/protocol-handlers.md b/docs/extensions/guides/protocol-handlers.md new file mode 100644 index 0000000000..8e13c8436a --- /dev/null +++ b/docs/extensions/guides/protocol-handlers.md @@ -0,0 +1,83 @@ +# Lens Protocol Handlers + +Lens has a file association with the `lens://` protocol. +This means that Lens can be opened by external programs by providing a link that has `lens` as its protocol. +Lens provides a routing mechanism that extensions can use to register custom handlers. + +## Registering A Protocol Handler + +The field `protocolHandlers` exists both on [`LensMainExtension`](extensions/api/classes/lensmainextension/#protocolhandlers) and on [`LensRendererExtension`](extensions/api/classes/lensrendererextension/#protocolhandlers). +This field will be iterated through every time a `lens://` request gets sent to the application. +The `pathSchema` argument must comply with the [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) package's `compileToRegex` function. + +Once you have registered a handler it will be called when a user opens a link on their computer. +Handlers will be run in both `main` and `renderer` in parallel with no synchronization between the two processes. +Furthermore, both `main` and `renderer` are routed separately. +In other words, which handler is selected in either process is independent from the list of possible handlers in the other. + +Example of registering a handler: + +```typescript +import { LensMainExtension, Interface } from "@k8slens/extensions"; + +function rootHandler(params: Iterface.ProtocolRouteParams) { + console.log("routed to ExampleExtension", params); +} + +export default class ExampleExtensionMain extends LensMainExtension { + protocolHandlers = [ + pathSchema: "/", + handler: rootHandler, + ] +} +``` + +For testing the routing of URIs the `open` (on macOS) or `xdg-open` (on most linux) CLI utilities can be used. +For the above handler, the following URI would be always routed to it: + +``` +open lens://extension/example-extension/ +``` + +## Deregistering A Protocol Handler + +All that is needed to deregister a handler is to remove it from the array of handlers. + +## Routing Algorithm + +The routing mechanism for extensions is quite straight forward. +For example consider an extension `example-extension` which is published by the `@mirantis` org. +If it were to register a handler with `"/display/:type"` as its corresponding link then we would match the following URI like this: + +![Lens Protocol Link Resolution](images/routing-diag.png) + +Once matched, the handler would be called with the following argument (note both `"search"` and `"pathname"` will always be defined): + +```json +{ + "search": { + "text": "Hello" + }, + "pathname": { + "type": "notification" + } +} +``` + +As the diagram above shows, the search (or query) params are not considered as part of the handler resolution. +If the URI had instead been `lens://extension/@mirantis/example-extension/display/notification/green` then a third (and optional) field will have the rest of the path. +The `tail` field would be filled with `"/green"`. +If multiple `pathSchema`'s match a given URI then the most specific handler will be called. + +For example consider the following `pathSchema`'s: + +1. `"/"` +1. `"/display"` +1. `"/display/:type"` +1. `"/show/:id"` + +The URI sub-path `"/display"` would be routed to #2 since it is an exact match. +On the other hand, the subpath `"/display/notification"` would be routed to #3. + +The URI is routed to the most specific matching `pathSchema`. +This way the `"/"` (root) `pathSchema` acts as a sort of catch all or default route if no other route matches. diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index bfb990378d..ed6537bd76 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -28,6 +28,28 @@ Review the [System Requirements](../supporting/requirements.md) to check if your See the [Download Lens](https://github.com/lensapp/lens/releases) page for a complete list of available installation options. +After installing Lens manually (not using a package manager file such as `.deb` or `.rpm`) the following will need to be done to allow protocol handling. +This assumes that your linux distribution uses `xdg-open` and the `xdg-*` suite of programs for determining which application can handle custom URIs. + +1. Create a file called `lens.desktop` in either `~/.local/share/applications/` or `/usr/share/applications` (if you have permissions and are installing Lens for all users). +1. That file should have the following contents, with `` being the absolute path to where you have installed the unpacked `Lens` executable: + ``` + [Desktop Entry] + Name=Lens + Exec= %U + Terminal=false + Type=Application + Icon=lens + StartupWMClass=Lens + Comment=Lens - The Kubernetes IDE + MimeType=x-scheme-handler/lens; + Categories=Network; + ``` +1. Then run the following command: + ``` + xdg-settings set default-url-scheme-handler lens lens.desktop + ``` +1. If that succeeds (exits with code `0`) then your Lens install should be set up to handle `lens://` URIs. ### Snap @@ -52,4 +74,3 @@ To stay current with the Lens features, you can review the [release notes](https - [Add clusters](../clusters/adding-clusters.md) - [Watch introductory videos](./introductory-videos.md) - diff --git a/package.json b/package.json index e575e48520..3639366298 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens", "build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens", "build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens", - "test": "jest --env=jsdom src $@", + "test": "scripts/test.sh", "integration": "jest --runInBand integration", "dist": "yarn run compile && electron-builder --publish onTag", "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", @@ -170,7 +170,14 @@ "repo": "lens", "owner": "lensapp" } - ] + ], + "protocols": { + "name": "Lens Protocol Handler", + "schemes": [ + "lens" + ], + "role": "Viewer" + } }, "lens": { "extensions": [ @@ -187,6 +194,7 @@ "@hapi/call": "^8.0.0", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.12.0", + "abort-controller": "^3.0.0", "array-move": "^3.0.0", "await-lock": "^2.1.0", "byline": "^5.0.0", @@ -213,6 +221,7 @@ "mobx-observable-history": "^1.0.3", "mobx-react": "^6.2.2", "mock-fs": "^4.12.0", + "moment": "^2.26.0", "node-pty": "^0.9.0", "npm": "^6.14.8", "openid-client": "^3.15.2", @@ -232,6 +241,7 @@ "tar": "^6.0.5", "tcp-port-used": "^1.0.1", "tempy": "^0.5.0", + "url-parse": "^1.4.7", "uuid": "^8.3.2", "win-ca": "^3.2.0", "winston": "^3.2.1", @@ -289,6 +299,7 @@ "@types/tempy": "^0.3.0", "@types/terser-webpack-plugin": "^3.0.0", "@types/universal-analytics": "^0.4.4", + "@types/url-parse": "^1.4.3", "@types/uuid": "^8.3.0", "@types/webdriverio": "^4.13.0", "@types/webpack": "^4.41.17", @@ -325,10 +336,10 @@ "jest-mock-extended": "^1.0.10", "make-plural": "^6.2.2", "mini-css-extract-plugin": "^0.9.0", - "moment": "^2.26.0", "node-loader": "^0.6.0", "node-sass": "^4.14.1", "nodemon": "^2.0.4", + "open": "^7.3.1", "patch-package": "^6.2.2", "postinstall-postinstall": "^2.1.0", "prettier": "^2.2.0", diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000000..19c1f71c47 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1 @@ +jest --env=jsdom ${1:-src} diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index 48b0b89153..b104b31f4a 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -47,7 +47,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) { view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); } } catch (error) { - logger.error("[IPC]: failed to send IPC message", { error }); + logger.error("[IPC]: failed to send IPC message", { error: String(error) }); } } } diff --git a/src/common/protocol-handler/error.ts b/src/common/protocol-handler/error.ts new file mode 100644 index 0000000000..ebe7adccd7 --- /dev/null +++ b/src/common/protocol-handler/error.ts @@ -0,0 +1,36 @@ +import Url from "url-parse"; + +export enum RoutingErrorType { + INVALID_PROTOCOL = "invalid-protocol", + INVALID_HOST = "invalid-host", + INVALID_PATHNAME = "invalid-pathname", + NO_HANDLER = "no-handler", + NO_EXTENSION_ID = "no-ext-id", + MISSING_EXTENSION = "missing-ext", +} + +export class RoutingError extends Error { + /** + * Will be set if the routing error originated in an extension route table + */ + public extensionName?: string; + + constructor(public type: RoutingErrorType, public url: Url) { + super("routing error"); + } + + toString(): string { + switch (this.type) { + case RoutingErrorType.INVALID_HOST: + return "invalid host"; + case RoutingErrorType.INVALID_PROTOCOL: + return "invalid protocol"; + case RoutingErrorType.INVALID_PATHNAME: + return "invalid pathname"; + case RoutingErrorType.NO_EXTENSION_ID: + return "no extension ID"; + case RoutingErrorType.MISSING_EXTENSION: + return "extension not found"; + } + } +} diff --git a/src/common/protocol-handler/index.ts b/src/common/protocol-handler/index.ts new file mode 100644 index 0000000000..887f549507 --- /dev/null +++ b/src/common/protocol-handler/index.ts @@ -0,0 +1,2 @@ +export * from "./error"; +export * from "./router"; diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts new file mode 100644 index 0000000000..b18eb84368 --- /dev/null +++ b/src/common/protocol-handler/router.ts @@ -0,0 +1,218 @@ +import { match, matchPath } from "react-router"; +import { countBy } from "lodash"; +import { Singleton } from "../utils"; +import { pathToRegexp } from "path-to-regexp"; +import logger from "../../main/logger"; +import Url from "url-parse"; +import { RoutingError, RoutingErrorType } from "./error"; +import { extensionsStore } from "../../extensions/extensions-store"; +import { extensionLoader } from "../../extensions/extension-loader"; +import { LensExtension } from "../../extensions/lens-extension"; +import { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler-registry"; + +// IPC channel for protocol actions. Main broadcasts the open-url events to this channel. +export const ProtocolHandlerIpcPrefix = "protocol-handler"; + +export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`; +export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`; + +/** + * These two names are long and cumbersome by design so as to decrease the chances + * of an extension using the same names. + * + * Though under the current (2021/01/18) implementation, these are never matched + * against in the final matching so their names are less of a concern. + */ +const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; +const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; + +export abstract class LensProtocolRouter extends Singleton { + // Map between path schemas and the handlers + protected internalRoutes = new Map(); + + public static readonly LoggingPrefix = "[PROTOCOL ROUTER]"; + + protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; + + /** + * + * @param url the parsed URL that initiated the `lens://` protocol + */ + protected _routeToInternal(url: Url): void { + this._route(Array.from(this.internalRoutes.entries()), url); + } + + /** + * match against all matched URIs, returning either the first exact match or + * the most specific match if none are exact. + * @param routes the array of path schemas, handler pairs to match against + * @param url the url (in its current state) + */ + protected _findMatchingRoute(routes: [string, RouteHandler][], url: Url): null | [match>, RouteHandler] { + const matches: [match>, RouteHandler][] = []; + + for (const [schema, handler] of routes) { + const match = matchPath(url.pathname, { path: schema }); + + if (!match) { + continue; + } + + // prefer an exact match + if (match.isExact) { + return [match, handler]; + } + + matches.push([match, handler]); + } + + // if no exact match pick the one that is the most specific + return matches.sort(([a], [b]) => compareMatches(a, b))[0] ?? null; + } + + /** + * find the most specific matching handler and call it + * @param routes the array of (path schemas, handler) paris to match against + * @param url the url (in its current state) + */ + protected _route(routes: [string, RouteHandler][], url: Url, extensionName?: string): void { + const route = this._findMatchingRoute(routes, url); + + if (!route) { + const data: Record = { url: url.toString() }; + + if (extensionName) { + data.extensionName = extensionName; + } + + return void logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data); + } + + const [match, handler] = route; + + const params: RouteParams = { + pathname: match.params, + search: url.query, + }; + + if (!match.isExact) { + params.tail = url.pathname.slice(match.url.length); + } + + handler(params); + } + + /** + * Tries to find the matching LensExtension instance + * + * Note: this needs to be async so that `main`'s overloaded version can also be async + * @param url the protocol request URI that was "open"-ed + * @returns either the found name or the instance of `LensExtension` + */ + protected async _findMatchingExtensionByName(url: Url): Promise { + interface ExtensionUrlMatch { + [EXTENSION_PUBLISHER_MATCH]: string; + [EXTENSION_NAME_MATCH]: string; + } + + const match = matchPath(url.pathname, LensProtocolRouter.ExtensionUrlSchema); + + if (!match) { + throw new RoutingError(RoutingErrorType.NO_EXTENSION_ID, url); + } + + const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params; + const name = [publisher, partialName].filter(Boolean).join("/"); + + const extension = extensionLoader.userExtensionsByName.get(name); + + if (!extension) { + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed`); + + return name; + } + + if (!extensionsStore.isEnabled(extension.id)) { + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`); + + return name; + } + + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`); + + return extension; + } + + /** + * Find a matching extension by the first one or two path segments of `url` and then try to `_route` + * its correspondingly registered handlers. + * + * If no handlers are found or the extension is not enabled then `_missingHandlers` is called before + * checking if more handlers have been added. + * + * Note: this function modifies its argument, do not reuse + * @param url the protocol request URI that was "open"-ed + */ + protected async _routeToExtension(url: Url): Promise { + const extension = await this._findMatchingExtensionByName(url); + + if (typeof extension === "string") { + // failed to find an extension, it returned its name + return; + } + + // remove the extension name from the path name so we don't need to match on it anymore + url.set("pathname", url.pathname.slice(extension.name.length + 1)); + + const handlers = extension + .protocolHandlers + .map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]); + + try { + this._route(handlers, url, extension.name); + } catch (error) { + if (error instanceof RoutingError) { + error.extensionName = extension.name; + } + + throw error; + } + } + + /** + * Add a handler under the `lens://app` tree of routing. + * @param pathSchema the URI path schema to match against for this handler + * @param handler a function that will be called if a protocol path matches + */ + public addInternalHandler(urlSchema: string, handler: RouteHandler): void { + pathToRegexp(urlSchema); // verify now that the schema is valid + logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`); + this.internalRoutes.set(urlSchema, handler); + } + + /** + * Remove an internal protocol handler. + * @param pathSchema the path schema that the handler was registered under + */ + public removeInternalHandler(urlSchema: string): void { + this.internalRoutes.delete(urlSchema); + } +} + +/** + * a comparison function for `array.sort(...)`. Sort order should be most path + * parts to least path parts. + * @param a the left side to compare + * @param b the right side to compare + */ +function compareMatches(a: match, b: match): number { + if (a.path === "/") { + return 1; + } + + if (b.path === "/") { + return -1; + } + + return countBy(b.path)["/"] - countBy(a.path)["/"]; +} diff --git a/src/common/utils/delay.ts b/src/common/utils/delay.ts index 7d0686d29b..f19839538a 100644 --- a/src/common/utils/delay.ts +++ b/src/common/utils/delay.ts @@ -1,8 +1,19 @@ +import { AbortController } from "abort-controller"; + /** * Return a promise that will be resolved after at least `timeout` ms have - * passed + * passed. If `failFast` is provided then the promise is also resolved if it has + * been aborted. * @param timeout The number of milliseconds before resolving + * @param failFast An abort controller instance to cause the delay to short-circuit */ -export function delay(timeout = 1000): Promise { - return new Promise(resolve => setTimeout(resolve, timeout)); +export function delay(timeout = 1000, failFast?: AbortController): Promise { + return new Promise(resolve => { + const timeoutId = setTimeout(resolve, timeout); + + failFast?.signal.addEventListener("abort", () => { + clearTimeout(timeoutId); + resolve(); + }); + }); } diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 2b8147fad9..6f26bab2da 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -18,4 +18,4 @@ export * from "./openExternal"; export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./tar"; -export * from "./delay"; +export * from "./type-narrowing"; diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts new file mode 100644 index 0000000000..6a239c43ee --- /dev/null +++ b/src/common/utils/type-narrowing.ts @@ -0,0 +1,13 @@ +/** + * Narrows `val` to include the property `key` (if true is returned) + * @param val The object to be tested + * @param key The key to test if it is present on the object + */ +export function hasOwnProperty(val: V, key: K): val is (V & { [key in K]: unknown }) { + // this call syntax is for when `val` was created by `Object.create(null)` + return Object.prototype.hasOwnProperty.call(val, key); +} + +export function hasOwnProperties(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) { + return keys.every(key => hasOwnProperty(val, key)); +} diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 98697d252c..b4aedaf274 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -14,7 +14,7 @@ import type { LensRendererExtension } from "./lens-renderer-extension"; import * as registries from "./registries"; import fs from "fs"; -// lazy load so that we get correct userData + export function extensionPackagesRoot() { return path.join((app || remote.app).getPath("userData")); } @@ -52,6 +52,30 @@ export class ExtensionLoader { return extensions; } + @computed get userExtensionsByName(): Map { + const extensions = new Map(); + + for (const [, val] of this.instances.toJS()) { + if (val.isBundled) { + continue; + } + + extensions.set(val.manifest.name, val); + } + + return extensions; + } + + getExtensionByName(name: string): LensExtension | null { + for (const [, val] of this.instances) { + if (val.name === name) { + return val; + } + } + + return null; + } + // Transform userExtensions to a state object for storing into ExtensionsStore @computed get storeState() { return Object.fromEntries( @@ -102,7 +126,6 @@ export class ExtensionLoader { } catch (error) { logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); } - } removeExtension(lensExtensionId: LensExtensionId) { diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store.ts index 0885bbb730..8e88d22f38 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store.ts @@ -27,7 +27,7 @@ export class ExtensionsStore extends BaseStore { protected state = observable.map(); - isEnabled(extId: LensExtensionId) { + isEnabled(extId: LensExtensionId): boolean { const state = this.state.get(extId); // By default false, so that copied extensions are disabled by default. diff --git a/src/extensions/interfaces/registrations.ts b/src/extensions/interfaces/registrations.ts index ff51d9a824..10a55d1b78 100644 --- a/src/extensions/interfaces/registrations.ts +++ b/src/extensions/interfaces/registrations.ts @@ -6,3 +6,4 @@ export type { KubeObjectStatusRegistration } from "../registries/kube-object-sta export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; export type { StatusBarRegistration } from "../registries/status-bar-registry"; +export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler-registry"; diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index aaa6f60ac5..a00e289e17 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -2,6 +2,7 @@ import type { InstalledExtension } from "./extension-discovery"; import { action, observable, reaction } from "mobx"; import { filesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; +import { ProtocolHandlerRegistration } from "./registries/protocol-handler-registry"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -21,6 +22,8 @@ export class LensExtension { readonly manifestPath: string; readonly isBundled: boolean; + protocolHandlers: ProtocolHandlerRegistration[] = []; + @observable private isEnabled = false; constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 8b9b132114..982830d8af 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -31,8 +31,7 @@ export class LensRendererExtension extends LensExtension { /** * Defines if extension is enabled for a given cluster. Defaults to `true`. */ - // eslint-disable-next-line unused-imports/no-unused-vars-ts async isEnabledForCluster(cluster: Cluster): Promise { - return true; + return (void cluster) || true; } } diff --git a/src/extensions/registries/protocol-handler-registry.ts b/src/extensions/registries/protocol-handler-registry.ts new file mode 100644 index 0000000000..dd637818a3 --- /dev/null +++ b/src/extensions/registries/protocol-handler-registry.ts @@ -0,0 +1,44 @@ +/** + * ProtocolHandlerRegistration is the data required for an extension to register + * a handler to a specific path or dynamic path. + */ +export interface ProtocolHandlerRegistration { + pathSchema: string; + handler: RouteHandler; +} + +/** + * The collection of the dynamic parts of a URI which initiated a `lens://` + * protocol request + */ +export interface RouteParams { + /** + * the parts of the URI query string + */ + search: Record; + + /** + * the matching parts of the path. The dynamic parts of the URI path. + */ + pathname: Record; + + /** + * if the most specific path schema that is matched does not cover the whole + * of the URI's path. Then this field will be set to the remaining path + * segments. + * + * Example: + * + * If the path schema `/landing/:type` is the matched schema for the URI + * `/landing/soft/easy` then this field will be set to `"/easy"`. + */ + tail?: string; +} + +/** + * RouteHandler represents the function signature of the handler function for + * `lens://` protocol routing. + */ +export interface RouteHandler { + (params: RouteParams): void; +} diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index ed2bf4250b..9893abfa81 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -4,6 +4,7 @@ import { isDevelopment, isTestEnv } from "../common/vars"; import { delay } from "../common/utils"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; import { ipcMain } from "electron"; +import { once } from "lodash"; let installVersion: null | string = null; @@ -28,7 +29,7 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd * starts the automatic update checking * @param interval milliseconds between interval to check on, defaults to 24h */ -export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { +export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24): void { if (isDevelopment || isTestEnv) { return; } @@ -83,7 +84,7 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { } helper(); -} +}); export async function checkForUpdates(): Promise { try { diff --git a/src/main/index.ts b/src/main/index.ts index c98595fa35..ddee53bfea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,7 +4,7 @@ import "../common/system-ca"; import "../common/prometheus-providers"; import * as Mobx from "mobx"; import * as LensExtensions from "../extensions/core-api"; -import { app, autoUpdater, dialog, powerMonitor } from "electron"; +import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron"; import { appName } from "../common/vars"; import path from "path"; import { LensProxy } from "./lens-proxy"; @@ -25,6 +25,7 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension- import type { LensExtensionId } from "../extensions/lens-extension"; import { installDeveloperTools } from "./developer-tools"; import { filesystemProvisionerStore } from "./extension-filesystem"; +import { LensProtocolRouterMain } from "./protocol-handler"; import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { bindBroadcastHandlers } from "../common/ipc"; import { startUpdateChecking } from "./app-updater"; @@ -37,30 +38,54 @@ let windowManager: WindowManager; app.setName(appName); +logger.info("πŸ“Ÿ Setting as Lens as protocol client for lens://"); + +if (app.setAsDefaultProtocolClient("lens")) { + logger.info("πŸ“Ÿ succeeded βœ…"); +} else { + logger.info("πŸ“Ÿ failed ❗"); +} + if (!process.env.CICD) { app.setPath("userData", workingDir); } +if (process.env.LENS_DISABLE_GPU) { + app.disableHardwareAcceleration(); +} + mangleProxyEnv(); if (app.commandLine.getSwitchValue("proxy-server") !== "") { process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); } -const instanceLock = app.requestSingleInstanceLock(); - -if (!instanceLock) { +if (!app.requestSingleInstanceLock()) { app.exit(); +} else { + const lprm = LensProtocolRouterMain.getInstance(); + + for (const arg of process.argv) { + if (arg.toLowerCase().startsWith("lens://")) { + lprm.route(arg) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); + } + } } -app.on("second-instance", () => { +app.on("second-instance", (event, argv) => { + const lprm = LensProtocolRouterMain.getInstance(); + + for (const arg of argv) { + if (arg.toLowerCase().startsWith("lens://")) { + lprm.route(arg) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); + } + } + windowManager?.ensureMainWindow(); }); -if (process.env.LENS_DISABLE_GPU) { - app.disableHardwareAcceleration(); -} - app.on("ready", async () => { logger.info(`πŸš€ Starting Lens from "${workingDir}"`); logger.info("🐚 Syncing shell environment"); @@ -128,7 +153,19 @@ app.on("ready", async () => { logger.info("πŸ–₯️ Starting WindowManager"); windowManager = WindowManager.getInstance(proxyPort); - windowManager.whenLoaded.then(() => startUpdateChecking()); + + ipcMain.on("renderer:loaded", () => { + startUpdateChecking(); + LensProtocolRouterMain + .getInstance() + .rendererLoaded = true; + }); + + extensionLoader.whenLoaded.then(() => { + LensProtocolRouterMain + .getInstance() + .extensionsLoaded = true; + }); logger.info("🧩 Initializing extensions"); @@ -174,8 +211,8 @@ let blockQuit = true; autoUpdater.on("before-quit-for-update", () => blockQuit = false); -// Quit app on Cmd+Q (MacOS) app.on("will-quit", (event) => { + // Quit app on Cmd+Q (MacOS) logger.info("APP:QUIT"); appEventBus.emit({name: "app", action: "close"}); @@ -188,6 +225,16 @@ app.on("will-quit", (event) => { } }); +app.on("open-url", (event, rawUrl) => { + // lens:// protocol handler + event.preventDefault(); + + LensProtocolRouterMain + .getInstance() + .route(rawUrl) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl })); +}); + // Extensions-api runtime exports export const LensExtensionsApi = { ...LensExtensions, diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts new file mode 100644 index 0000000000..6b3f668079 --- /dev/null +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -0,0 +1,259 @@ +import { LensProtocolRouterMain } from "../router"; +import { noop } from "../../../common/utils"; +import { extensionsStore } from "../../../extensions/extensions-store"; +import { extensionLoader } from "../../../extensions/extension-loader"; +import * as uuid from "uuid"; +import { LensMainExtension } from "../../../extensions/core-api"; +import { broadcastMessage } from "../../../common/ipc"; +import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler"; + +jest.mock("../../../common/ipc"); + +function throwIfDefined(val: any): void { + if (val != null) { + throw val; + } +} + +describe("protocol router tests", () => { + let lpr: LensProtocolRouterMain; + + beforeEach(() => { + jest.clearAllMocks(); + (extensionsStore as any).state.clear(); + (extensionLoader as any).instances.clear(); + LensProtocolRouterMain.resetInstance(); + lpr = LensProtocolRouterMain.getInstance(); + lpr.extensionsLoaded = true; + lpr.rendererLoaded = true; + }); + + it("should throw on non-lens URLS", async () => { + try { + expect(await lpr.route("https://google.ca")).toBeUndefined(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + + it("should throw when host not internal or extension", async () => { + try { + expect(await lpr.route("lens://foobar")).toBeUndefined(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + + it("should not throw when has valid host", async () => { + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "@mirantis/minikube", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers.push({ + pathSchema: "/", + handler: noop, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" }); + + lpr.addInternalHandler("/", noop); + + try { + expect(await lpr.route("lens://app")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + + try { + expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(broadcastMessage).toHaveBeenNthCalledWith(1, ProtocolHandlerInternal, "lens://app"); + expect(broadcastMessage).toHaveBeenNthCalledWith(2, ProtocolHandlerExtension, "lens://extension/@mirantis/minikube"); + }); + + it("should call handler if matches", async () => { + let called = false; + + lpr.addInternalHandler("/page", () => { called = true; }); + + try { + expect(await lpr.route("lens://app/page")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(true); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page"); + }); + + it("should call most exact handler", async () => { + let called: any = 0; + + lpr.addInternalHandler("/page", () => { called = 1; }); + lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; }); + + try { + expect(await lpr.route("lens://app/page/foo")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe("foo"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo"); + }); + + it("should call most exact handler for an extension", async () => { + let called: any = 0; + + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "@foobar/icecream", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers + .push({ + pathSchema: "/page", + handler: () => { called = 1; }, + }, { + pathSchema: "/page/:id", + handler: params => { called = params.pathname.id; }, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + + try { + expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe("foob"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob"); + }); + + it("should work with non-org extensions", async () => { + let called: any = 0; + + { + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "@foobar/icecream", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers + .push({ + pathSchema: "/page/:id", + handler: params => { called = params.pathname.id; }, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + } + + { + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "icecream", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers + .push({ + pathSchema: "/page", + handler: () => { called = 1; }, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" }); + } + + (extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" }); + (extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" }); + + try { + expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(1); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page"); + }); + + it("should throw if urlSchema is invalid", () => { + expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError(); + }); + + it("should call most exact handler with 3 found handlers", async () => { + let called: any = 0; + + lpr.addInternalHandler("/", () => { called = 2; }); + lpr.addInternalHandler("/page", () => { called = 1; }); + lpr.addInternalHandler("/page/foo", () => { called = 3; }); + lpr.addInternalHandler("/page/bar", () => { called = 4; }); + + try { + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(3); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); + }); + + it("should call most exact handler with 2 found handlers", async () => { + let called: any = 0; + + lpr.addInternalHandler("/", () => { called = 2; }); + lpr.addInternalHandler("/page", () => { called = 1; }); + lpr.addInternalHandler("/page/bar", () => { called = 4; }); + + try { + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(1); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); + }); +}); diff --git a/src/main/protocol-handler/index.ts b/src/main/protocol-handler/index.ts new file mode 100644 index 0000000000..9ca8201129 --- /dev/null +++ b/src/main/protocol-handler/index.ts @@ -0,0 +1 @@ +export * from "./router"; diff --git a/src/main/protocol-handler/router.ts b/src/main/protocol-handler/router.ts new file mode 100644 index 0000000000..20962372b2 --- /dev/null +++ b/src/main/protocol-handler/router.ts @@ -0,0 +1,112 @@ +import logger from "../logger"; +import * as proto from "../../common/protocol-handler"; +import Url from "url-parse"; +import { LensExtension } from "../../extensions/lens-extension"; +import { broadcastMessage } from "../../common/ipc"; +import { observable, when } from "mobx"; + +export interface FallbackHandler { + (name: string): Promise; +} + +export class LensProtocolRouterMain extends proto.LensProtocolRouter { + private missingExtensionHandlers: FallbackHandler[] = []; + + @observable rendererLoaded = false; + @observable extensionsLoaded = false; + + /** + * Find the most specific registered handler, if it exists, and invoke it. + * + * This will send an IPC message to the renderer router to do the same + * in the renderer. + */ + public async route(rawUrl: string): Promise { + try { + const url = new Url(rawUrl, true); + + if (url.protocol.toLowerCase() !== "lens:") { + throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url); + } + + logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`); + + switch (url.host) { + case "app": + return this._routeToInternal(url); + case "extension": + await when(() => this.extensionsLoaded); + + return this._routeToExtension(url); + default: + throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url); + } + + } catch (error) { + if (error instanceof proto.RoutingError) { + logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url }); + } else { + logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { rawUrl }); + } + } + } + + protected async _executeMissingExtensionHandlers(extensionName: string): Promise { + for (const handler of this.missingExtensionHandlers) { + if (await handler(extensionName)) { + return true; + } + } + + return false; + } + + protected async _findMatchingExtensionByName(url: Url): Promise { + const firstAttempt = await super._findMatchingExtensionByName(url); + + if (typeof firstAttempt !== "string") { + return firstAttempt; + } + + if (await this._executeMissingExtensionHandlers(firstAttempt)) { + return super._findMatchingExtensionByName(url); + } + + return ""; + } + + protected async _routeToInternal(url: Url): Promise { + const rawUrl = url.toString(); // for sending to renderer + + super._routeToInternal(url); + + await when(() => this.rendererLoaded); + + return broadcastMessage(proto.ProtocolHandlerInternal, rawUrl); + } + + protected async _routeToExtension(url: Url): Promise { + const rawUrl = url.toString(); // for sending to renderer + + /** + * This needs to be done first, so that the missing extension handlers can + * be called before notifying the renderer. + * + * Note: this needs to clone the url because _routeToExtension modifies its + * argument. + */ + await super._routeToExtension(new Url(url.toString(), true)); + await when(() => this.rendererLoaded); + + return broadcastMessage(proto.ProtocolHandlerExtension, rawUrl); + } + + /** + * Add a function to the list which will be sequentially called if an extension + * is not found while routing to the extensions + * @param handler A function that tries to find an extension + */ + public addMissingExtensionHandler(handler: FallbackHandler): void { + this.missingExtensionHandlers.push(handler); + } +} diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index c092e186cb..691aa7c66b 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -1,5 +1,5 @@ import type { ClusterId } from "../common/cluster-store"; -import { observable, when } from "mobx"; +import { observable } from "mobx"; import { app, BrowserWindow, dialog, shell, webContents } from "electron"; import windowStateKeeper from "electron-window-state"; import { appEventBus } from "../common/event-bus"; @@ -16,9 +16,6 @@ export class WindowManager extends Singleton { protected windowState: windowStateKeeper.State; protected disposers: Record = {}; - @observable mainViewInitiallyLoaded = false; - whenLoaded = when(() => this.mainViewInitiallyLoaded); - @observable activeClusterId: ClusterId; constructor(protected proxyPort: number) { @@ -104,7 +101,6 @@ export class WindowManager extends Singleton { setTimeout(() => { appEventBus.emit({ name: "app", action: "start" }); }, 1000); - this.mainViewInitiallyLoaded = true; } catch (err) { dialog.showErrorBox("ERROR!", err.toString()); } diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index 963bd43e4e..c6f55872a9 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -12,12 +12,15 @@ import { ConfirmDialog } from "./components/confirm-dialog"; import { extensionLoader } from "../extensions/extension-loader"; import { broadcastMessage } from "../common/ipc"; import { CommandContainer } from "./components/command-palette/command-container"; +import { LensProtocolRouterRenderer } from "./protocol-handler/router"; import { registerIpcHandlers } from "./ipc"; +import { ipcRenderer } from "electron"; @observer export class LensApp extends React.Component { static async init() { extensionLoader.loadOnClusterManagerRenderer(); + LensProtocolRouterRenderer.getInstance().init(); window.addEventListener("offline", () => { broadcastMessage("network:offline"); }); @@ -26,6 +29,7 @@ export class LensApp extends React.Component { }); registerIpcHandlers(); + ipcRenderer.send("renderer:loaded"); } render() { diff --git a/src/renderer/navigation/index.ts b/src/renderer/navigation/index.ts index 94930fc994..adf2577f4e 100644 --- a/src/renderer/navigation/index.ts +++ b/src/renderer/navigation/index.ts @@ -1,8 +1,10 @@ // Navigation (renderer) import { bindEvents } from "./events"; +import { bindProtocolHandlers } from "./protocol-handlers"; export * from "./history"; export * from "./helpers"; bindEvents(); +bindProtocolHandlers(); diff --git a/src/renderer/navigation/protocol-handlers.ts b/src/renderer/navigation/protocol-handlers.ts new file mode 100644 index 0000000000..423cc70fd0 --- /dev/null +++ b/src/renderer/navigation/protocol-handlers.ts @@ -0,0 +1,10 @@ +import { LensProtocolRouterRenderer } from "../protocol-handler/router"; +import { navigate } from "./helpers"; + +export function bindProtocolHandlers() { + const lprr = LensProtocolRouterRenderer.getInstance(); + + lprr.addInternalHandler("/preferences", () => { + navigate("/preferences"); + }); +} diff --git a/src/renderer/protocol-handler/index.ts b/src/renderer/protocol-handler/index.ts new file mode 100644 index 0000000000..d18015da88 --- /dev/null +++ b/src/renderer/protocol-handler/index.ts @@ -0,0 +1 @@ +export * from "./router.ts"; diff --git a/src/renderer/protocol-handler/router.ts b/src/renderer/protocol-handler/router.ts new file mode 100644 index 0000000000..d1dc0ceafd --- /dev/null +++ b/src/renderer/protocol-handler/router.ts @@ -0,0 +1,40 @@ +import { ipcRenderer } from "electron"; +import * as proto from "../../common/protocol-handler"; +import logger from "../../main/logger"; +import Url from "url-parse"; +import { autobind } from "../utils"; + +export class LensProtocolRouterRenderer extends proto.LensProtocolRouter { + /** + * This function is needed to be called early on in the renderers lifetime. + */ + public init(): void { + ipcRenderer + .on(proto.ProtocolHandlerInternal, this.ipcInternalHandler) + .on(proto.ProtocolHandlerExtension, this.ipcExtensionHandler); + } + + @autobind() + private ipcInternalHandler(event: Electron.IpcRendererEvent, ...args: any[]): void { + if (args.length !== 1) { + return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args }); + } + + const [rawUrl] = args; + const url = new Url(rawUrl, true); + + this._routeToInternal(url); + } + + @autobind() + private ipcExtensionHandler(event: Electron.IpcRendererEvent, ...args: any[]): void { + if (args.length !== 1) { + return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args }); + } + + const [rawUrl] = args; + const url = new Url(rawUrl, true); + + this._routeToExtension(url); + } +} diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 517fd8f359..e546f94154 100755 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -1,7 +1,5 @@ // Common usage utils & helpers -export const isElectron = !!navigator.userAgent.match(/Electron/); - export * from "../../common/utils"; export * from "./cssVar"; diff --git a/yarn.lock b/yarn.lock index 2cc97b2383..bc2e606b71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1771,6 +1771,11 @@ resolved "https://registry.yarnpkg.com/@types/universal-analytics/-/universal-analytics-0.4.4.tgz#496a52b92b599a0112bec7c12414062de6ea8449" integrity sha512-9g3F0SGxVr4UDd6y07bWtFnkpSSX1Ake7U7AGHgSFrwM6pF53/fV85bfxT2JLWS/3sjLCcyzoYzQlCxpkVo7wA== +"@types/url-parse@^1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@types/url-parse/-/url-parse-1.4.3.tgz#fba49d90f834951cb000a674efee3d6f20968329" + integrity sha512-4kHAkbV/OfW2kb5BLVUuUMoumB3CP8rHqlw48aHvFy5tf9ER0AfOonBlX29l/DD68G70DmyhRlSYfQPSYpC5Vw== + "@types/uuid@^8.3.0": version "8.3.0" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" @@ -2137,6 +2142,13 @@ abbrev@1, abbrev@~1.1.1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" @@ -5336,6 +5348,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" @@ -10092,6 +10109,14 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +open@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/open/-/open-7.3.1.tgz#111119cb919ca1acd988f49685c4fdd0f4755356" + integrity sha512-f2wt9DCBKKjlFbjzGb8MOAW8LH8F0mrs1zc7KTjAJ9PZNQbfenzWbNP1VZJvw6ICMG9r14Ah6yfwPn7T7i646A== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + opener@^1.5.1: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -13754,7 +13779,7 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" -url-parse@^1.4.3: +url-parse@^1.4.3, url-parse@^1.4.7: version "1.4.7" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== From 3acbdcfd6049227a8ef8f7ddbd5bc084eb3922d9 Mon Sep 17 00:00:00 2001 From: pashevskii <53330707+pashevskii@users.noreply.github.com> Date: Thu, 25 Feb 2021 19:09:07 +0400 Subject: [PATCH 18/29] Extract chart version ignoring numbers in chart name (#2226) Signed-off-by: Pavel Ashevskiy Co-authored-by: Pavel Ashevskiy --- src/renderer/api/endpoints/helm-releases.api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/api/endpoints/helm-releases.api.ts b/src/renderer/api/endpoints/helm-releases.api.ts index fb3eae1a79..9a5a6dadd0 100644 --- a/src/renderer/api/endpoints/helm-releases.api.ts +++ b/src/renderer/api/endpoints/helm-releases.api.ts @@ -187,7 +187,7 @@ export class HelmRelease implements ItemObject { } getVersion() { - const versions = this.chart.match(/(v?\d+)[^-].*$/); + const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/); if (versions) { return versions[0]; From 090a3c2bf5172c71dd5c4094c8c9a524b2345f16 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 26 Feb 2021 07:34:54 +0200 Subject: [PATCH 19/29] Flush response headers always when proxy gets a response (#2229) * flush response header always when proxy gets a response Signed-off-by: Jari Kolehmainen * force flush only when watch param exists Signed-off-by: Jari Kolehmainen --- src/main/lens-proxy.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 177e4d11d2..7e1aa98b7e 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -120,12 +120,18 @@ export class LensProxy { protected createProxy(): httpProxy { const proxy = httpProxy.createProxyServer(); - proxy.on("proxyRes", (proxyRes, req) => { + proxy.on("proxyRes", (proxyRes, req, res) => { const retryCounterId = this.getRequestId(req); if (this.retryCounters.has(retryCounterId)) { this.retryCounters.delete(retryCounterId); } + + if (!res.headersSent && req.url) { + const url = new URL(req.url, "http://localhost"); + + if (url.searchParams.has("watch")) res.flushHeaders(); + } }); proxy.on("error", (error, req, res, target) => { From 5a9bf6c55abc8c4fc2e5f79c95e68951bfc93722 Mon Sep 17 00:00:00 2001 From: Christoph Meier Date: Fri, 26 Feb 2021 07:39:54 +0100 Subject: [PATCH 20/29] Fix(rbac): pdp should have policy group (#2132) For rbac the `PodDisruptionBudget` should use the `policy` group. Signed-off-by: Christoph MEIER --- src/common/rbac.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/rbac.ts b/src/common/rbac.ts index de242b114a..7d02e3be51 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -31,7 +31,7 @@ export const apiResources: KubeApiResource[] = [ { kind: "PersistentVolume", apiName: "persistentvolumes" }, { kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" }, { kind: "Pod", apiName: "pods" }, - { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets" }, + { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets", group: "policy" }, { kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" }, { kind: "ResourceQuota", apiName: "resourcequotas" }, { kind: "ReplicaSet", apiName: "replicasets", group: "apps" }, From a2ce429f221e4c3cb6b66e47e582306729a3f1c9 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Fri, 26 Feb 2021 14:56:51 +0200 Subject: [PATCH 21/29] Pass Lens wslenvs to terminal session on Windows (#2198) Signed-off-by: Lauri Nevala --- src/main/shell-session.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index 9e5af371f7..10a2f9ed47 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -110,6 +110,14 @@ export class ShellSession extends EventEmitter { env["SystemRoot"] = process.env.SystemRoot; env["PTYSHELL"] = process.env.SHELL || "powershell.exe"; env["PATH"] = pathStr; + env["LENS_SESSION"] = "true"; + const lensWslEnv = "KUBECONFIG/up:LENS_SESSION/u"; + + if (process.env.WSLENV != undefined) { + env["WSLENV"] = `${process.env["WSLENV"]}:${lensWslEnv}`; + } else { + env["WSLENV"] = lensWslEnv; + } } else if(typeof(process.env.SHELL) != "undefined") { env["PTYSHELL"] = process.env.SHELL; env["PATH"] = pathStr; From b3176a6fc4707a4678fca1f04a37f01448d8a0df Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 26 Feb 2021 09:33:47 -0500 Subject: [PATCH 22/29] fix protocol log line grammer (#2234) Signed-off-by: Sebastian Malton --- src/main/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index ddee53bfea..9a0b11b626 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -38,7 +38,7 @@ let windowManager: WindowManager; app.setName(appName); -logger.info("πŸ“Ÿ Setting as Lens as protocol client for lens://"); +logger.info("πŸ“Ÿ Setting Lens as protocol client for lens://"); if (app.setAsDefaultProtocolClient("lens")) { logger.info("πŸ“Ÿ succeeded βœ…"); From e837e6f1db197fba56036f9bf7af40cbdbbbda27 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 1 Mar 2021 12:35:00 +0300 Subject: [PATCH 23/29] Removing unused chart files (#2238) Signed-off-by: Alex Andreev --- .../chart/background-block.plugin.ts | 42 ----------------- .../components/chart/useRealTimeMetrics.ts | 45 ------------------- 2 files changed, 87 deletions(-) delete mode 100644 src/renderer/components/chart/background-block.plugin.ts delete mode 100644 src/renderer/components/chart/useRealTimeMetrics.ts diff --git a/src/renderer/components/chart/background-block.plugin.ts b/src/renderer/components/chart/background-block.plugin.ts deleted file mode 100644 index ff4816c4dd..0000000000 --- a/src/renderer/components/chart/background-block.plugin.ts +++ /dev/null @@ -1,42 +0,0 @@ -import ChartJS from "chart.js"; -import get from "lodash/get"; - -const defaultOptions = { - interval: 61, - coverBars: 3, - borderColor: "#44474A", - backgroundColor: "#00000033" -}; - -export const BackgroundBlock = { - options: {}, - - getOptions(chart: ChartJS) { - return get(chart, "options.plugins.BackgroundBlock"); - }, - - afterInit(chart: ChartJS) { - this.options = { - ...defaultOptions, - ...this.getOptions(chart) - }; - }, - - beforeDraw(chart: ChartJS) { - if (!chart.chartArea) return; - const { interval, coverBars, borderColor, backgroundColor } = this.options; - const { ctx, chartArea } = chart; - const { left, right, top, bottom } = chartArea; - const blockWidth = (right - left) / interval * coverBars; - - ctx.save(); - ctx.fillStyle = backgroundColor; - ctx.strokeStyle = borderColor; - ctx.fillRect(right - blockWidth, top, blockWidth, bottom - top); - ctx.beginPath(); - ctx.moveTo(right - blockWidth + 1.5, top); - ctx.lineTo(right - blockWidth + 1.5, bottom); - ctx.stroke(); - ctx.restore(); - } -}; diff --git a/src/renderer/components/chart/useRealTimeMetrics.ts b/src/renderer/components/chart/useRealTimeMetrics.ts deleted file mode 100644 index b01629e8e9..0000000000 --- a/src/renderer/components/chart/useRealTimeMetrics.ts +++ /dev/null @@ -1,45 +0,0 @@ -import moment from "moment"; -import { useState, useEffect } from "react"; -import { useInterval } from "../../hooks"; - -type IMetricValues = [number, string][]; -type IChartData = { x: number; y: string }[]; - -const defaultParams = { - fetchInterval: 15, - updateInterval: 5 -}; - -export function useRealTimeMetrics(metrics: IMetricValues, chartData: IChartData, params = defaultParams) { - const [index, setIndex] = useState(0); - const { fetchInterval, updateInterval } = params; - const rangeMetrics = metrics.slice(-updateInterval); - const steps = fetchInterval / updateInterval; - const data = [...chartData]; - - useEffect(() => { - setIndex(0); - }, [metrics]); - - useInterval(() => { - if (index < steps + 1) { - setIndex(index + steps - 1); - } - }, updateInterval * 1000); - - if (data.length && metrics.length) { - const lastTime = data[data.length - 1].x; - const values = []; - - for (let i = 0; i < 3; i++) { - values[i] = moment.unix(lastTime).add(i + 1, "m").unix(); - } - data.push( - { x: values[0], y: "0" }, - { x: values[1], y: parseFloat(rangeMetrics[index][1]).toFixed(3) }, - { x: values[2], y: "0" } - ); - } - - return data; -} From 4f74b9aabec453e090396c83875ef8bc87ab82d3 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Mon, 1 Mar 2021 17:30:22 +0200 Subject: [PATCH 24/29] Ignore clusters with invalid kubeconfig (#1956) * Ignore clusters with invalid kubeconfig Signed-off-by: Lauri Nevala * Improve error message Signed-off-by: Lauri Nevala * Mark cluster as dead if kubeconfig loading fails Signed-off-by: Lauri Nevala * Fix tests Signed-off-by: Lauri Nevala * Validate cluster object in kubeconfig when constructing cluster Signed-off-by: Lauri Nevala * Add unit tests for validateKubeConfig Signed-off-by: Lauri Nevala * Refactor validateKubeconfig unit tests Signed-off-by: Lauri Nevala * Extract ValidationOpts type Signed-off-by: Lauri Nevala * Add default value to validationOpts param Signed-off-by: Lauri Nevala * Change isDead to property Signed-off-by: Lauri Nevala * Fix lint issues Signed-off-by: Lauri Nevala * Add missing new line Signed-off-by: Lauri Nevala * Update validateKubeConfig in-code documentation Signed-off-by: Lauri Nevala * Remove isDead property Signed-off-by: Lauri Nevala * Display warning notification if invalid kubeconfig detected (#2233) * Display warning notification if invalid kubeconfig detected Signed-off-by: Lauri Nevala --- src/common/__tests__/cluster-store.test.ts | 113 ++++++++++++++++-- src/common/__tests__/kube-helpers.test.ts | 101 ++++++++++++++++ src/common/cluster-store.ts | 4 +- src/common/ipc/index.ts | 1 + src/common/ipc/invalid-kubeconfig/index.ts | 3 + src/common/kube-helpers.ts | 43 +++++-- src/main/cluster.ts | 15 ++- .../components/+add-cluster/add-cluster.tsx | 2 +- .../components/dock/create-resource.tsx | 2 +- .../notifications/notifications.tsx | 5 +- src/renderer/ipc/index.tsx | 2 + .../ipc/invalid-kubeconfig-handler.tsx | 46 +++++++ 12 files changed, 307 insertions(+), 30 deletions(-) create mode 100644 src/common/__tests__/kube-helpers.test.ts create mode 100644 src/common/ipc/invalid-kubeconfig/index.ts create mode 100644 src/renderer/ipc/invalid-kubeconfig-handler.tsx diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 6ee34388a2..d1d2f76603 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -6,6 +6,29 @@ import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; import { workspaceStore } from "../workspace-store"; const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); +const kubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://localhost + name: test +contexts: +- context: + cluster: test + user: test + name: foo +- context: + cluster: test + user: test + name: foo2 +current-context: test +kind: Config +preferences: {} +users: +- name: test + user: + token: kubeconfig-user-q4lm4:xxxyyyy +`; jest.mock("electron", () => { return { @@ -47,13 +70,13 @@ describe("empty config", () => { clusterStore.addCluster( new Cluster({ id: "foo", - contextName: "minikube", + contextName: "foo", preferences: { terminalCWD: "/tmp", icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", clusterName: "minikube" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"), + kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig), workspace: workspaceStore.currentWorkspaceId }) ); @@ -91,20 +114,20 @@ describe("empty config", () => { clusterStore.addClusters( new Cluster({ id: "prod", - contextName: "prod", + contextName: "foo", preferences: { clusterName: "prod" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"), + kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig), workspace: "workstation" }), new Cluster({ id: "dev", - contextName: "dev", + contextName: "foo2", preferences: { clusterName: "dev" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"), + kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig), workspace: "workstation" }) ); @@ -177,20 +200,20 @@ describe("config with existing clusters", () => { clusters: [ { id: "cluster1", - kubeConfig: "foo", + kubeConfigPath: kubeconfig, contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "default" }, { id: "cluster2", - kubeConfig: "foo2", + kubeConfigPath: kubeconfig, contextName: "foo2", preferences: { terminalCWD: "/foo2" } }, { id: "cluster3", - kubeConfig: "foo", + kubeConfigPath: kubeconfig, contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "foo", @@ -247,6 +270,78 @@ describe("config with existing clusters", () => { }); }); +describe("config with invalid cluster kubeconfig", () => { + beforeEach(() => { + const invalidKubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://localhost + name: test2 +contexts: +- context: + cluster: test + user: test + name: test +current-context: test +kind: Config +preferences: {} +users: +- name: test + user: + token: kubeconfig-user-q4lm4:xxxyyyy +`; + + ClusterStore.resetInstance(); + const mockOpts = { + "tmp": { + "lens-cluster-store.json": JSON.stringify({ + __internal__: { + migrations: { + version: "99.99.99" + } + }, + clusters: [ + { + id: "cluster1", + kubeConfigPath: invalidKubeconfig, + contextName: "test", + preferences: { terminalCWD: "/foo" }, + workspace: "foo", + }, + { + id: "cluster2", + kubeConfigPath: kubeconfig, + contextName: "foo", + preferences: { terminalCWD: "/foo" }, + workspace: "default" + }, + + ] + }) + } + }; + + mockFs(mockOpts); + clusterStore = ClusterStore.getInstance(); + + return clusterStore.load(); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it("does not enable clusters with invalid kubeconfig", () => { + const storedClusters = clusterStore.clustersList; + + expect(storedClusters.length).toBe(2); + expect(storedClusters[0].enabled).toBeFalsy; + expect(storedClusters[1].id).toBe("cluster2"); + expect(storedClusters[1].enabled).toBeTruthy; + }); +}); + describe("pre 2.0 config with an existing cluster", () => { beforeEach(() => { ClusterStore.resetInstance(); diff --git a/src/common/__tests__/kube-helpers.test.ts b/src/common/__tests__/kube-helpers.test.ts new file mode 100644 index 0000000000..a782772d34 --- /dev/null +++ b/src/common/__tests__/kube-helpers.test.ts @@ -0,0 +1,101 @@ +import { KubeConfig } from "@kubernetes/client-node"; +import { validateKubeConfig } from "../kube-helpers"; + +const kubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://localhost + name: test +contexts: +- context: + cluster: test + user: test + name: valid +- context: + cluster: test2 + user: test + name: invalidCluster +- context: + cluster: test + user: test2 + name: invalidUser +- context: + cluster: test + user: invalidExec + name: invalidExec +current-context: test +kind: Config +preferences: {} +users: +- name: test + user: + exec: + command: echo +- name: invalidExec + user: + exec: + command: foo +`; + +const kc = new KubeConfig(); + +describe("validateKubeconfig", () => { + beforeAll(() => { + kc.loadFromString(kubeconfig); + }); + describe("with default validation options", () => { + describe("with valid kubeconfig", () => { + it("does not raise exceptions", () => { + expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow(); + }); + }); + describe("with invalid context object", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'"); + }); + }); + + describe("with invalid cluster object", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'"); + }); + }); + + describe("with invalid user object", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'"); + }); + }); + + describe("with invalid exec command", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalidExec");}).toThrow("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig"); + }); + }); + }); + + describe("with validateCluster as false", () => { + describe("with invalid cluster object", () => { + it("does not raise exception", () => { + expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow(); + }); + }); + }); + + describe("with validateUser as false", () => { + describe("with invalid user object", () => { + it("does not raise excpetions", () => { + expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow(); + }); + }); + }); + + describe("with validateExec as false", () => { + describe("with invalid exec object", () => { + it("does not raise excpetions", () => { + expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow(); + }); + }); + }); +}); diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 4000684d16..6bf932f0f4 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -323,7 +323,7 @@ export class ClusterStore extends BaseStore { } else { cluster = new Cluster(clusterModel); - if (!cluster.isManaged) { + if (!cluster.isManaged && cluster.apiUrl) { cluster.enabled = true; } } @@ -337,7 +337,7 @@ export class ClusterStore extends BaseStore { } }); - this.activeCluster = newClusters.has(activeCluster) ? activeCluster : null; + this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null; this.clusters.replace(newClusters); this.removedClusters.replace(removedClusters); } diff --git a/src/common/ipc/index.ts b/src/common/ipc/index.ts index a34890472e..c5e864dc75 100644 --- a/src/common/ipc/index.ts +++ b/src/common/ipc/index.ts @@ -1,3 +1,4 @@ export * from "./ipc"; +export * from "./invalid-kubeconfig"; export * from "./update-available"; export * from "./type-enforced-ipc"; diff --git a/src/common/ipc/invalid-kubeconfig/index.ts b/src/common/ipc/invalid-kubeconfig/index.ts new file mode 100644 index 0000000000..9e8e7921d7 --- /dev/null +++ b/src/common/ipc/invalid-kubeconfig/index.ts @@ -0,0 +1,3 @@ +export const InvalidKubeconfigChannel = "invalid-kubeconfig"; + +export type InvalidKubeConfigArgs = [clusterId: string]; diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 02a9faef92..c2a2a8df93 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -7,6 +7,12 @@ import logger from "../main/logger"; import commandExists from "command-exists"; import { ExecValidationNotFoundError } from "./custom-errors"; +export type KubeConfigValidationOpts = { + validateCluster?: boolean; + validateUser?: boolean; + validateExec?: boolean; +}; + export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); function resolveTilde(filePath: string) { @@ -151,27 +157,42 @@ export function getNodeWarningConditions(node: V1Node) { } /** - * Validates kubeconfig supplied in the add clusters screen. At present this will just validate - * the User struct, specifically the command passed to the exec substructure. - */ -export function validateKubeConfig (config: KubeConfig) { + * Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required) + */ +export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) { // we only receive a single context, cluster & user object here so lets validate them as this // will be called when we add a new cluster to Lens - logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`); + + const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts; + + const contextObject = config.getContextObject(contextName); + + // Validate the Context Object + if (!contextObject) { + throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`); + } + + // Validate the Cluster Object + if (validateCluster && !config.getCluster(contextObject.cluster)) { + throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`); + } + + const user = config.getUser(contextObject.user); // Validate the User Object - const user = config.getCurrentUser(); - - if (user.exec) { + if (validateUser && !user) { + throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`); + } + + // Validate exec command if present + if (validateExec && user?.exec) { const execCommand = user.exec["command"]; // check if the command is absolute or not const isAbsolute = path.isAbsolute(execCommand); // validate the exec struct in the user object, start with the command field - logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`); - if (!commandExists.sync(execCommand)) { - logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`); + logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`); throw new ExecValidationNotFoundError(execCommand, isAbsolute); } } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 13c74a285e..198d24c2f9 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -4,12 +4,12 @@ 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 } from "../common/ipc"; +import { broadcastMessage, InvalidKubeconfigChannel } from "../common/ipc"; import { ContextHandler } from "./context-handler"; import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { Kubectl } from "./kubectl"; import { KubeconfigManager } from "./kubeconfig-manager"; -import { loadConfig } from "../common/kube-helpers"; +import { loadConfig, validateKubeConfig } from "../common/kube-helpers"; import request, { RequestPromiseOptions } from "request-promise-native"; import { apiResources, KubeApiResource } from "../common/rbac"; import logger from "./logger"; @@ -177,6 +177,7 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable isAdmin = false; + /** * Global watch-api accessibility , e.g. "/api/v1/services?watch=1" * @@ -256,10 +257,16 @@ export class Cluster implements ClusterModel, ClusterState { constructor(model: ClusterModel) { this.updateModel(model); - const kubeconfig = this.getKubeconfig(); - if (kubeconfig.getContextObject(this.contextName)) { + try { + 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); } } diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index ae4a3e6ace..38d03482e8 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -147,7 +147,7 @@ export class AddCluster extends React.Component { try { const kubeConfig = this.kubeContexts.get(context); - validateKubeConfig(kubeConfig); + validateKubeConfig(kubeConfig, context); return true; } catch (err) { diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx index 8ee859d2cf..01e6002309 100644 --- a/src/renderer/components/dock/create-resource.tsx +++ b/src/renderer/components/dock/create-resource.tsx @@ -52,7 +52,7 @@ export class CreateResource extends React.Component { ); if (errors.length) { - errors.forEach(Notifications.error); + errors.forEach(error => Notifications.error(error)); if (!createdResources.length) throw errors[0]; } const successMessage = ( diff --git a/src/renderer/components/notifications/notifications.tsx b/src/renderer/components/notifications/notifications.tsx index 0c1ac692cf..206102b1a3 100644 --- a/src/renderer/components/notifications/notifications.tsx +++ b/src/renderer/components/notifications/notifications.tsx @@ -21,11 +21,12 @@ export class Notifications extends React.Component { }); } - static error(message: NotificationMessage) { + static error(message: NotificationMessage, customOpts: Partial = {}) { notificationsStore.add({ message, timeout: 10000, - status: NotificationStatus.ERROR + status: NotificationStatus.ERROR, + ...customOpts }); } diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index b9644f7404..544cefbf78 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -5,6 +5,7 @@ import { Notifications, notificationsStore } from "../components/notifications"; import { Button } from "../components/button"; import { isMac } from "../../common/vars"; import * as uuid from "uuid"; +import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler"; function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void { notificationsStore.remove(notificationId); @@ -58,4 +59,5 @@ export function registerIpcHandlers() { listener: UpdateAvailableHandler, verifier: areArgsUpdateAvailableFromMain, }); + onCorrect(invalidKubeconfigHandler); } diff --git a/src/renderer/ipc/invalid-kubeconfig-handler.tsx b/src/renderer/ipc/invalid-kubeconfig-handler.tsx new file mode 100644 index 0000000000..cadf7e4e3f --- /dev/null +++ b/src/renderer/ipc/invalid-kubeconfig-handler.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { ipcRenderer, IpcRendererEvent, shell } from "electron"; +import { clusterStore } from "../../common/cluster-store"; +import { InvalidKubeConfigArgs, InvalidKubeconfigChannel } from "../../common/ipc/invalid-kubeconfig"; +import { Notifications, notificationsStore } from "../components/notifications"; +import { Button } from "../components/button"; + +export const invalidKubeconfigHandler = { + source: ipcRenderer, + channel: InvalidKubeconfigChannel, + listener: InvalidKubeconfigListener, + verifier: (args: [unknown]): args is InvalidKubeConfigArgs => { + return args.length === 1 && typeof args[0] === "string" && !!clusterStore.getById(args[0]); + }, +}; + +function InvalidKubeconfigListener(event: IpcRendererEvent, ...[clusterId]: InvalidKubeConfigArgs): void { + const notificationId = `invalid-kubeconfig:${clusterId}`; + const cluster = clusterStore.getById(clusterId); + const contextName = cluster.name !== cluster.contextName ? `(context: ${cluster.contextName})` : ""; + + Notifications.error( + ( +

+ Cluster with Invalid Kubeconfig Detected! +

Cluster {cluster.name} has invalid kubeconfig {contextName} and cannot be displayed. + Please fix the { e.preventDefault(); shell.showItemInFolder(cluster.kubeConfigPath); }}>kubeconfig manually and restart Lens + or remove the cluster.

+

Do you want to remove the cluster now?

+
+
+
+ ), + { + id: notificationId, + timeout: 0 + } + ); +} + + From 4931f681e48564ba05e9ea14e53d92720ee71ab7 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 2 Mar 2021 08:07:16 -0500 Subject: [PATCH 25/29] log JSON api calls when in DEBUG mode (#2263) Signed-off-by: Sebastian Malton --- src/renderer/api/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 1a82ce45f8..7c2b55d0e1 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -1,11 +1,11 @@ import { JsonApi, JsonApiErrorParsed } from "./json-api"; import { KubeJsonApi } from "./kube-json-api"; import { Notifications } from "../components/notifications"; -import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars"; +import { apiKubePrefix, apiPrefix, isDebugging, isDevelopment } from "../../common/vars"; export const apiBase = new JsonApi({ apiBase: apiPrefix, - debug: isDevelopment, + debug: isDevelopment || isDebugging, }); export const apiKube = new KubeJsonApi({ apiBase: apiKubePrefix, From 25a7403f3c05ecf6083c00c97da9d8b671c9d4e6 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Wed, 3 Mar 2021 15:40:19 +0200 Subject: [PATCH 26/29] Render only secret name on pod details without access to secrets (#2244) * Render only secret name on pod details without access to secrets Signed-off-by: Lauri Nevala * Preserving layout when amount of secrets passed Signed-off-by: Alex Andreev * Refactor secrets to observable map Signed-off-by: Lauri Nevala Co-authored-by: Alex Andreev --- .../+workloads-pods/pod-details-secrets.scss | 4 +-- .../+workloads-pods/pod-details-secrets.tsx | 32 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/+workloads-pods/pod-details-secrets.scss b/src/renderer/components/+workloads-pods/pod-details-secrets.scss index d1ee08c51b..f20aced60d 100644 --- a/src/renderer/components/+workloads-pods/pod-details-secrets.scss +++ b/src/renderer/components/+workloads-pods/pod-details-secrets.scss @@ -1,7 +1,7 @@ .PodDetailsSecrets { - a { + > * { display: block; - margin-bottom: $margin; + margin-bottom: var(--margin); &:last-child { margin-bottom: 0; diff --git a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx b/src/renderer/components/+workloads-pods/pod-details-secrets.tsx index af1515c1b4..b2c7cd14c3 100644 --- a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-secrets.tsx @@ -13,33 +13,49 @@ interface Props { @observer export class PodDetailsSecrets extends Component { - @observable secrets: Secret[] = []; + @observable secrets: Map = observable.map(); @disposeOnUnmount secretsLoader = autorun(async () => { const { pod } = this.props; - this.secrets = await Promise.all( + const secrets = await Promise.all( pod.getSecrets().map(secretName => secretsApi.get({ name: secretName, namespace: pod.getNs(), })) ); + + secrets.forEach(secret => secret && this.secrets.set(secret.getName(), secret)); }); render() { + const { pod } = this.props; + return (
{ - this.secrets.map(secret => { - return ( - - {secret.getName()} - - ); + pod.getSecrets().map(secretName => { + const secret = this.secrets.get(secretName); + + if (secret) { + return this.renderSecretLink(secret); + } else { + return ( + {secretName} + ); + } }) }
); } + + protected renderSecretLink(secret: Secret) { + return ( + + {secret.getName()} + + ); + } } From bcaef7938669610ccbda6bbe973e70fd134e244d Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 3 Mar 2021 14:34:35 -0500 Subject: [PATCH 27/29] Cleanup add namespace dialog (#2261) - Remove mixing of async and promises - Remove unnecessary instance close method - Use condition call syntax Signed-off-by: Sebastian Malton --- .../+namespaces/add-namespace-dialog.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx index a3b30235fa..1ad2efae59 100644 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ b/src/renderer/components/+namespaces/add-namespace-dialog.tsx @@ -33,20 +33,18 @@ export class AddNamespaceDialog extends React.Component { this.namespace = ""; }; - close = () => { - AddNamespaceDialog.close(); - }; - addNamespace = async () => { const { namespace } = this; const { onSuccess, onError } = this.props; try { - await namespaceStore.create({ name: namespace }).then(onSuccess); - this.close(); + const created = await namespaceStore.create({ name: namespace }); + + onSuccess?.(created); + AddNamespaceDialog.close(); } catch (err) { Notifications.error(err); - onError && onError(err); + onError?.(err); } }; @@ -61,9 +59,9 @@ export class AddNamespaceDialog extends React.Component { className="AddNamespaceDialog" isOpen={AddNamespaceDialog.isOpen} onOpen={this.reset} - close={this.close} + close={AddNamespaceDialog.close} > - + Date: Thu, 4 Mar 2021 15:12:28 +0300 Subject: [PATCH 28/29] Fix: preventing to render on cluster refresh (#2253) * Removing @observer decorator from Signed-off-by: Alex Andreev * Add observer wrapper to Signed-off-by: Alex Andreev * Fix eslint claim Signed-off-by: Alex Andreev * Moving extension route renderers to components Signed-off-by: Alex Andreev * Clean up Signed-off-by: Alex Andreev * Removing external observables out from App render() Signed-off-by: Alex Andreev * Fetching hosted cluster inside Command Palette Signed-off-by: Alex Andreev * Setting route lists explicitly To avoid using observable data within tabRoutes arrays Signed-off-by: Alex Andreev * Review fixes Signed-off-by: Alex Andreev --- .../components/+config/config.route.ts | 19 +++++-- .../components/+network/network.route.ts | 15 ++++-- .../+pod-security-policies/index.ts | 1 - .../pod-security-policies.route.ts | 8 --- .../components/+storage/storage.route.ts | 13 +++-- .../+user-management/user-management.route.ts | 20 ++++--- .../+user-management/user-management.tsx | 4 +- .../components/+workloads/workloads.route.ts | 20 ++++--- src/renderer/components/app.tsx | 54 ++----------------- .../command-palette/command-container.tsx | 7 ++- .../components/layout/main-layout-header.tsx | 5 +- 11 files changed, 70 insertions(+), 96 deletions(-) delete mode 100644 src/renderer/components/+pod-security-policies/pod-security-policies.route.ts diff --git a/src/renderer/components/+config/config.route.ts b/src/renderer/components/+config/config.route.ts index 8ea637c505..d269455566 100644 --- a/src/renderer/components/+config/config.route.ts +++ b/src/renderer/components/+config/config.route.ts @@ -1,12 +1,21 @@ import { RouteProps } from "react-router"; -import { Config } from "./config"; import { IURLParams } from "../../../common/utils/buildUrl"; -import { configMapsURL } from "../+config-maps/config-maps.route"; +import { configMapsRoute, configMapsURL } from "../+config-maps/config-maps.route"; +import { hpaRoute } from "../+config-autoscalers"; +import { limitRangesRoute } from "../+config-limit-ranges"; +import { pdbRoute } from "../+config-pod-disruption-budgets"; +import { resourceQuotaRoute } from "../+config-resource-quotas"; +import { secretsRoute } from "../+config-secrets"; export const configRoute: RouteProps = { - get path() { - return Config.tabRoutes.map(({ routePath }) => routePath).flat(); - } + path: [ + configMapsRoute, + secretsRoute, + resourceQuotaRoute, + limitRangesRoute, + hpaRoute, + pdbRoute + ].map(route => route.path.toString()) }; export const configURL = (params?: IURLParams) => configMapsURL(params); diff --git a/src/renderer/components/+network/network.route.ts b/src/renderer/components/+network/network.route.ts index de36bfc77a..2e5d5ffcb5 100644 --- a/src/renderer/components/+network/network.route.ts +++ b/src/renderer/components/+network/network.route.ts @@ -1,12 +1,17 @@ import { RouteProps } from "react-router"; -import { Network } from "./network"; -import { servicesURL } from "../+network-services"; +import { endpointRoute } from "../+network-endpoints"; +import { ingressRoute } from "../+network-ingresses"; +import { networkPoliciesRoute } from "../+network-policies"; +import { servicesRoute, servicesURL } from "../+network-services"; import { IURLParams } from "../../../common/utils/buildUrl"; export const networkRoute: RouteProps = { - get path() { - return Network.tabRoutes.map(({ routePath }) => routePath).flat(); - } + path: [ + servicesRoute, + endpointRoute, + ingressRoute, + networkPoliciesRoute + ].map(route => route.path.toString()) }; export const networkURL = (params?: IURLParams) => servicesURL(params); diff --git a/src/renderer/components/+pod-security-policies/index.ts b/src/renderer/components/+pod-security-policies/index.ts index c9379d3381..223affa147 100644 --- a/src/renderer/components/+pod-security-policies/index.ts +++ b/src/renderer/components/+pod-security-policies/index.ts @@ -1,3 +1,2 @@ -export * from "./pod-security-policies.route"; export * from "./pod-security-policies"; export * from "./pod-security-policy-details"; diff --git a/src/renderer/components/+pod-security-policies/pod-security-policies.route.ts b/src/renderer/components/+pod-security-policies/pod-security-policies.route.ts deleted file mode 100644 index 8bee44985e..0000000000 --- a/src/renderer/components/+pod-security-policies/pod-security-policies.route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RouteProps } from "react-router"; -import { buildURL } from "../../../common/utils/buildUrl"; - -export const podSecurityPoliciesRoute: RouteProps = { - path: "/pod-security-policies" -}; - -export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path); diff --git a/src/renderer/components/+storage/storage.route.ts b/src/renderer/components/+storage/storage.route.ts index 6ad17a4fdf..174eaea080 100644 --- a/src/renderer/components/+storage/storage.route.ts +++ b/src/renderer/components/+storage/storage.route.ts @@ -1,12 +1,15 @@ import { RouteProps } from "react-router"; -import { volumeClaimsURL } from "../+storage-volume-claims"; -import { Storage } from "./storage"; +import { storageClassesRoute } from "../+storage-classes"; +import { volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims"; +import { volumesRoute } from "../+storage-volumes"; import { IURLParams } from "../../../common/utils/buildUrl"; export const storageRoute: RouteProps = { - get path() { - return Storage.tabRoutes.map(({ routePath }) => routePath).flat(); - } + path: [ + volumeClaimsRoute, + volumesRoute, + storageClassesRoute + ].map(route => route.path.toString()) }; export const storageURL = (params?: IURLParams) => volumeClaimsURL(params); diff --git a/src/renderer/components/+user-management/user-management.route.ts b/src/renderer/components/+user-management/user-management.route.ts index 9dc17cbbd8..3acebb7899 100644 --- a/src/renderer/components/+user-management/user-management.route.ts +++ b/src/renderer/components/+user-management/user-management.route.ts @@ -1,12 +1,5 @@ import type { RouteProps } from "react-router"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; -import { UserManagement } from "./user-management"; - -export const usersManagementRoute: RouteProps = { - get path() { - return UserManagement.tabRoutes.map(({ routePath }) => routePath).flat(); - } -}; // Routes export const serviceAccountsRoute: RouteProps = { @@ -18,6 +11,18 @@ export const rolesRoute: RouteProps = { export const roleBindingsRoute: RouteProps = { path: "/role-bindings" }; +export const podSecurityPoliciesRoute: RouteProps = { + path: "/pod-security-policies" +}; + +export const usersManagementRoute: RouteProps = { + path: [ + serviceAccountsRoute, + roleBindingsRoute, + rolesRoute, + podSecurityPoliciesRoute + ].map(route => route.path.toString()) +}; // Route params export interface IServiceAccountsRouteParams { @@ -34,3 +39,4 @@ export const usersManagementURL = (params?: IURLParams) => serviceAccountsURL(pa export const serviceAccountsURL = buildURL(serviceAccountsRoute.path); export const roleBindingsURL = buildURL(roleBindingsRoute.path); export const rolesURL = buildURL(rolesRoute.path); +export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path); diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index e851d50424..480808d6a1 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -5,9 +5,9 @@ import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { Roles } from "../+user-management-roles"; import { RoleBindings } from "../+user-management-roles-bindings"; import { ServiceAccounts } from "../+user-management-service-accounts"; -import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; +import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; import { namespaceUrlParam } from "../+namespaces/namespace.store"; -import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies"; +import { PodSecurityPolicies } from "../+pod-security-policies"; import { isAllowedResource } from "../../../common/rbac"; @observer diff --git a/src/renderer/components/+workloads/workloads.route.ts b/src/renderer/components/+workloads/workloads.route.ts index 44c43c5ef9..14a0bbb07d 100644 --- a/src/renderer/components/+workloads/workloads.route.ts +++ b/src/renderer/components/+workloads/workloads.route.ts @@ -1,13 +1,6 @@ import type { RouteProps } from "react-router"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; import { KubeResource } from "../../../common/rbac"; -import { Workloads } from "./workloads"; - -export const workloadsRoute: RouteProps = { - get path() { - return Workloads.tabRoutes.map(({ routePath }) => routePath).flat(); - } -}; // Routes export const overviewRoute: RouteProps = { @@ -35,6 +28,19 @@ export const cronJobsRoute: RouteProps = { path: "/cronjobs" }; +export const workloadsRoute: RouteProps = { + path: [ + overviewRoute, + podsRoute, + deploymentsRoute, + daemonSetsRoute, + statefulSetsRoute, + replicaSetsRoute, + jobsRoute, + cronJobsRoute + ].map(route => route.path.toString()) +}; + // Route params export interface IWorkloadsOverviewRouteParams { } diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 8b7f8a527c..f5ad684595 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -91,27 +91,15 @@ export class App extends React.Component { reaction(() => this.warningsTotal, (count: number) => { broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count); }), - - reaction(() => clusterPageMenuRegistry.getRootItems(), (rootItems) => { - this.generateExtensionTabLayoutRoutes(rootItems); - }, { - fireImmediately: true - }) ]); } + @observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL(); + @computed get warningsTotal(): number { return nodesStore.getWarningsCount() + eventStore.getWarningsCount(); } - get startURL() { - if (isAllowedResource(["events", "nodes", "pods"])) { - return clusterURL(); - } - - return workloadsURL(); - } - getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { const routes: TabLayoutRoute[] = []; @@ -152,38 +140,6 @@ export class App extends React.Component { }); } - @observable extensionRoutes: Map = new Map(); - - generateExtensionTabLayoutRoutes(rootItems: ClusterPageMenuRegistration[]) { - rootItems.forEach((menu, index) => { - let route = this.extensionRoutes.get(menu); - - if (!route) { - const tabRoutes = this.getTabLayoutRoutes(menu); - - if (tabRoutes.length > 0) { - const pageComponent = () => ; - - route = tab.routePath)}/>; - this.extensionRoutes.set(menu, route); - } else { - const page = clusterPageRegistry.getByPageTarget(menu.target); - - if (page) { - route = ; - this.extensionRoutes.set(menu, route); - } - } - } - }); - - for (const menu of this.extensionRoutes.keys()) { - if (!rootItems.includes(menu)) { - this.extensionRoutes.delete(menu); - } - } - } - renderExtensionRoutes() { return clusterPageRegistry.getItems().map((page, index) => { const menu = clusterPageMenuRegistry.getByPage(page); @@ -195,8 +151,6 @@ export class App extends React.Component { } render() { - const cluster = getHostedCluster(); - return ( @@ -215,7 +169,7 @@ export class App extends React.Component { {this.renderExtensionTabLayoutRoutes()} {this.renderExtensionRoutes()} - + @@ -228,7 +182,7 @@ export class App extends React.Component { - + ); diff --git a/src/renderer/components/command-palette/command-container.tsx b/src/renderer/components/command-palette/command-container.tsx index 34ab71ee67..c6950bc5b9 100644 --- a/src/renderer/components/command-palette/command-container.tsx +++ b/src/renderer/components/command-palette/command-container.tsx @@ -10,7 +10,6 @@ import { CommandDialog } from "./command-dialog"; import { CommandRegistration, commandRegistry } from "../../../extensions/registries/command-registry"; import { clusterStore } from "../../../common/cluster-store"; import { workspaceStore } from "../../../common/workspace-store"; -import { Cluster } from "../../../main/cluster"; export type CommandDialogEvent = { component: React.ReactElement @@ -29,7 +28,7 @@ export class CommandOverlay { } @observer -export class CommandContainer extends React.Component<{cluster?: Cluster}> { +export class CommandContainer extends React.Component<{ clusterId?: string }> { @observable.ref commandComponent: React.ReactElement; private escHandler(event: KeyboardEvent) { @@ -56,8 +55,8 @@ export class CommandContainer extends React.Component<{cluster?: Cluster}> { } componentDidMount() { - if (this.props.cluster) { - subscribeToBroadcast(`command-palette:run-action:${this.props.cluster.id}`, (event, commandId: string) => { + if (this.props.clusterId) { + subscribeToBroadcast(`command-palette:run-action:${this.props.clusterId}`, (event, commandId: string) => { const command = this.findCommandById(commandId); if (command) { diff --git a/src/renderer/components/layout/main-layout-header.tsx b/src/renderer/components/layout/main-layout-header.tsx index 6753f8d262..570c04a1f9 100644 --- a/src/renderer/components/layout/main-layout-header.tsx +++ b/src/renderer/components/layout/main-layout-header.tsx @@ -1,3 +1,4 @@ +import { observer } from "mobx-react"; import React from "react"; import { clusterSettingsURL } from "../+cluster-settings"; @@ -11,7 +12,7 @@ interface Props { className?: string } -export function MainLayoutHeader({ cluster, className }: Props) { +export const MainLayoutHeader = observer(({ cluster, className }: Props) => { return (
{cluster.name} @@ -29,4 +30,4 @@ export function MainLayoutHeader({ cluster, className }: Props) { />
); -} +}); From 35e60654875cae6623af665ea133f1855fbf30ad Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 4 Mar 2021 08:38:07 -0500 Subject: [PATCH 29/29] Broadcast update available only after downloading update (#2232) * broadcast update available only after downloading update * remove unnecessary downloads, force silent and restart modes if user chooses to install update immediately * add app.exit() for installing (fixes bug on Windows) Signed-off-by: Sebastian Malton --- src/main/app-updater.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 9893abfa81..8f31df8b57 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -3,8 +3,8 @@ import logger from "./logger"; import { isDevelopment, isTestEnv } from "../common/vars"; import { delay } from "../common/utils"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; -import { ipcMain } from "electron"; import { once } from "lodash"; +import { app, ipcMain } from "electron"; let installVersion: null | string = null; @@ -12,13 +12,11 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd if (arg.doUpdate) { if (arg.now) { logger.info(`${AutoUpdateLogPrefix}: User chose to update now`); - autoUpdater.on("update-downloaded", () => autoUpdater.quitAndInstall()); - autoUpdater.downloadUpdate().catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error })); + autoUpdater.quitAndInstall(true, true); + app.exit(); // this is needed for the installer not to fail on windows. } else { logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`); autoUpdater.autoInstallOnAppQuit = true; - autoUpdater.downloadUpdate() - .catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download update`, { error })); } } else { logger.info(`${AutoUpdateLogPrefix}: User chose not to update`); @@ -39,10 +37,10 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 autoUpdater.autoInstallOnAppQuit = false; autoUpdater - .on("update-available", (args: UpdateInfo) => { + .on("update-available", (info: UpdateInfo) => { if (autoUpdater.autoInstallOnAppQuit) { // a previous auto-update loop was completed with YES+LATER, check if same version - if (installVersion === args.version) { + if (installVersion === info.version) { // same version, don't broadcast return; } @@ -54,10 +52,14 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 * didn't ask for. */ autoUpdater.autoInstallOnAppQuit = false; - installVersion = args.version; + installVersion = info.version; + autoUpdater.downloadUpdate() + .catch(error => logger.error(`${AutoUpdateLogPrefix}: failed to download update`, { error: String(error) })); + }) + .on("update-downloaded", (info: UpdateInfo) => { try { - const backchannel = `auto-update:${args.version}`; + const backchannel = `auto-update:${info.version}`; ipcMain.removeAllListeners(backchannel); // only one handler should be present @@ -68,8 +70,8 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 listener: handleAutoUpdateBackChannel, verifier: areArgsUpdateAvailableToBackchannel, }); - logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: args.version }); - broadcastMessage(UpdateAvailableChannel, backchannel, args); + logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version }); + broadcastMessage(UpdateAvailableChannel, backchannel, info); } catch (error) { logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); installVersion = undefined;