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

Merge remote-tracking branch 'origin/master' into pages-url-params

# Conflicts:
#	src/renderer/components/+workloads-replicasets/replicasets.tsx
#	src/renderer/components/+workloads/workloads.tsx
This commit is contained in:
Roman 2020-12-10 20:09:46 +02:00
commit 3a4a07ca7f
34 changed files with 1213 additions and 200 deletions

View File

@ -47,7 +47,7 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
return ( return (
<Component.MenuItem key={name} onClick={Util.prevDefault(() => this.showLogs(container))} className="flex align-center"> <Component.MenuItem key={name} onClick={Util.prevDefault(() => this.showLogs(container))} className="flex align-center">
{brick} {brick}
{name} <span>{name}</span>
</Component.MenuItem> </Component.MenuItem>
); );
}) })

View File

@ -54,7 +54,7 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
return ( return (
<Component.MenuItem key={name} onClick={Util.prevDefault(() => this.execShell(name))} className="flex align-center"> <Component.MenuItem key={name} onClick={Util.prevDefault(() => this.execShell(name))} className="flex align-center">
<Component.StatusBrick/> <Component.StatusBrick/>
{name} <span>{name}</span>
</Component.MenuItem> </Component.MenuItem>
); );
}) })

View File

@ -273,6 +273,12 @@ describe("Lens integration tests", () => {
expectedSelector: "h5.title", expectedSelector: "h5.title",
expectedText: "Stateful Sets" expectedText: "Stateful Sets"
}, },
{
name: "ReplicaSets",
href: "replicasets",
expectedSelector: "h5.title",
expectedText: "Replica Sets"
},
{ {
name: "Jobs", name: "Jobs",
href: "jobs", href: "jobs",

View File

@ -140,6 +140,43 @@ msgstr "Add field"
msgid "Adding helm branch <0>{0}</0> has failed: {1}" msgid "Adding helm branch <0>{0}</0> has failed: {1}"
msgstr "Adding helm branch <0>{0}</0> has failed: {1}" msgstr "Adding helm branch <0>{0}</0> has failed: {1}"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:91
msgid "Helm repository <0>{0}</0> has added"
msgstr "Helm repository <0>{0}</0> has added"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Skip TLS certificate checks for the repository"
msgstr "Skip TLS certificate checks for the repository"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Key file"
msgstr "Key file"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:129
msgid "Ca file"
msgstr "Ca file"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:130
msgid "Cerificate file"
msgstr "Cerificate file"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:134
msgid "Username"
msgstr "Username"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:140
msgid "Password"
msgstr "Password"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "Helm repo name"
msgstr "Helm repo name"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "More"
msgstr "More"
#: src/renderer/components/+preferences/preferences.tsx:108 #: src/renderer/components/+preferences/preferences.tsx:108
#~ msgid "Adding repo <0>{0}</0> has failed: {1}" #~ msgid "Adding repo <0>{0}</0> has failed: {1}"
#~ msgstr "Adding repo <0>{0}</0> has failed: {1}" #~ msgstr "Adding repo <0>{0}</0> has failed: {1}"
@ -186,6 +223,7 @@ msgstr "Affinities"
#: src/renderer/components/+workloads-pods/pods.tsx:78 #: src/renderer/components/+workloads-pods/pods.tsx:78
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:51 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:51
#: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:56
msgid "Age" msgid "Age"
msgstr "Age" msgstr "Age"
@ -729,6 +767,10 @@ msgstr "Cron Jobs"
msgid "CronJobs" msgid "CronJobs"
msgstr "CronJobs" msgstr "CronJobs"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:54
msgid "Current"
msgstr "Current"
#: src/renderer/components/+config-autoscalers/hpa-details.tsx:50 #: src/renderer/components/+config-autoscalers/hpa-details.tsx:50
msgid "Current / Target" msgid "Current / Target"
msgstr "Current / Target" msgstr "Current / Target"
@ -740,6 +782,7 @@ msgstr "Current Healthy"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:104
msgid "Current replica scale: {currentReplicas}" msgid "Current replica scale: {currentReplicas}"
msgstr "Current replica scale: {currentReplicas}" msgstr "Current replica scale: {currentReplicas}"
@ -824,6 +867,10 @@ msgstr "Deployments"
msgid "Description" msgid "Description"
msgstr "Description" msgstr "Description"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:53
msgid "Desired"
msgstr "Desired"
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx:42 #: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx:42
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx:41 #: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx:41
msgid "Desired Healthy" msgid "Desired Healthy"
@ -831,6 +878,7 @@ msgstr "Desired Healthy"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:108
msgid "Desired number of replicas" msgid "Desired number of replicas"
msgstr "Desired number of replicas" msgstr "Desired number of replicas"
@ -1095,6 +1143,7 @@ msgstr "Hide"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:131
msgid "High number of replicas may cause cluster performance issues" msgid "High number of replicas may cause cluster performance issues"
msgstr "High number of replicas may cause cluster performance issues" msgstr "High number of replicas may cause cluster performance issues"
@ -1546,6 +1595,7 @@ msgstr "Mounts"
#: src/renderer/components/+workspaces/workspaces.tsx:135 #: src/renderer/components/+workspaces/workspaces.tsx:135
#: src/renderer/components/dock/edit-resource.tsx:89 #: src/renderer/components/dock/edit-resource.tsx:89
#: src/renderer/components/kube-object/kube-object-meta.tsx:20 #: src/renderer/components/kube-object/kube-object-meta.tsx:20
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:50
msgid "Name" msgid "Name"
msgstr "Name" msgstr "Name"
@ -1595,6 +1645,7 @@ msgstr "Names"
#: src/renderer/components/item-object-list/page-filters-select.tsx:57 #: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23 #: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:52
msgid "Namespace" msgid "Namespace"
msgstr "Namespace" msgstr "Namespace"
@ -2004,6 +2055,7 @@ msgstr "Read-only Root Filesystem"
msgid "Readiness" msgid "Readiness"
msgstr "Readiness" msgstr "Readiness"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:55
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready" msgid "Ready"
msgstr "Ready" msgstr "Ready"
@ -2119,6 +2171,10 @@ msgstr "Removing helm branch <0>{0}</0> has failed: {1}"
msgid "Replicas" msgid "Replicas"
msgstr "Replicas" msgstr "Replicas"
#: src/renderer/components/+workloads/workloads.tsx:70
msgid "ReplicaSets"
msgstr "ReplicaSets"
#: src/renderer/components/dock/install-chart.tsx:119 #: src/renderer/components/dock/install-chart.tsx:119
msgid "Repo/Name" msgid "Repo/Name"
msgstr "Repo/Name" msgstr "Repo/Name"
@ -2310,6 +2366,7 @@ msgstr "Save"
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
#: src/renderer/components/+workloads-deployments/deployments.tsx:83 #: src/renderer/components/+workloads-deployments/deployments.tsx:83
#: src/renderer/components/+workloads-deployments/deployments.tsx:84 #: src/renderer/components/+workloads-deployments/deployments.tsx:84
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:160
msgid "Scale" msgid "Scale"
msgstr "Scale" msgstr "Scale"
@ -2317,6 +2374,10 @@ msgstr "Scale"
msgid "Scale Deployment <0>{deploymentName}</0>" msgid "Scale Deployment <0>{deploymentName}</0>"
msgstr "Scale Deployment <0>{deploymentName}</0>" msgstr "Scale Deployment <0>{deploymentName}</0>"
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:143
msgid "Scale Replica Set <0>{replicaSetName}</0>"
msgstr "Scale Replica Set <0>{replicaSetName}</0>"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
msgid "Scale Stateful Set <0>{statefulSetName}</0>" msgid "Scale Stateful Set <0>{statefulSetName}</0>"
msgstr "Scale Stateful Set <0>{statefulSetName}</0>" msgstr "Scale Stateful Set <0>{statefulSetName}</0>"

View File

@ -140,6 +140,42 @@ msgstr ""
msgid "Adding helm branch <0>{0}</0> has failed: {1}" msgid "Adding helm branch <0>{0}</0> has failed: {1}"
msgstr "" msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:91
msgid "Helm repository <0>{0}</0> has added"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Skip TLS certificate checks for the repository"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Key file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:129
msgid "Ca file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:130
msgid "Cerificate file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:134
msgid "Username"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:140
msgid "Password"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "Helm repo name"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "More"
msgstr ""
#: src/renderer/components/+preferences/preferences.tsx:108 #: src/renderer/components/+preferences/preferences.tsx:108
#~ msgid "Adding repo <0>{0}</0> has failed: {1}" #~ msgid "Adding repo <0>{0}</0> has failed: {1}"
#~ msgstr "" #~ msgstr ""
@ -186,6 +222,7 @@ msgstr ""
#: src/renderer/components/+workloads-pods/pods.tsx:78 #: src/renderer/components/+workloads-pods/pods.tsx:78
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:51 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:51
#: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:56
msgid "Age" msgid "Age"
msgstr "" msgstr ""
@ -725,6 +762,10 @@ msgstr ""
msgid "CronJobs" msgid "CronJobs"
msgstr "" msgstr ""
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:54
msgid "Current"
msgstr ""
#: src/renderer/components/+config-autoscalers/hpa-details.tsx:50 #: src/renderer/components/+config-autoscalers/hpa-details.tsx:50
msgid "Current / Target" msgid "Current / Target"
msgstr "" msgstr ""
@ -736,6 +777,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:104
msgid "Current replica scale: {currentReplicas}" msgid "Current replica scale: {currentReplicas}"
msgstr "" msgstr ""
@ -820,6 +862,10 @@ msgstr ""
msgid "Description" msgid "Description"
msgstr "" msgstr ""
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:53
msgid "Desired"
msgstr ""
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx:42 #: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx:42
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx:41 #: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx:41
msgid "Desired Healthy" msgid "Desired Healthy"
@ -827,6 +873,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:108
msgid "Desired number of replicas" msgid "Desired number of replicas"
msgstr "" msgstr ""
@ -1086,6 +1133,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
#: src/renderer/components/+workloads-replicaset/replicaset-scale-dialog.tsx:131
msgid "High number of replicas may cause cluster performance issues" msgid "High number of replicas may cause cluster performance issues"
msgstr "" msgstr ""
@ -1537,6 +1585,7 @@ msgstr ""
#: src/renderer/components/+workspaces/workspaces.tsx:135 #: src/renderer/components/+workspaces/workspaces.tsx:135
#: src/renderer/components/dock/edit-resource.tsx:89 #: src/renderer/components/dock/edit-resource.tsx:89
#: src/renderer/components/kube-object/kube-object-meta.tsx:20 #: src/renderer/components/kube-object/kube-object-meta.tsx:20
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:50
msgid "Name" msgid "Name"
msgstr "" msgstr ""
@ -1586,6 +1635,7 @@ msgstr ""
#: src/renderer/components/item-object-list/page-filters-select.tsx:57 #: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23 #: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:52
msgid "Namespace" msgid "Namespace"
msgstr "" msgstr ""
@ -1987,6 +2037,7 @@ msgstr ""
msgid "Readiness" msgid "Readiness"
msgstr "" msgstr ""
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:55
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready" msgid "Ready"
msgstr "" msgstr ""
@ -2102,6 +2153,10 @@ msgstr ""
msgid "Replicas" msgid "Replicas"
msgstr "" msgstr ""
#: src/renderer/components/+workloads/workloads.tsx:70
msgid "ReplicaSets"
msgstr ""
#: src/renderer/components/dock/install-chart.tsx:119 #: src/renderer/components/dock/install-chart.tsx:119
msgid "Repo/Name" msgid "Repo/Name"
msgstr "" msgstr ""
@ -2293,6 +2348,7 @@ msgstr ""
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
#: src/renderer/components/+workloads-deployments/deployments.tsx:83 #: src/renderer/components/+workloads-deployments/deployments.tsx:83
#: src/renderer/components/+workloads-deployments/deployments.tsx:84 #: src/renderer/components/+workloads-deployments/deployments.tsx:84
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:160
msgid "Scale" msgid "Scale"
msgstr "" msgstr ""
@ -2300,6 +2356,10 @@ msgstr ""
msgid "Scale Deployment <0>{deploymentName}</0>" msgid "Scale Deployment <0>{deploymentName}</0>"
msgstr "" msgstr ""
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:143
msgid "Scale Replica Set <0>{replicaSetName}</0>"
msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
msgid "Scale Stateful Set <0>{statefulSetName}</0>" msgid "Scale Stateful Set <0>{statefulSetName}</0>"
msgstr "" msgstr ""

View File

@ -141,6 +141,42 @@ msgstr "Добавить поле"
msgid "Adding helm branch <0>{0}</0> has failed: {1}" msgid "Adding helm branch <0>{0}</0> has failed: {1}"
msgstr "" msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:91
msgid "Helm repository <0>{0}</0> has added"
msgstr "Helm репозиторий <0>{0}</0> добавлен"
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Skip TLS certificate checks for the repository"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:128
msgid "Key file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:129
msgid "Ca file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:130
msgid "Cerificate file"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:134
msgid "Username"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:140
msgid "Password"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "Helm repo name"
msgstr ""
#: src/renderer/components/+preferences/add-helm-repo-dialog.tsx:163
msgid "More"
msgstr ""
#: src/renderer/components/+preferences/preferences.tsx:108 #: src/renderer/components/+preferences/preferences.tsx:108
#~ msgid "Adding repo <0>{0}</0> has failed: {1}" #~ msgid "Adding repo <0>{0}</0> has failed: {1}"
#~ msgstr "" #~ msgstr ""
@ -187,6 +223,7 @@ msgstr "Аффинитеты"
#: src/renderer/components/+workloads-pods/pods.tsx:78 #: src/renderer/components/+workloads-pods/pods.tsx:78
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:51 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:51
#: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:56
msgid "Age" msgid "Age"
msgstr "Возраст" msgstr "Возраст"
@ -730,6 +767,10 @@ msgstr ""
msgid "CronJobs" msgid "CronJobs"
msgstr "CronJobs" msgstr "CronJobs"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:54
msgid "Current"
msgstr "Текущее"
#: src/renderer/components/+config-autoscalers/hpa-details.tsx:50 #: src/renderer/components/+config-autoscalers/hpa-details.tsx:50
msgid "Current / Target" msgid "Current / Target"
msgstr "Текущее / Цель" msgstr "Текущее / Цель"
@ -741,6 +782,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:101
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:103
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:104
msgid "Current replica scale: {currentReplicas}" msgid "Current replica scale: {currentReplicas}"
msgstr "Текущий размер реплики: {currentReplicas}" msgstr "Текущий размер реплики: {currentReplicas}"
@ -825,6 +867,10 @@ msgstr "Deployments"
msgid "Description" msgid "Description"
msgstr "Описание" msgstr "Описание"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:53
msgid "Desired"
msgstr "Желаемое"
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx:42 #: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets-details.tsx:42
#: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx:41 #: src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx:41
msgid "Desired Healthy" msgid "Desired Healthy"
@ -832,6 +878,7 @@ msgstr ""
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:105
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:107
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:108
msgid "Desired number of replicas" msgid "Desired number of replicas"
msgstr "Нужный уровень реплик" msgstr "Нужный уровень реплик"
@ -1096,6 +1143,7 @@ msgstr "Скрыть"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:127
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:116
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:131
msgid "High number of replicas may cause cluster performance issues" msgid "High number of replicas may cause cluster performance issues"
msgstr "Большое количество реплик может вызвать проблемы с производительностью кластера" msgstr "Большое количество реплик может вызвать проблемы с производительностью кластера"
@ -1547,6 +1595,7 @@ msgstr "Установки"
#: src/renderer/components/+workspaces/workspaces.tsx:135 #: src/renderer/components/+workspaces/workspaces.tsx:135
#: src/renderer/components/dock/edit-resource.tsx:89 #: src/renderer/components/dock/edit-resource.tsx:89
#: src/renderer/components/kube-object/kube-object-meta.tsx:20 #: src/renderer/components/kube-object/kube-object-meta.tsx:20
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:50
msgid "Name" msgid "Name"
msgstr "Имя" msgstr "Имя"
@ -1596,6 +1645,7 @@ msgstr ""
#: src/renderer/components/item-object-list/page-filters-select.tsx:57 #: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23 #: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:52
msgid "Namespace" msgid "Namespace"
msgstr "Namespace" msgstr "Namespace"
@ -2005,6 +2055,7 @@ msgstr ""
msgid "Readiness" msgid "Readiness"
msgstr "Готовность" msgstr "Готовность"
#: src/renderer/components/+workloads-replicasets/replicasets.tsx:55
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready" msgid "Ready"
msgstr "Готовы" msgstr "Готовы"
@ -2120,6 +2171,10 @@ msgstr ""
msgid "Replicas" msgid "Replicas"
msgstr "Реплики" msgstr "Реплики"
#: src/renderer/components/+workloads/workloads.tsx:70
msgid "ReplicaSets"
msgstr "ReplicaSets"
#: src/renderer/components/dock/install-chart.tsx:119 #: src/renderer/components/dock/install-chart.tsx:119
msgid "Repo/Name" msgid "Repo/Name"
msgstr "Репозиторий/Имя" msgstr "Репозиторий/Имя"
@ -2311,6 +2366,7 @@ msgstr "Сохранить"
#: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128 #: src/renderer/components/+workloads-deployments/deployment-scale-dialog.tsx:128
#: src/renderer/components/+workloads-deployments/deployments.tsx:83 #: src/renderer/components/+workloads-deployments/deployments.tsx:83
#: src/renderer/components/+workloads-deployments/deployments.tsx:84 #: src/renderer/components/+workloads-deployments/deployments.tsx:84
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:160
msgid "Scale" msgid "Scale"
msgstr "Масштабировать" msgstr "Масштабировать"
@ -2318,6 +2374,10 @@ msgstr "Масштабировать"
msgid "Scale Deployment <0>{deploymentName}</0>" msgid "Scale Deployment <0>{deploymentName}</0>"
msgstr "Масштабировать Deployment <0>{deploymentName}</0>" msgstr "Масштабировать Deployment <0>{deploymentName}</0>"
#: src/renderer/components/+workloads-replicasets/replicaset-scale-dialog.tsx:143
msgid "Scale Replica Set <0>{replicaSetName}</0>"
msgstr "Масштабировать Replica Set <0>{replicaSetName}</0>"
#: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139 #: src/renderer/components/+workloads-statefulsets/statefulset-scale-dialog.tsx:139
msgid "Scale Stateful Set <0>{statefulSetName}</0>" msgid "Scale Stateful Set <0>{statefulSetName}</0>"
msgstr "Масштабировать Stateful Set <0>{statefulSetName}</0>" msgstr "Масштабировать Stateful Set <0>{statefulSetName}</0>"

View File

@ -31,6 +31,7 @@ export const apiResources: KubeApiResource[] = [
{ resource: "poddisruptionbudgets" }, { resource: "poddisruptionbudgets" },
{ resource: "podsecuritypolicies" }, { resource: "podsecuritypolicies" },
{ resource: "resourcequotas" }, { resource: "resourcequotas" },
{ resource: "replicasets", group: "apps" },
{ resource: "secrets" }, { resource: "secrets" },
{ resource: "services" }, { resource: "services" },
{ resource: "statefulsets", group: "apps" }, { resource: "statefulsets", group: "apps" },

View File

@ -0,0 +1,97 @@
import { watch } from "chokidar";
import { join, normalize } from "path";
import { ExtensionDiscovery, InstalledExtension } from "../extension-discovery";
jest.mock("../../common/ipc");
jest.mock("fs-extra");
jest.mock("chokidar", () => ({
watch: jest.fn()
}));
jest.mock("../extension-installer", () => ({
extensionInstaller: {
extensionPackagesRoot: "",
installPackages: jest.fn()
}
}));
const mockedWatch = watch as jest.MockedFunction<typeof watch>;
describe("ExtensionDiscovery", () => {
it("emits add for added extension", async done => {
globalThis.__non_webpack_require__.mockImplementation(() => ({
name: "my-extension"
}));
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
})
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = new ExtensionDiscovery();
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.initMain();
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
expect(extension).toEqual({
absolutePath: expect.any(String),
id: normalize("node_modules/my-extension/package.json"),
isBundled: false,
isEnabled: false,
manifest: {
name: "my-extension",
},
manifestPath: normalize("node_modules/my-extension/package.json"),
});
done();
});
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/package.json"));
});
it("doesn't emit add for added file under extension", async done => {
let addHandler: (filePath: string) => void;
const mockWatchInstance: any = {
on: jest.fn((event: string, handler: typeof addHandler) => {
if (event === "add") {
addHandler = handler;
}
return mockWatchInstance;
})
};
mockedWatch.mockImplementationOnce(() =>
(mockWatchInstance) as any
);
const extensionDiscovery = new ExtensionDiscovery();
// Need to force isLoaded to be true so that the file watching is started
extensionDiscovery.isLoaded = true;
await extensionDiscovery.initMain();
const onAdd = jest.fn();
extensionDiscovery.events.on("add", onAdd);
addHandler(join(extensionDiscovery.localFolderPath, "/my-extension/node_modules/dep/package.json"));
setTimeout(() => {
expect(onAdd).not.toHaveBeenCalled();
done();
}, 10);
});
});

View File

@ -1,4 +1,4 @@
import chokidar from "chokidar"; import { watch } from "chokidar";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import fs from "fs-extra"; import fs from "fs-extra";
@ -138,7 +138,7 @@ export class ExtensionDiscovery {
await this.whenLoaded; await this.whenLoaded;
// chokidar works better than fs.watch // chokidar works better than fs.watch
chokidar.watch(this.localFolderPath, { watch(this.localFolderPath, {
// For adding and removing symlinks to work, the depth has to be 1. // For adding and removing symlinks to work, the depth has to be 1.
depth: 1, depth: 1,
// Try to wait until the file has been completely copied. // Try to wait until the file has been completely copied.
@ -155,14 +155,26 @@ export class ExtensionDiscovery {
.on("unlinkDir", this.handleWatchUnlinkDir); .on("unlinkDir", this.handleWatchUnlinkDir);
} }
handleWatchFileAdd = async (filePath: string) => { handleWatchFileAdd = async (manifestPath: string) => {
if (path.basename(filePath) === manifestFilename) { // e.g. "foo/package.json"
const relativePath = path.relative(this.localFolderPath, manifestPath);
// Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies
// that the added file is in a folder under local folder path.
// This safeguards against a file watch being triggered under a sub-directory which is not an extension.
const isUnderLocalFolderPath = relativePath.split(path.sep).length === 2;
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try { try {
const absPath = path.dirname(filePath); const absPath = path.dirname(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson // this.loadExtensionFromPath updates this.packagesJson
const extension = await this.loadExtensionFromPath(absPath); const extension = await this.loadExtensionFromFolder(absPath);
if (extension) { if (extension) {
// Remove a broken symlink left by a previous installation if it exists.
await this.removeSymlinkByManifestPath(manifestPath);
// Install dependencies for the new extension // Install dependencies for the new extension
await this.installPackages(); await this.installPackages();
@ -190,6 +202,9 @@ export class ExtensionDiscovery {
.find(([, extensionFolder]) => filePath === extensionFolder)?.[0]; .find(([, extensionFolder]) => filePath === extensionFolder)?.[0];
if (extensionName !== undefined) { if (extensionName !== undefined) {
// If the extension is deleted manually while the application is running, also remove the symlink
await this.removeSymlinkByPackageName(extensionName);
delete this.packagesJson.dependencies[extensionName]; delete this.packagesJson.dependencies[extensionName];
// Reinstall dependencies to remove the extension from package.json // Reinstall dependencies to remove the extension from package.json
@ -197,7 +212,7 @@ export class ExtensionDiscovery {
// The path to the manifest file is the lens extension id // The path to the manifest file is the lens extension id
// Note that we need to use the symlinked path // Note that we need to use the symlinked path
const lensExtensionId = path.join(this.nodeModulesPath, extensionName, "package.json"); const lensExtensionId = path.join(this.nodeModulesPath, extensionName, manifestFilename);
logger.info(`${logModule} removed extension ${extensionName}`); logger.info(`${logModule} removed extension ${extensionName}`);
this.events.emit("remove", lensExtensionId as LensExtensionId); this.events.emit("remove", lensExtensionId as LensExtensionId);
@ -208,18 +223,34 @@ export class ExtensionDiscovery {
}; };
/** /**
* Uninstalls extension by path. * Remove the symlink under node_modules if exists.
* The application will detect the folder unlink and remove the extension from the UI automatically. * If we don't remove the symlink, the uninstall would leave a non-working symlink,
* @param absolutePath Path to the non-symlinked folder of the extension * which wouldn't be fixed if the extension was reinstalled, causing the extension not to work.
* @param name e.g. "@mirantis/lens-extension-cc"
*/ */
async uninstallExtension(absolutePath: string) { removeSymlinkByPackageName(name: string) {
logger.info(`${logModule} Uninstalling ${absolutePath}`); return fs.remove(this.getInstalledPath(name));
}
const exists = await fs.pathExists(absolutePath); /**
* Remove the symlink under node_modules if it exists.
* @param manifestPath Path to package.json
*/
removeSymlinkByManifestPath(manifestPath: string) {
const manifestJson = __non_webpack_require__(manifestPath);
if (!exists) { return this.removeSymlinkByPackageName(manifestJson.name);
throw new Error(`Extension path ${absolutePath} doesn't exist`); }
}
/**
* Uninstalls extension.
* The application will detect the folder unlink and remove the extension from the UI automatically.
* @param extension Extension to unistall.
*/
async uninstallExtension({ absolutePath, manifest }: InstalledExtension) {
logger.info(`${logModule} Uninstalling ${manifest.name}`);
await this.removeSymlinkByPackageName(manifest.name);
// fs.remove does nothing if the path doesn't exist anymore // fs.remove does nothing if the path doesn't exist anymore
await fs.remove(absolutePath); await fs.remove(absolutePath);
@ -260,6 +291,26 @@ export class ExtensionDiscovery {
return extensions; return extensions;
} }
/**
* Returns the symlinked path to the extension folder,
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension"
*/
protected getInstalledPath(name: string) {
return path.join(this.nodeModulesPath, name);
}
/**
* Returns the symlinked path to the package.json,
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension/package.json"
*/
protected getInstalledManifestPath(name: string) {
return path.join(this.getInstalledPath(name), manifestFilename);
}
/**
* Returns InstalledExtension from path to package.json file.
* Also updates this.packagesJson.
*/
protected async getByManifest(manifestPath: string, { isBundled = false }: { protected async getByManifest(manifestPath: string, { isBundled = false }: {
isBundled?: boolean; isBundled?: boolean;
} = {}): Promise<InstalledExtension | null> { } = {}): Promise<InstalledExtension | null> {
@ -270,7 +321,7 @@ export class ExtensionDiscovery {
fs.accessSync(manifestPath, fs.constants.F_OK); fs.accessSync(manifestPath, fs.constants.F_OK);
manifestJson = __non_webpack_require__(manifestPath); manifestJson = __non_webpack_require__(manifestPath);
const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json"); const installedManifestPath = this.getInstalledManifestPath(manifestJson.name);
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath); this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath);
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
@ -319,7 +370,7 @@ export class ExtensionDiscovery {
} }
const absPath = path.resolve(folderPath, fileName); const absPath = path.resolve(folderPath, fileName);
const extension = await this.loadExtensionFromPath(absPath, { isBundled: true }); const extension = await this.loadExtensionFromFolder(absPath, { isBundled: true });
if (extension) { if (extension) {
extensions.push(extension); extensions.push(extension);
@ -354,7 +405,7 @@ export class ExtensionDiscovery {
continue; continue;
} }
const extension = await this.loadExtensionFromPath(absPath); const extension = await this.loadExtensionFromFolder(absPath);
if (extension) { if (extension) {
extensions.push(extension); extensions.push(extension);
@ -368,8 +419,9 @@ export class ExtensionDiscovery {
/** /**
* Loads extension from absolute path, updates this.packagesJson to include it and returns the extension. * Loads extension from absolute path, updates this.packagesJson to include it and returns the extension.
* @param absPath Folder path to extension
*/ */
async loadExtensionFromPath(absPath: string, { isBundled = false }: { async loadExtensionFromFolder(absPath: string, { isBundled = false }: {
isBundled?: boolean; isBundled?: boolean;
} = {}): Promise<InstalledExtension | null> { } = {}): Promise<InstalledExtension | null> {
const manifestPath = path.resolve(absPath, manifestFilename); const manifestPath = path.resolve(absPath, manifestFilename);

View File

@ -1,3 +1,6 @@
import fetchMock from "jest-fetch-mock"; import fetchMock from "jest-fetch-mock";
// rewire global.fetch to call 'fetchMock' // rewire global.fetch to call 'fetchMock'
fetchMock.enableMocks(); fetchMock.enableMocks();
// Mock __non_webpack_require__ for tests
globalThis.__non_webpack_require__ = jest.fn();

View File

@ -22,7 +22,7 @@ export interface HelmRepo {
cacheFilePath?: string cacheFilePath?: string
caFile?: string, caFile?: string,
certFile?: string, certFile?: string,
insecure_skip_tls_verify?: boolean, insecureSkipTlsVerify?: boolean,
keyFile?: string, keyFile?: string,
username?: string, username?: string,
password?: string, password?: string,
@ -131,6 +131,25 @@ export class HelmRepoManager extends Singleton {
return stdout; return stdout;
} }
public async addСustomRepo(repoAttributes : HelmRepo) {
logger.info(`[HELM]: adding repo "${repoAttributes.name}" from ${repoAttributes.url}`);
const helm = await helmCli.binaryPath();
const insecureSkipTlsVerify = repoAttributes.insecureSkipTlsVerify ? " --insecure-skip-tls-verify" : "";
const username = repoAttributes.username ? ` --username "${repoAttributes.username}"` : "";
const password = repoAttributes.password ? ` --password "${repoAttributes.password}"` : "";
const caFile = repoAttributes.caFile ? ` --ca-file "${repoAttributes.caFile}"` : "";
const keyFile = repoAttributes.keyFile ? ` --key-file "${repoAttributes.keyFile}"` : "";
const certFile = repoAttributes.certFile ? ` --cert-file "${repoAttributes.certFile}"` : "";
const addRepoCommand = `"${helm}" repo add ${repoAttributes.name} ${repoAttributes.url}${insecureSkipTlsVerify}${username}${password}${caFile}${keyFile}${certFile}`;
const { stdout } = await promiseExec(addRepoCommand).catch((error) => {
throw(error.stderr);
});
return stdout;
}
public async removeRepo({ name, url }: HelmRepo): Promise<string> { public async removeRepo({ name, url }: HelmRepo): Promise<string> {
logger.info(`[HELM]: removing repo "${name}" from ${url}`); logger.info(`[HELM]: removing repo "${name}" from ${url}`);
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();

View File

@ -115,7 +115,7 @@ app.on("activate", (event, hasVisibleWindows) => {
logger.info("APP:ACTIVATE", { hasVisibleWindows }); logger.info("APP:ACTIVATE", { hasVisibleWindows });
if (!hasVisibleWindows) { if (!hasVisibleWindows) {
windowManager.initMainWindow(); windowManager?.initMainWindow(false);
} }
}); });

View File

@ -67,7 +67,7 @@ export interface IPodContainer {
image: string; image: string;
command?: string[]; command?: string[];
args?: string[]; args?: string[];
ports: { ports?: {
name?: string; name?: string;
containerPort: number; containerPort: number;
protocol: string; protocol: string;
@ -137,7 +137,7 @@ interface IContainerProbe {
export interface IPodContainerStatus { export interface IPodContainerStatus {
name: string; name: string;
state: { state?: {
[index: string]: object; [index: string]: object;
running?: { running?: {
startedAt: string; startedAt: string;
@ -153,21 +153,28 @@ export interface IPodContainerStatus {
reason: string; reason: string;
}; };
}; };
lastState: { lastState?: {
[index: string]: object; [index: string]: object;
running?: {
startedAt: string;
};
waiting?: {
reason: string;
message: string;
};
terminated?: { terminated?: {
startedAt: string; startedAt: string;
finishedAt: string; finishedAt: string;
exitCode: number; exitCode: number;
reason: string; reason: string;
containerID: string;
}; };
}; };
ready: boolean; ready: boolean;
restartCount: number; restartCount: number;
image: string; image: string;
imageID: string; imageID: string;
containerID: string; containerID?: string;
started?: boolean;
} }
@autobind() @autobind()
@ -196,28 +203,44 @@ export class Pod extends WorkloadKubeObject {
}[]; }[];
initContainers: IPodContainer[]; initContainers: IPodContainer[];
containers: IPodContainer[]; containers: IPodContainer[];
restartPolicy: string; restartPolicy?: string;
terminationGracePeriodSeconds: number; terminationGracePeriodSeconds?: number;
dnsPolicy: string; activeDeadlineSeconds?: number;
dnsPolicy?: string;
serviceAccountName: string; serviceAccountName: string;
serviceAccount: string; serviceAccount: string;
priority: number; automountServiceAccountToken?: boolean;
priorityClassName: string; priority?: number;
nodeName: string; priorityClassName?: string;
nodeName?: string;
nodeSelector?: { nodeSelector?: {
[selector: string]: string; [selector: string]: string;
}; };
securityContext: {}; securityContext?: {};
schedulerName: string; imagePullSecrets?: {
tolerations: { name: string;
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[]; }[];
affinity: IAffinity; hostNetwork?: boolean;
hostPID?: boolean;
hostIPC?: boolean;
shareProcessNamespace?: boolean;
hostname?: string;
subdomain?: string;
schedulerName?: string;
tolerations?: {
key?: string;
operator?: string;
effect?: string;
tolerationSeconds?: number;
value?: string;
}[];
hostAliases?: {
ip: string;
hostnames: string[];
};
affinity?: IAffinity;
}; };
status: { status?: {
phase: string; phase: string;
conditions: { conditions: {
type: string; type: string;
@ -230,7 +253,7 @@ export class Pod extends WorkloadKubeObject {
startTime: string; startTime: string;
initContainerStatuses?: IPodContainerStatus[]; initContainerStatuses?: IPodContainerStatus[];
containerStatuses?: IPodContainerStatus[]; containerStatuses?: IPodContainerStatus[];
qosClass: string; qosClass?: string;
reason?: string; reason?: string;
}; };

View File

@ -1,51 +1,78 @@
import get from "lodash/get"; import get from "lodash/get";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { WorkloadKubeObject } from "../workload-kube-object";
import { IPodContainer } from "./pods.api"; import { IPodContainer, Pod } from "./pods.api";
import { KubeApi } from "../kube-api"; import { KubeApi } from "../kube-api";
export class ReplicaSetApi extends KubeApi<ReplicaSet> {
protected getScaleApiUrl(params: { namespace: string; name: string }) {
return `${this.getUrl(params)}/scale`;
}
getReplicas(params: { namespace: string; name: string }): Promise<number> {
return this.request
.get(this.getScaleApiUrl(params))
.then(({ status }: any) => status?.replicas);
}
scale(params: { namespace: string; name: string }, replicas: number) {
return this.request.put(this.getScaleApiUrl(params), {
data: {
metadata: params,
spec: {
replicas
}
}
});
}
}
@autobind() @autobind()
export class ReplicaSet extends WorkloadKubeObject { export class ReplicaSet extends WorkloadKubeObject {
static kind = "ReplicaSet"; static kind = "ReplicaSet";
static namespaced = true; static namespaced = true;
static apiBase = "/apis/apps/v1/replicasets"; static apiBase = "/apis/apps/v1/replicasets";
spec: { spec: {
replicas?: number; replicas?: number;
selector?: { selector: { matchLabels: { [app: string]: string } };
matchLabels: {
[key: string]: string;
};
};
containers?: IPodContainer[];
template?: { template?: {
spec?: { metadata: {
affinity?: IAffinity; labels: {
nodeSelector?: { app: string;
[selector: string]: string;
}; };
tolerations: {
key: string;
operator: string;
effect: string;
tolerationSeconds: number;
}[];
containers: IPodContainer[];
}; };
spec?: Pod["spec"];
}; };
restartPolicy?: string; minReadySeconds?: number;
terminationGracePeriodSeconds?: number;
dnsPolicy?: string;
schedulerName?: string;
}; };
status: { status: {
replicas: number; replicas: number;
fullyLabeledReplicas: number; fullyLabeledReplicas?: number;
readyReplicas: number; readyReplicas?: number;
availableReplicas: number; availableReplicas?: number;
observedGeneration: number; observedGeneration?: number;
conditions?: {
type: string;
status: string;
lastUpdateTime: string;
lastTransitionTime: string;
reason: string;
message: string;
}[];
}; };
getDesired() {
return this.spec.replicas || 0;
}
getCurrent() {
return this.status.availableReplicas || 0;
}
getReady() {
return this.status.readyReplicas || 0;
}
getImages() { getImages() {
const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []); const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []);
@ -53,6 +80,6 @@ export class ReplicaSet extends WorkloadKubeObject {
} }
} }
export const replicaSetApi = new KubeApi({ export const replicaSetApi = new ReplicaSetApi({
objectConstructor: ReplicaSet, objectConstructor: ReplicaSet,
}); });

View File

@ -68,7 +68,7 @@ describe("Extensions", () => {
// Approve confirm dialog // Approve confirm dialog
fireEvent.click(screen.getByText("Yes")); fireEvent.click(screen.getByText("Yes"));
expect(extensionDiscovery.uninstallExtension).toHaveBeenCalledWith("/absolute/path"); expect(extensionDiscovery.uninstallExtension).toHaveBeenCalled();
expect(screen.getByText("Disable").closest("button")).toBeDisabled(); expect(screen.getByText("Disable").closest("button")).toBeDisabled();
expect(screen.getByText("Uninstall").closest("button")).toBeDisabled(); expect(screen.getByText("Uninstall").closest("button")).toBeDisabled();
}); });

View File

@ -413,7 +413,7 @@ export class Extensions extends React.Component {
displayName displayName
}); });
await extensionDiscovery.uninstallExtension(extension.absolutePath); await extensionDiscovery.uninstallExtension(extension);
} catch (error) { } catch (error) {
Notifications.error( Notifications.error(
<p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p> <p>Uninstalling extension <b>{displayName}</b> has failed: <em>{error?.message ?? ""}</em></p>

View File

@ -0,0 +1,22 @@
.AddHelmRepoDialog {
--flex-gap: #{$margin * 1.5};
.accordion {
font-weight: bold;
align-self: flex-start;
padding: 0;
&:focus:not(:active) {
box-shadow: none;
}
}
.Icon {
--color-active: black;
}
.fields-title {
display: grid;
grid-template-columns: min-content auto;
align-items: center;
gap: $margin / 2;
}
}

View File

@ -0,0 +1,178 @@
import "./add-helm-repo-dialog.scss";
import React from "react";
import { remote, FileFilter } from "electron";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Input } from "../input";
import { Checkbox } from "../checkbox";
import { Button } from "../button";
import { systemName, isUrl, isPath } from "../input/input_validators";
import { SubTitle } from "../layout/sub-title";
import { Icon } from "../icon";
import { Notifications } from "../notifications";
import { HelmRepo, repoManager } from "../../../main/helm/helm-repo-manager";
interface Props extends Partial<DialogProps> {
onAddRepo: Function
}
enum FileType {
CaFile = "caFile",
KeyFile = "keyFile",
CertFile = "certFile",
}
@observer
export class AddHelmRepoDialog extends React.Component<Props> {
private emptyRepo = {name: "", url: "", username: "", password: "", insecureSkipTlsVerify: false, caFile:"", keyFile: "", certFile: ""};
private static keyExtensions = ["key", "keystore", "jks", "p12", "pfx", "pem"];
private static certExtensions = ["crt", "cer", "ca-bundle", "p7b", "p7c" , "p7s", "p12", "pfx", "pem"];
@observable static isOpen = false;
static open() {
AddHelmRepoDialog.isOpen = true;
}
static close() {
AddHelmRepoDialog.isOpen = false;
}
@observable helmRepo : HelmRepo = this.emptyRepo;
@observable showOptions = false;
close = () => {
AddHelmRepoDialog.close();
this.helmRepo = this.emptyRepo;
this.showOptions = false;
};
setFilepath(type: FileType, value: string) {
this.helmRepo[type] = value;
}
getFilePath(type: FileType) : string {
return this.helmRepo[type];
}
async selectFileDialog(type: FileType, fileFilter: FileFilter) {
const { dialog, BrowserWindow } = remote;
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
defaultPath: this.getFilePath(type),
properties: ["openFile", "showHiddenFiles"],
message: _i18n._(t`Select file`),
buttonLabel: _i18n._(t`Use file`),
filters: [
fileFilter,
{ name: "Any", extensions: ["*"]}
]
});
if (!canceled && filePaths.length) {
this.setFilepath(type, filePaths[0]);
}
}
async addCustomRepo() {
try {
await repoManager.addСustomRepo(this.helmRepo);
Notifications.ok(<Trans>Helm repository <b>{this.helmRepo.name}</b> has added</Trans>);
this.props.onAddRepo();
this.close();
} catch (err) {
Notifications.error(<Trans>Adding helm branch <b>{this.helmRepo.name}</b> has failed: {String(err)}</Trans>);
}
}
renderFileInput(placeholder:string, fileType:FileType ,fileExtensions:string[]){
return(
<div className="flex gaps align-center">
<Input
placeholder={placeholder}
validators = {isPath}
className="box grow"
value={this.getFilePath(fileType)}
onChange={v => this.setFilepath(fileType, v)}
/>
<Icon
material="folder"
onClick={() => this.selectFileDialog(fileType, {name: placeholder, extensions: fileExtensions})}
tooltip={<Trans>Browse</Trans>}
/>
</div>);
}
renderOptions() {
return (
<>
<SubTitle title={<Trans>Security settings</Trans>} />
<Checkbox
label={_i18n._(t`Skip TLS certificate checks for the repository`)}
value={this.helmRepo.insecureSkipTlsVerify}
onChange={v => this.helmRepo.insecureSkipTlsVerify = v}
/>
{this.renderFileInput(_i18n._(t`Key file`), FileType.KeyFile, AddHelmRepoDialog.keyExtensions)}
{this.renderFileInput(_i18n._(t`Ca file`), FileType.CaFile, AddHelmRepoDialog.certExtensions)}
{this.renderFileInput(_i18n._(t`Cerificate file`), FileType.CertFile, AddHelmRepoDialog.certExtensions)}
<SubTitle title={<Trans>Chart Repository Credentials</Trans>} />
<Input
placeholder={_i18n._(t`Username`)}
value={this.helmRepo.username} onChange= {v => this.helmRepo.username = v}
/>
<Input
type="password"
placeholder={_i18n._(t`Password`)}
value={this.helmRepo.password} onChange={v => this.helmRepo.password = v}
/>
</>);
}
render() {
const { ...dialogProps } = this.props;
const header = <h5><Trans>Add custom Helm Repo</Trans></h5>;
return (
<Dialog
{...dialogProps}
className="AddHelmRepoDialog"
isOpen={AddHelmRepoDialog.isOpen}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep contentClass="flow column" nextLabel={<Trans>Add</Trans>} next={()=>{this.addCustomRepo();}}>
<div className="flex column gaps">
<Input
autoFocus required
placeholder={_i18n._(t`Helm repo name`)}
validators={systemName}
value={this.helmRepo.name} onChange={v => this.helmRepo.name = v}
/>
<Input
required
placeholder={_i18n._(t`URL`)}
validators={isUrl}
value={this.helmRepo.url} onChange={v => this.helmRepo.url = v}
/>
<Button plain className="accordion" onClick={() => this.showOptions = !this.showOptions} >
<Trans>More</Trans>
<Icon
small
tooltip={_i18n._(t`More`)}
material={this.showOptions ? "remove" : "add"}
/>
</Button>
{this.showOptions && this.renderOptions()}
</div>
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -13,11 +13,13 @@ import { Input } from "../input";
import { Checkbox } from "../checkbox"; import { Checkbox } from "../checkbox";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { Button } from "../button";
import { themeStore } from "../../theme.store"; import { themeStore } from "../../theme.store";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { KubectlBinaries } from "./kubectl-binaries"; import { KubectlBinaries } from "./kubectl-binaries";
import { appPreferenceRegistry } from "../../../extensions/registries/app-preference-registry"; import { appPreferenceRegistry } from "../../../extensions/registries/app-preference-registry";
import { PageLayout } from "../layout/page-layout"; import { PageLayout } from "../layout/page-layout";
import { AddHelmRepoDialog } from "./add-helm-repo-dialog";
@observer @observer
export class Preferences extends React.Component { export class Preferences extends React.Component {
@ -134,16 +136,25 @@ export class Preferences extends React.Component {
<KubectlBinaries preferences={preferences}/> <KubectlBinaries preferences={preferences}/>
<h2><Trans>Helm</Trans></h2> <h2><Trans>Helm</Trans></h2>
<Select id="HelmRepoSelect" <div className="flex gaps">
placeholder={<Trans>Repositories</Trans>} <Select id="HelmRepoSelect"
isLoading={this.helmLoading} placeholder={<Trans>Repositories</Trans>}
isDisabled={this.helmLoading} isLoading={this.helmLoading}
options={this.helmOptions} isDisabled={this.helmLoading}
onChange={this.onRepoSelect} options={this.helmOptions}
formatOptionLabel={this.formatHelmOptionLabel} onChange={this.onRepoSelect}
controlShouldRenderValue={false} formatOptionLabel={this.formatHelmOptionLabel}
/> controlShouldRenderValue={false}
<div className="repos flex column"> className="box grow"
/>
<Button
primary
label={<Trans>Add Custom Helm Repo</Trans>}
onClick={AddHelmRepoDialog.open}
/>
</div>
<AddHelmRepoDialog onAddRepo={()=>this.loadHelmRepos()}/>
<div className="repos flex gaps column">
{Array.from(this.helmAddedRepos).map(([name, repo]) => { {Array.from(this.helmAddedRepos).map(([name, repo]) => {
const tooltipId = `message-${name}`; const tooltipId = `message-${name}`;

View File

@ -11,7 +11,6 @@ import { cssNames } from "../../utils";
import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations";
import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities";
import { KubeEventDetails } from "../+events/kube-event-details"; import { KubeEventDetails } from "../+events/kube-event-details";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
import { podsStore } from "../+workloads-pods/pods.store"; import { podsStore } from "../+workloads-pods/pods.store";
import { KubeObjectDetailsProps } from "../kube-object"; import { KubeObjectDetailsProps } from "../kube-object";
import { _i18n } from "../../i18n"; import { _i18n } from "../../i18n";
@ -20,7 +19,6 @@ import { deploymentStore } from "./deployments.store";
import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts";
import { reaction } from "mobx"; import { reaction } from "mobx";
import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { PodDetailsList } from "../+workloads-pods/pod-details-list";
import { ReplicaSets } from "../+workloads-replicasets";
import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { KubeObjectMeta } from "../kube-object/kube-object-meta";
import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry";
@ -38,10 +36,6 @@ export class DeploymentDetails extends React.Component<Props> {
if (!podsStore.isLoaded) { if (!podsStore.isLoaded) {
podsStore.loadAll(); podsStore.loadAll();
} }
if (!replicaSetStore.isLoaded) {
replicaSetStore.loadAll();
}
} }
componentWillUnmount() { componentWillUnmount() {
@ -56,7 +50,6 @@ export class DeploymentDetails extends React.Component<Props> {
const nodeSelector = deployment.getNodeSelectors(); const nodeSelector = deployment.getNodeSelectors();
const selectors = deployment.getSelectors(); const selectors = deployment.getSelectors();
const childPods = deploymentStore.getChildPods(deployment); const childPods = deploymentStore.getChildPods(deployment);
const replicaSets = replicaSetStore.getReplicaSetsByOwner(deployment);
const metrics = deploymentStore.metrics; const metrics = deploymentStore.metrics;
return ( return (
@ -118,7 +111,6 @@ export class DeploymentDetails extends React.Component<Props> {
<PodDetailsTolerations workload={deployment}/> <PodDetailsTolerations workload={deployment}/>
<PodDetailsAffinities workload={deployment}/> <PodDetailsAffinities workload={deployment}/>
<ResourceMetricsText metrics={metrics}/> <ResourceMetricsText metrics={metrics}/>
<ReplicaSets replicaSets={replicaSets}/>
<PodDetailsList pods={childPods} owner={deployment}/> <PodDetailsList pods={childPods} owner={deployment}/>
</div> </div>
); );

View File

@ -15,7 +15,7 @@
.workloads { .workloads {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, 155px); grid-template-columns: repeat(auto-fit, 180px);
justify-content: space-evenly; justify-content: space-evenly;
grid-gap: $margin; grid-gap: $margin;
padding: $padding * 2; padding: $padding * 2;

View File

@ -18,6 +18,7 @@ const resources: KubeResource[] = [
"deployments", "deployments",
"statefulsets", "statefulsets",
"daemonsets", "daemonsets",
"replicasets",
"jobs", "jobs",
"cronjobs", "cronjobs",
]; ];

View File

@ -0,0 +1,49 @@
.ReplicaSetScaleDialog {
.Wizard {
.header {
span {
color: #a0a0a0;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.WizardStep {
.step-content {
min-height: 90px;
overflow: hidden;
}
}
.current-scale {
font-weight: bold
}
.desired-scale {
flex: 1.1 0;
}
.slider-container {
flex: 1 0;
}
.plus-minus-container {
margin-left: $margin * 2;
.Icon {
--color-active: black;
}
}
.warning {
color: $colorSoftError;
font-size: small;
display: flex;
align-items: center;
.Icon {
margin: 0;
margin-right: $margin;
}
}
}
}

View File

@ -0,0 +1,167 @@
import "@testing-library/jest-dom/extend-expect";
jest.mock("../../api/endpoints");
import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog";
import { render, waitFor, fireEvent } from "@testing-library/react";
import React from "react";
import { replicaSetApi } from "../../api/endpoints/replica-set.api";
const dummyReplicaSet = {
apiVersion: "v1",
kind: "dummy",
metadata: {
uid: "dummy",
name: "dummy",
creationTimestamp: "dummy",
resourceVersion: "dummy",
selfLink: "link",
},
selfLink: "link",
spec: {
replicas: 1,
selector: {
matchLabels: { "label": "label" }
},
template: {
metadata: {
labels: {
app: "label",
},
},
spec: {
containers: [{
name: "dummy",
image: "dummy",
imagePullPolicy: "dummy",
}],
initContainers: [{
name: "dummy",
image: "dummy",
imagePullPolicy: "dummy",
}],
priority: 1,
serviceAccountName: "dummy",
serviceAccount: "dummy",
securityContext: {},
schedulerName: "dummy",
},
},
minReadySeconds: 1,
},
status: {
replicas: 1,
fullyLabeledReplicas: 1,
readyReplicas: 1,
availableReplicas: 1,
observedGeneration: 1,
conditions: [{
type: "dummy",
status: "dummy",
lastUpdateTime: "dummy",
lastTransitionTime: "dummy",
reason: "dummy",
message: "dummy",
}],
},
getDesired: jest.fn(),
getCurrent: jest.fn(),
getReady: jest.fn(),
getImages: jest.fn(),
getReplicas: jest.fn(),
getSelectors: jest.fn(),
getTemplateLabels: jest.fn(),
getAffinity: jest.fn(),
getTolerations: jest.fn(),
getNodeSelectors: jest.fn(),
getAffinityNumber: jest.fn(),
getId: jest.fn(),
getResourceVersion: jest.fn(),
getName: jest.fn(),
getNs: jest.fn(),
getAge: jest.fn(),
getFinalizers: jest.fn(),
getLabels: jest.fn(),
getAnnotations: jest.fn(),
getOwnerRefs: jest.fn(),
getSearchFields: jest.fn(),
toPlainObject: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
};
describe("<ReplicaSetScaleDialog />", () => {
it("renders w/o errors", () => {
const { container } = render(<ReplicaSetScaleDialog/>);
expect(container).toBeInstanceOf(HTMLElement);
});
it("init with a dummy replica set and mocked current/desired scale", async () => {
// mock replicaSetApi.getReplicas() which will be called
// when <ReplicaSetScaleDialog /> rendered.
const initReplicas = 1;
replicaSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const { getByTestId } = render(<ReplicaSetScaleDialog/>);
ReplicaSetScaleDialog.open(dummyReplicaSet);
// we need to wait for the replicaSetScaleDialog to show up
// because there is an <Animate /> in <Dialog /> which renders null at start.
await waitFor(async () => {
const [currentScale, desiredScale] = await Promise.all([
getByTestId("current-scale"),
getByTestId("desired-scale"),
]);
expect(currentScale).toHaveTextContent(`${initReplicas}`);
expect(desiredScale).toHaveTextContent(`${initReplicas}`);
});
});
it("changes the desired scale when clicking the icon buttons +/-", async () => {
const initReplicas = 1;
replicaSetApi.getReplicas = jest.fn().mockImplementationOnce(async () => initReplicas);
const component = render(<ReplicaSetScaleDialog/>);
ReplicaSetScaleDialog.open(dummyReplicaSet);
await waitFor(async () => {
expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`);
expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`);
});
const up = await component.findByTestId("desired-replicas-up");
const down = await component.findByTestId("desired-replicas-down");
fireEvent.click(up);
expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas + 1}`);
expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas + 1}`);
fireEvent.click(down);
expect(await component.findByTestId("desired-scale")).toHaveTextContent(`${initReplicas}`);
expect(await component.findByTestId("current-scale")).toHaveTextContent(`${initReplicas}`);
expect((await component.baseElement.querySelector("input").value)).toBe(`${initReplicas}`);
// edge case, desiredScale must >= 0
let times = 10;
for (let i = 0; i < times; i++) {
fireEvent.click(down);
}
expect(await component.findByTestId("desired-scale")).toHaveTextContent("0");
expect((await component.baseElement.querySelector("input").value)).toBe("0");
// edge case, desiredScale must <= scaleMax (100)
times = 120;
for (let i = 0; i < times; i++) {
fireEvent.click(up);
}
expect(await component.findByTestId("desired-scale")).toHaveTextContent("100");
expect((component.baseElement.querySelector("input").value)).toBe("100");
expect(await component.findByTestId("warning"))
.toHaveTextContent("High number of replicas may cause cluster performance issues");
});
});

View File

@ -0,0 +1,169 @@
import "./replicaset-scale-dialog.scss";
import React, { Component } from "react";
import { computed, observable } from "mobx";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Icon } from "../icon";
import { Slider } from "../slider";
import { Notifications } from "../notifications";
import { cssNames } from "../../utils";
import { ReplicaSet, replicaSetApi } from "../../api/endpoints/replica-set.api";
interface Props extends Partial<DialogProps> {
}
@observer
export class ReplicaSetScaleDialog extends Component<Props> {
@observable static isOpen = false;
@observable static data: ReplicaSet = null;
@observable ready = false;
@observable currentReplicas = 0;
@observable desiredReplicas = 0;
static open(replicaSet: ReplicaSet) {
ReplicaSetScaleDialog.isOpen = true;
ReplicaSetScaleDialog.data = replicaSet;
}
static close() {
ReplicaSetScaleDialog.isOpen = false;
}
get replicaSet() {
return ReplicaSetScaleDialog.data;
}
close = () => {
ReplicaSetScaleDialog.close();
};
onOpen = async () => {
const { replicaSet } = this;
this.currentReplicas = await replicaSetApi.getReplicas({
namespace: replicaSet.getNs(),
name: replicaSet.getName(),
});
this.desiredReplicas = this.currentReplicas;
this.ready = true;
};
onClose = () => {
this.ready = false;
};
onChange = (evt: React.ChangeEvent, value: number) => {
this.desiredReplicas = value;
};
@computed get scaleMax() {
const { currentReplicas } = this;
const defaultMax = 50;
return currentReplicas <= defaultMax
? defaultMax * 2
: currentReplicas * 2;
}
scale = async () => {
const { replicaSet } = this;
const { currentReplicas, desiredReplicas, close } = this;
try {
if (currentReplicas !== desiredReplicas) {
await replicaSetApi.scale({
name: replicaSet.getName(),
namespace: replicaSet.getNs(),
}, desiredReplicas);
}
close();
} catch (err) {
Notifications.error(err);
}
};
desiredReplicasUp = () => {
this.desiredReplicas < this.scaleMax && this.desiredReplicas++;
};
desiredReplicasDown = () => {
this.desiredReplicas > 0 && this.desiredReplicas--;
};
renderContents() {
const { currentReplicas, desiredReplicas, onChange, scaleMax } = this;
const warning = currentReplicas < 10 && desiredReplicas > 90;
return (
<>
<div className="current-scale" data-testid="current-scale">
<Trans>Current replica scale: {currentReplicas}</Trans>
</div>
<div className="flex gaps align-center">
<div className="desired-scale" data-testid="desired-scale">
<Trans>Desired number of replicas</Trans>: {desiredReplicas}
</div>
<div className="slider-container flex align-center" data-testid="slider">
<Slider value={desiredReplicas} max={scaleMax}
onChange={onChange as any /** see: https://github.com/mui-org/material-ui/issues/20191 */}
/>
</div>
<div className="plus-minus-container flex gaps">
<Icon
material="add_circle_outline"
onClick={this.desiredReplicasUp}
data-testid="desired-replicas-up"
/>
<Icon
material="remove_circle_outline"
onClick={this.desiredReplicasDown}
data-testid="desired-replicas-down"
/>
</div>
</div>
{warning &&
<div className="warning" data-testid="warning">
<Icon material="warning"/>
<Trans>High number of replicas may cause cluster performance issues</Trans>
</div>
}
</>
);
}
render() {
const { className, ...dialogProps } = this.props;
const replicaSetName = this.replicaSet ? this.replicaSet.getName() : "";
const header = (
<h5>
<Trans>Scale Replica Set <span>{replicaSetName}</span></Trans>
</h5>
);
return (
<Dialog
{...dialogProps}
isOpen={ReplicaSetScaleDialog.isOpen}
className={cssNames("ReplicaSetScaleDialog", className)}
onOpen={this.onOpen}
onClose={this.onClose}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
contentClass="flex gaps column"
next={this.scale}
nextLabel={<Trans>Scale</Trans>}
disabledNext={!this.ready}
>
{this.renderContents()}
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -1,20 +1,5 @@
.ReplicaSets { .ReplicaSets {
position: relative;
min-height: 80px;
.Table {
margin: 0 (-$margin * 3);
}
.TableCell { .TableCell {
&:first-child {
margin-left: $margin;
}
&:last-child {
margin-right: $margin;
}
&.name { &.name {
flex-grow: 2; flex-grow: 2;
} }
@ -22,13 +7,5 @@
&.warning { &.warning {
@include table-cell-warning; @include table-cell-warning;
} }
&.namespace {
flex-grow: 1.2;
}
&.actions {
@include table-cell-action;
}
} }
} }

View File

@ -4,6 +4,7 @@ import { KubeObjectStore } from "../../kube-object.store";
import { Deployment, IPodMetrics, podsApi, ReplicaSet, replicaSetApi } from "../../api/endpoints"; import { Deployment, IPodMetrics, podsApi, ReplicaSet, replicaSetApi } from "../../api/endpoints";
import { podsStore } from "../+workloads-pods/pods.store"; import { podsStore } from "../+workloads-pods/pods.store";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
import { PodStatus } from "../../api/endpoints/pods.api";
@autobind() @autobind()
export class ReplicaSetStore extends KubeObjectStore<ReplicaSet> { export class ReplicaSetStore extends KubeObjectStore<ReplicaSet> {
@ -20,6 +21,26 @@ export class ReplicaSetStore extends KubeObjectStore<ReplicaSet> {
return podsStore.getPodsByOwner(replicaSet); return podsStore.getPodsByOwner(replicaSet);
} }
getStatuses(replicaSets: ReplicaSet[]) {
const status = { failed: 0, pending: 0, running: 0 };
replicaSets.forEach(replicaSet => {
const pods = this.getChildPods(replicaSet);
if (pods.some(pod => pod.getStatus() === PodStatus.FAILED)) {
status.failed++;
}
else if (pods.some(pod => pod.getStatus() === PodStatus.PENDING)) {
status.pending++;
}
else {
status.running++;
}
});
return status;
}
getReplicaSetsByOwner(deployment: Deployment) { getReplicaSetsByOwner(deployment: Deployment) {
return this.items.filter(replicaSet => return this.items.filter(replicaSet =>
!!replicaSet.getOwnerRefs().find(owner => owner.uid === deployment.getId()) !!replicaSet.getOwnerRefs().find(owner => owner.uid === deployment.getId())

View File

@ -2,97 +2,93 @@ import "./replicasets.scss";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Trans } from "@lingui/macro"; import { t, Trans } from "@lingui/macro";
import { ReplicaSet } from "../../api/endpoints"; import { ReplicaSet } from "../../api/endpoints";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; import { KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { replicaSetStore } from "./replicasets.store"; import { replicaSetStore } from "./replicasets.store";
import { Spinner } from "../spinner";
import { prevDefault, stopPropagation } from "../../utils";
import { DrawerTitle } from "../drawer";
import { Table, TableCell, TableHead, TableRow } from "../table";
import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { KubeObjectStatusIcon } from "../kube-object-status-icon";
import { showDetails } from "../kube-object"; import { RouteComponentProps } from "react-router";
import { IReplicaSetsRouteParams } from "../+workloads/workloads.route";
import { KubeObjectListLayout } from "../kube-object/kube-object-list-layout";
import { MenuItem } from "../menu/menu";
import { Icon } from "../icon/icon";
import { _i18n } from "../../i18n";
import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry";
import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog";
enum sortBy { enum sortBy {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
pods = "pods", desired = "desired",
current = "current",
ready = "ready",
age = "age", age = "age",
} }
interface Props { interface Props extends RouteComponentProps<IReplicaSetsRouteParams> {
replicaSets: ReplicaSet[];
} }
@observer @observer
export class ReplicaSets extends React.Component<Props> { export class ReplicaSets extends React.Component<Props> {
private sortingCallbacks = {
[sortBy.name]: (replicaSet: ReplicaSet) => replicaSet.getName(),
[sortBy.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(),
[sortBy.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp,
[sortBy.pods]: (replicaSet: ReplicaSet) => this.getPodsLength(replicaSet),
};
getPodsLength(replicaSet: ReplicaSet) {
return replicaSetStore.getChildPods(replicaSet).length;
}
render() { render() {
const { replicaSets } = this.props;
if (!replicaSets.length && !replicaSetStore.isLoaded) return (
<div className="ReplicaSets"><Spinner center/></div>
);
if (!replicaSets.length) return null;
return ( return (
<div className="ReplicaSets flex column"> <KubeObjectListLayout
<DrawerTitle title={<Trans>Deploy Revisions</Trans>}/> className="ReplicaSets" store={replicaSetStore}
<Table sortingCallbacks={{
selectable [sortBy.name]: (replicaSet: ReplicaSet) => replicaSet.getName(),
scrollable={false} [sortBy.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(),
sortable={this.sortingCallbacks} [sortBy.desired]: (replicaSet: ReplicaSet) => replicaSet.getDesired(),
sortByDefault={{ sortBy: sortBy.pods, orderBy: "desc" }} [sortBy.current]: (replicaSet: ReplicaSet) => replicaSet.getCurrent(),
sortSyncWithUrl={false} [sortBy.ready]: (replicaSet: ReplicaSet) => replicaSet.getReady(),
className="box grow" [sortBy.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp,
> }}
<TableHead> searchFilters={[
<TableCell className="name" sortBy={sortBy.name}><Trans>Name</Trans></TableCell> (replicaSet: ReplicaSet) => replicaSet.getSearchFields(),
<TableCell className="warning"/> ]}
<TableCell className="namespace" sortBy={sortBy.namespace}>Namespace</TableCell> renderHeaderTitle={<Trans>Replica Sets</Trans>}
<TableCell className="pods" sortBy={sortBy.pods}><Trans>Pods</Trans></TableCell> renderTableHeader={[
<TableCell className="age" sortBy={sortBy.age}><Trans>Age</Trans></TableCell> { title: <Trans>Name</Trans>, className: "name", sortBy: sortBy.name },
<TableCell className="actions"/> { className: "warning" },
</TableHead> { title: <Trans>Namespace</Trans>, className: "namespace", sortBy: sortBy.namespace },
{ { title: <Trans>Desired</Trans>, className: "desired", sortBy: sortBy.desired },
replicaSets.map(replica => { { title: <Trans>Current</Trans>, className: "current", sortBy: sortBy.current },
return ( { title: <Trans>Ready</Trans>, className: "ready", sortBy: sortBy.ready },
<TableRow { title: <Trans>Age</Trans>, className: "age", sortBy: sortBy.age },
key={replica.getId()} ]}
sortItem={replica} renderTableContents={(replicaSet: ReplicaSet) => [
nowrap replicaSet.getName(),
onClick={prevDefault(() => showDetails(replica.selfLink, false))} <KubeObjectStatusIcon key="icon" object={replicaSet}/>,
> replicaSet.getNs(),
<TableCell className="name">{replica.getName()}</TableCell> replicaSet.getDesired(),
<TableCell className="warning"><KubeObjectStatusIcon key="icon" object={replica}/></TableCell> replicaSet.getCurrent(),
<TableCell className="namespace">{replica.getNs()}</TableCell> replicaSet.getReady(),
<TableCell className="pods">{this.getPodsLength(replica)}</TableCell> replicaSet.getAge(),
<TableCell className="age">{replica.getAge()}</TableCell> ]}
<TableCell className="actions" onClick={stopPropagation}> renderItemMenu={(item: ReplicaSet) => {
<ReplicaSetMenu object={replica}/> return <ReplicaSetMenu object={item}/>;
</TableCell> }}
</TableRow> />
);
})
}
</Table>
</div>
); );
} }
} }
export function ReplicaSetMenu(props: KubeObjectMenuProps<ReplicaSet>) { export function ReplicaSetMenu(props: KubeObjectMenuProps<ReplicaSet>) {
const { object, toolbar } = props;
return ( return (
<KubeObjectMenu {...props}/> <>
<MenuItem onClick={() => ReplicaSetScaleDialog.open(object)}>
<Icon material="open_with" title={_i18n._(t`Scale`)} interactive={toolbar}/>
<span className="title"><Trans>Scale</Trans></span>
</MenuItem>
</>
); );
} }
kubeObjectMenuRegistry.add({
kind: "ReplicaSet",
apiVersions: ["apps/v1"],
components: {
MenuItem: ReplicaSetMenu
}
});

View File

@ -25,6 +25,9 @@ export const daemonSetsRoute: RouteProps = {
export const statefulSetsRoute: RouteProps = { export const statefulSetsRoute: RouteProps = {
path: "/statefulsets" path: "/statefulsets"
}; };
export const replicaSetsRoute: RouteProps = {
path: "/replicasets"
};
export const jobsRoute: RouteProps = { export const jobsRoute: RouteProps = {
path: "/jobs" path: "/jobs"
}; };
@ -48,6 +51,9 @@ export interface IDaemonSetsRouteParams {
export interface IStatefulSetsRouteParams { export interface IStatefulSetsRouteParams {
} }
export interface IReplicaSetsRouteParams {
}
export interface IJobsRouteParams { export interface IJobsRouteParams {
} }
@ -61,6 +67,7 @@ export const podsURL = buildURL<IPodsRouteParams>(podsRoute.path);
export const deploymentsURL = buildURL<IDeploymentsRouteParams>(deploymentsRoute.path); export const deploymentsURL = buildURL<IDeploymentsRouteParams>(deploymentsRoute.path);
export const daemonSetsURL = buildURL<IDaemonSetsRouteParams>(daemonSetsRoute.path); export const daemonSetsURL = buildURL<IDaemonSetsRouteParams>(daemonSetsRoute.path);
export const statefulSetsURL = buildURL<IStatefulSetsRouteParams>(statefulSetsRoute.path); export const statefulSetsURL = buildURL<IStatefulSetsRouteParams>(statefulSetsRoute.path);
export const replicaSetsURL = buildURL<IReplicaSetsRouteParams>(replicaSetsRoute.path);
export const jobsURL = buildURL<IJobsRouteParams>(jobsRoute.path); export const jobsURL = buildURL<IJobsRouteParams>(jobsRoute.path);
export const cronJobsURL = buildURL<ICronJobsRouteParams>(cronJobsRoute.path); export const cronJobsURL = buildURL<ICronJobsRouteParams>(cronJobsRoute.path);
@ -69,6 +76,7 @@ export const workloadURL: Partial<Record<KubeResource, ReturnType<typeof buildUR
"deployments": deploymentsURL, "deployments": deploymentsURL,
"daemonsets": daemonSetsURL, "daemonsets": daemonSetsURL,
"statefulsets": statefulSetsURL, "statefulsets": statefulSetsURL,
"replicasets": replicaSetsURL,
"jobs": jobsURL, "jobs": jobsURL,
"cronjobs": cronJobsURL, "cronjobs": cronJobsURL,
}; };

View File

@ -6,12 +6,14 @@ import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store";
import { jobStore } from "../+workloads-jobs/job.store"; import { jobStore } from "../+workloads-jobs/job.store";
import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { cronJobStore } from "../+workloads-cronjobs/cronjob.store";
import { KubeResource } from "../../../common/rbac"; import { KubeResource } from "../../../common/rbac";
import { replicaSetStore } from "../+workloads-replicasets/replicasets.store";
export const workloadStores: Partial<Record<KubeResource, KubeObjectStore>> = { export const workloadStores: Partial<Record<KubeResource, KubeObjectStore>> = {
"pods": podsStore, "pods": podsStore,
"deployments": deploymentStore, "deployments": deploymentStore,
"daemonsets": daemonSetStore, "daemonsets": daemonSetStore,
"statefulsets": statefulSetStore, "statefulsets": statefulSetStore,
"replicasets": replicaSetStore,
"jobs": jobStore, "jobs": jobStore,
"cronjobs": cronJobStore, "cronjobs": cronJobStore,
}; };

View File

@ -5,7 +5,7 @@ import { observer } from "mobx-react";
import { Trans } from "@lingui/macro"; import { Trans } from "@lingui/macro";
import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
import { WorkloadsOverview } from "../+workloads-overview/overview"; import { WorkloadsOverview } from "../+workloads-overview/overview";
import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, statefulSetsRoute, statefulSetsURL } from "./workloads.route"; import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, replicaSetsRoute, replicaSetsURL, statefulSetsRoute, statefulSetsURL } from "./workloads.route";
import { namespaceUrlParam } from "../+namespaces/namespace.store"; import { namespaceUrlParam } from "../+namespaces/namespace.store";
import { Pods } from "../+workloads-pods"; import { Pods } from "../+workloads-pods";
import { Deployments } from "../+workloads-deployments"; import { Deployments } from "../+workloads-deployments";
@ -14,6 +14,7 @@ import { StatefulSets } from "../+workloads-statefulsets";
import { Jobs } from "../+workloads-jobs"; import { Jobs } from "../+workloads-jobs";
import { CronJobs } from "../+workloads-cronjobs"; import { CronJobs } from "../+workloads-cronjobs";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
import { ReplicaSets } from "../+workloads-replicasets";
@observer @observer
export class Workloads extends React.Component { export class Workloads extends React.Component {
@ -64,6 +65,15 @@ export class Workloads extends React.Component {
}); });
} }
if (isAllowedResource("replicasets")) {
routes.push({
title: <Trans>ReplicaSets</Trans>,
component: ReplicaSets,
url: replicaSetsURL({ query }),
routePath: replicaSetsRoute.path.toString(),
});
}
if (isAllowedResource("jobs")) { if (isAllowedResource("jobs")) {
routes.push({ routes.push({
title: <Trans>Jobs</Trans>, title: <Trans>Jobs</Trans>,

View File

@ -48,6 +48,7 @@ import { computed, reaction } from "mobx";
import { nodesStore } from "./+nodes/nodes.store"; import { nodesStore } from "./+nodes/nodes.store";
import { podsStore } from "./+workloads-pods/pods.store"; import { podsStore } from "./+workloads-pods/pods.store";
import { sum } from "lodash"; import { sum } from "lodash";
import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
@ -203,6 +204,7 @@ export class App extends React.Component {
<AddRoleBindingDialog/> <AddRoleBindingDialog/>
<DeploymentScaleDialog/> <DeploymentScaleDialog/>
<StatefulSetScaleDialog/> <StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/>
<CronJobTriggerDialog/> <CronJobTriggerDialog/>
</ErrorBoundary> </ErrorBoundary>
</Router> </Router>

View File

@ -95,15 +95,9 @@ export class PodLogList extends React.Component<Props> {
@action @action
setLastLineVisibility = (props: ListOnScrollProps) => { setLastLineVisibility = (props: ListOnScrollProps) => {
const { scrollHeight, clientHeight } = this.virtualListDiv.current; const { scrollHeight, clientHeight } = this.virtualListDiv.current;
const { scrollOffset, scrollDirection } = props; const { scrollOffset } = props;
if (scrollDirection == "backward") { this.isLastLineVisible = (clientHeight + scrollOffset) === scrollHeight;
this.isLastLineVisible = false;
} else {
if (clientHeight + scrollOffset === scrollHeight) {
this.isLastLineVisible = true;
}
}
}; };
/** /**

View File

@ -91,9 +91,14 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
let items: T[]; let items: T[];
try { try {
const { allowedNamespaces } = getHostedCluster(); const { allowedNamespaces, accessibleNamespaces, isAdmin } = getHostedCluster();
if (isAdmin && accessibleNamespaces.length == 0) {
items = await this.loadItems();
} else {
items = await this.loadItems(allowedNamespaces);
}
items = await this.loadItems(allowedNamespaces);
items = this.filterItemsOnLoad(items); items = this.filterItemsOnLoad(items);
} finally { } finally {
if (items) { if (items) {