From db4dca30053aeed1cecd9e89f53491c773da4e67 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Sun, 15 Mar 2020 09:24:38 +0200 Subject: [PATCH] lens app source code Signed-off-by: Jari Kolehmainen --- .azure-pipelines.yml | 141 + .eslintrc.js | 75 + .gitignore | 9 + .yarnrc | 3 + LICENSE | 204 +- Makefile | 63 + README.md | 26 +- build/download_kubectl.ts | 104 + build/entitlements.mac.plist | 12 + build/icon.ico | Bin 0 -> 9033 bytes build/icon.png | Bin 0 -> 12150 bytes build/icons/512x512.png | Bin 0 -> 13118 bytes build/notarize.js | 20 + dashboard/.babelrc | 10 + dashboard/.dockerignore | 12 + dashboard/.gitignore | 14 + dashboard/.linguirc | 18 + dashboard/client/api/api-manager.ts | 79 + .../endpoints/__tests__/cron-job.api.test.ts | 47 + .../client/api/endpoints/cert-manager.api.ts | 261 + .../api/endpoints/cluster-role-binding.api.ts | 13 + .../client/api/endpoints/cluster-role.api.ts | 15 + dashboard/client/api/endpoints/cluster.api.ts | 114 + .../api/endpoints/component-status.api.ts | 25 + dashboard/client/api/endpoints/config.api.ts | 9 + .../client/api/endpoints/configmap.api.ts | 29 + dashboard/client/api/endpoints/crd.api.ts | 138 + .../client/api/endpoints/cron-job.api.ts | 88 + .../client/api/endpoints/daemon-set.api.ts | 76 + .../client/api/endpoints/deployment.api.ts | 171 + dashboard/client/api/endpoints/events.api.ts | 59 + .../client/api/endpoints/helm-charts.api.ts | 129 + .../client/api/endpoints/helm-releases.api.ts | 213 + dashboard/client/api/endpoints/hpa.api.ts | 140 + dashboard/client/api/endpoints/index.ts | 32 + dashboard/client/api/endpoints/ingress.api.ts | 118 + dashboard/client/api/endpoints/job.api.ts | 98 + .../client/api/endpoints/kubeconfig.api.ts | 12 + dashboard/client/api/endpoints/metrics.api.ts | 112 + .../client/api/endpoints/namespaces.api.ts | 28 + .../api/endpoints/network-policy.api.ts | 72 + dashboard/client/api/endpoints/nodes.api.ts | 158 + .../endpoints/persistent-volume-claims.api.ts | 91 + .../api/endpoints/persistent-volume.api.ts | 71 + .../client/api/endpoints/pod-metrics.api.ts | 21 + dashboard/client/api/endpoints/pods.api.ts | 411 + .../api/endpoints/podsecuritypolicy.api.ts | 94 + .../client/api/endpoints/replica-set.api.ts | 58 + .../api/endpoints/resource-applier.api.ts | 26 + .../api/endpoints/resource-quota.api.ts | 68 + .../client/api/endpoints/role-binding.api.ts | 37 + dashboard/client/api/endpoints/role.api.ts | 24 + dashboard/client/api/endpoints/secret.api.ts | 51 + .../endpoints/selfsubjectrulesreviews.api.ts | 68 + .../api/endpoints/service-accounts.api.ts | 30 + dashboard/client/api/endpoints/service.api.ts | 75 + .../client/api/endpoints/stateful-set.api.ts | 84 + .../client/api/endpoints/storage-class.api.ts | 39 + dashboard/client/api/index.ts | 43 + dashboard/client/api/json-api.ts | 154 + dashboard/client/api/kube-api.ts | 233 + dashboard/client/api/kube-json-api.ts | 68 + dashboard/client/api/kube-object.ts | 142 + dashboard/client/api/kube-watch-api.ts | 151 + dashboard/client/api/terminal-api.ts | 172 + dashboard/client/api/websocket-api.ts | 169 + dashboard/client/api/workload-kube-object.ts | 100 + dashboard/client/browser-check.tsx | 20 + dashboard/client/components/+404/index.ts | 1 + .../client/components/+404/not-found.tsx | 15 + .../+apps-helm-charts/helm-chart-details.scss | 53 + .../+apps-helm-charts/helm-chart-details.tsx | 138 + .../+apps-helm-charts/helm-chart.store.ts | 67 + .../+apps-helm-charts/helm-charts.route.ts | 14 + .../+apps-helm-charts/helm-charts.scss | 62 + .../+apps-helm-charts/helm-charts.tsx | 109 + .../+apps-helm-charts/helm-placeholder.svg | 1 + .../components/+apps-helm-charts/index.ts | 2 + .../client/components/+apps-releases/index.ts | 2 + .../+apps-releases/release-details.scss | 86 + .../+apps-releases/release-details.tsx | 254 + .../+apps-releases/release-menu.tsx | 70 + .../release-rollback-dialog.scss | 9 + .../release-rollback-dialog.tsx | 109 + .../+apps-releases/release.mixins.scss | 25 + .../+apps-releases/release.route.ts | 14 + .../+apps-releases/release.store.ts | 112 + .../components/+apps-releases/releases.scss | 19 + .../components/+apps-releases/releases.tsx | 183 + .../client/components/+apps/apps.route.ts | 8 + dashboard/client/components/+apps/apps.tsx | 41 + dashboard/client/components/+apps/index.ts | 2 + .../components/+cluster/cluster-issues.scss | 58 + .../components/+cluster/cluster-issues.tsx | 149 + .../+cluster/cluster-metric-switchers.scss | 7 + .../+cluster/cluster-metric-switchers.tsx | 43 + .../components/+cluster/cluster-metrics.scss | 16 + .../components/+cluster/cluster-metrics.tsx | 95 + .../+cluster/cluster-no-metrics.tsx | 18 + .../+cluster/cluster-pie-charts.scss | 29 + .../+cluster/cluster-pie-charts.tsx | 204 + .../components/+cluster/cluster.routes.ts | 8 + .../client/components/+cluster/cluster.scss | 24 + .../components/+cluster/cluster.store.ts | 110 + .../client/components/+cluster/cluster.tsx | 69 + dashboard/client/components/+cluster/index.ts | 2 + .../autoscaler.mixins.scss | 14 + .../+config-autoscalers/hpa-details.scss | 17 + .../+config-autoscalers/hpa-details.tsx | 133 + .../+config-autoscalers/hpa.route.ts | 11 + .../components/+config-autoscalers/hpa.scss | 26 + .../+config-autoscalers/hpa.store.ts | 12 + .../components/+config-autoscalers/hpa.tsx | 99 + .../components/+config-autoscalers/index.ts | 3 + .../+config-maps/config-map-details.scss | 15 + .../+config-maps/config-map-details.tsx | 99 + .../+config-maps/config-maps.route.ts | 11 + .../components/+config-maps/config-maps.scss | 15 + .../+config-maps/config-maps.store.ts | 12 + .../components/+config-maps/config-maps.tsx | 69 + .../client/components/+config-maps/index.ts | 3 + .../add-quota-dialog.scss | 38 + .../add-quota-dialog.tsx | 204 + .../+config-resource-quotas/index.ts | 3 + .../resource-quota-details.scss | 16 + .../resource-quota-details.tsx | 94 + .../resource-quotas.route.ts | 11 + .../resource-quotas.scss | 2 + .../resource-quotas.store.ts | 12 + .../resource-quotas.tsx | 73 + .../+config-secrets/add-secret-dialog.scss | 19 + .../+config-secrets/add-secret-dialog.tsx | 221 + .../components/+config-secrets/index.ts | 4 + .../+config-secrets/secret-details.scss | 15 + .../+config-secrets/secret-details.tsx | 118 + .../+config-secrets/secrets.route.ts | 11 + .../components/+config-secrets/secrets.scss | 11 + .../+config-secrets/secrets.store.ts | 12 + .../components/+config-secrets/secrets.tsx | 86 + .../client/components/+config/config.route.ts | 12 + .../client/components/+config/config.tsx | 80 + dashboard/client/components/+config/index.ts | 2 + .../cert-manager.mixins.scss | 12 + .../certificate-details.scss | 7 + .../certificate-details.tsx | 142 + .../certmanager.k8s.io/certificates.scss | 26 + .../certmanager.k8s.io/certificates.tsx | 105 + .../certmanager.k8s.io/index.ts | 4 + .../certmanager.k8s.io/issuer-details.scss | 7 + .../certmanager.k8s.io/issuer-details.tsx | 175 + .../certmanager.k8s.io/issuers.scss | 27 + .../certmanager.k8s.io/issuers.tsx | 103 + .../+custom-resources/crd-details.scss | 45 + .../+custom-resources/crd-details.tsx | 138 + .../+custom-resources/crd-list.scss | 24 + .../components/+custom-resources/crd-list.tsx | 122 + .../crd-resource-details.scss | 12 + .../crd-resource-details.tsx | 69 + .../+custom-resources/crd-resource.store.ts | 14 + .../+custom-resources/crd-resources.scss | 3 + .../+custom-resources/crd-resources.tsx | 112 + .../+custom-resources/crd.mixins.scss | 17 + .../components/+custom-resources/crd.route.ts | 26 + .../components/+custom-resources/crd.store.ts | 77 + .../+custom-resources/custom-resources.tsx | 37 + .../components/+custom-resources/index.ts | 8 + .../components/+events/event-details.scss | 7 + .../components/+events/event-details.tsx | 79 + .../client/components/+events/event.store.ts | 49 + .../client/components/+events/events.route.ts | 8 + .../client/components/+events/events.scss | 32 + .../client/components/+events/events.tsx | 130 + dashboard/client/components/+events/index.ts | 3 + .../+events/kube-event-details.scss | 24 + .../components/+events/kube-event-details.tsx | 61 + .../components/+events/kube-event-icon.scss | 17 + .../components/+events/kube-event-icon.tsx | 49 + .../+namespaces/add-namespace-dialog.scss | 2 + .../+namespaces/add-namespace-dialog.tsx | 79 + .../client/components/+namespaces/index.ts | 4 + .../+namespaces/namespace-details.scss | 11 + .../+namespaces/namespace-details.tsx | 61 + .../+namespaces/namespace-select.scss | 22 + .../+namespaces/namespace-select.tsx | 105 + .../components/+namespaces/namespace.store.ts | 76 + .../+namespaces/namespaces-mixins.scss | 9 + .../+namespaces/namespaces.route.ts | 11 + .../components/+namespaces/namespaces.scss | 16 + .../components/+namespaces/namespaces.tsx | 81 + .../components/+network-ingresses/index.ts | 3 + .../+network-ingresses/ingress-charts.tsx | 97 + .../+network-ingresses/ingress-details.scss | 29 + .../+network-ingresses/ingress-details.tsx | 113 + .../+network-ingresses/ingress.store.ts | 22 + .../+network-ingresses/ingresses.route.ts | 11 + .../+network-ingresses/ingresses.scss | 7 + .../+network-ingresses/ingresses.tsx | 73 + .../components/+network-policies/index.ts | 3 + .../network-policies.route.ts | 11 + .../+network-policies/network-policies.scss | 2 + .../+network-policies/network-policies.tsx | 66 + .../network-policy-details.scss | 5 + .../network-policy-details.tsx | 149 + .../+network-policies/network-policy.store.ts | 12 + .../components/+network-services/index.ts | 3 + .../+network-services/service-details.scss | 3 + .../+network-services/service-details.tsx | 70 + .../+network-services/services.route.ts | 11 + .../+network-services/services.scss | 20 + .../+network-services/services.store.ts | 12 + .../components/+network-services/services.tsx | 89 + dashboard/client/components/+network/index.ts | 2 + .../components/+network/network-mixins.scss | 12 + .../components/+network/network.route.ts | 12 + .../client/components/+network/network.scss | 2 + .../client/components/+network/network.tsx | 55 + dashboard/client/components/+nodes/index.ts | 4 + .../client/components/+nodes/node-charts.tsx | 154 + .../components/+nodes/node-details.scss | 11 + .../client/components/+nodes/node-details.tsx | 158 + .../client/components/+nodes/node-menu.tsx | 78 + .../components/+nodes/nodes-mixins.scss | 28 + .../client/components/+nodes/nodes.route.ts | 11 + dashboard/client/components/+nodes/nodes.scss | 53 + .../client/components/+nodes/nodes.store.ts | 72 + dashboard/client/components/+nodes/nodes.tsx | 186 + .../+pod-security-policies/index.ts | 3 + .../pod-security-policies.route.ts | 8 + .../pod-security-policies.scss | 9 + .../pod-security-policies.store.ts | 12 + .../pod-security-policies.tsx | 69 + .../pod-security-policy-details.scss | 2 + .../pod-security-policy-details.tsx | 214 + .../components/+storage-classes/index.ts | 4 + .../storage-class-details.scss | 3 + .../storage-class-details.tsx | 67 + .../+storage-classes/storage-class.store.ts | 12 + .../+storage-classes/storage-classes.route.ts | 12 + .../+storage-classes/storage-classes.scss | 7 + .../+storage-classes/storage-classes.tsx | 72 + .../+storage-volume-claims/index.ts | 4 + .../volume-claim-details.scss | 7 + .../volume-claim-details.tsx | 100 + .../volume-claim-disk-chart.tsx | 52 + .../volume-claim.store.ts | 23 + .../volume-claims.route.ts | 11 + .../+storage-volume-claims/volume-claims.scss | 33 + .../+storage-volume-claims/volume-claims.tsx | 101 + .../components/+storage-volumes/index.ts | 3 + .../+storage-volumes/volume-details.scss | 3 + .../+storage-volumes/volume-details.tsx | 108 + .../+storage-volumes/volumes.route.ts | 11 + .../components/+storage-volumes/volumes.scss | 33 + .../+storage-volumes/volumes.store.ts | 12 + .../components/+storage-volumes/volumes.tsx | 91 + dashboard/client/components/+storage/index.ts | 2 + .../components/+storage/storage-mixins.scss | 40 + .../components/+storage/storage.route.ts | 12 + .../client/components/+storage/storage.scss | 2 + .../client/components/+storage/storage.tsx | 64 + .../add-role-binding-dialog.scss | 11 + .../add-role-binding-dialog.tsx | 280 + .../+user-management-roles-bindings/index.ts | 3 + .../role-binding-details.scss | 2 + .../role-binding-details.tsx | 127 + .../role-bindings.scss | 5 + .../role-bindings.store.ts | 75 + .../role-bindings.tsx | 93 + .../add-role-dialog.scss | 5 + .../add-role-dialog.tsx | 79 + .../+user-management-roles/index.ts | 3 + .../+user-management-roles/role-details.scss | 21 + .../+user-management-roles/role-details.tsx | 71 + .../+user-management-roles/roles.scss | 5 + .../+user-management-roles/roles.store.ts | 51 + .../+user-management-roles/roles.tsx | 91 + .../create-service-account-dialog.scss | 2 + .../create-service-account-dialog.tsx | 83 + .../index.ts | 3 + .../service-accounts-details.scss | 7 + .../service-accounts-details.tsx | 103 + .../service-accounts-secret.scss | 40 + .../service-accounts-secret.tsx | 70 + .../service-accounts.scss | 2 + .../service-accounts.store.ts | 17 + .../service-accounts.tsx | 81 + .../components/+user-management/index.ts | 2 + .../user-management.routes.ts | 38 + .../+user-management/user-management.scss | 2 + .../+user-management/user-management.tsx | 68 + .../+workloads-cronjobs/cronjob-details.scss | 19 + .../+workloads-cronjobs/cronjob-details.tsx | 92 + .../+workloads-cronjobs/cronjob.store.ts | 33 + .../+workloads-cronjobs/cronjobs.scss | 7 + .../+workloads-cronjobs/cronjobs.tsx | 89 + .../components/+workloads-cronjobs/index.ts | 2 + .../daemonset-details.scss | 2 + .../daemonset-details.tsx | 102 + .../+workloads-daemonsets/daemonsets.scss | 20 + .../+workloads-daemonsets/daemonsets.store.ts | 48 + .../+workloads-daemonsets/daemonsets.tsx | 89 + .../components/+workloads-daemonsets/index.ts | 2 + .../deployment-details.scss | 20 + .../deployment-details.tsx | 127 + .../deployment-scale-dialog.scss | 42 + .../deployment-scale-dialog.tsx | 142 + .../+workloads-deployments/deployments.scss | 41 + .../deployments.store.ts | 55 + .../+workloads-deployments/deployments.tsx | 110 + .../+workloads-deployments/index.ts | 2 + .../components/+workloads-jobs/index.ts | 2 + .../+workloads-jobs/job-details.scss | 8 + .../+workloads-jobs/job-details.tsx | 112 + .../components/+workloads-jobs/job.store.ts | 45 + .../components/+workloads-jobs/jobs.scss | 17 + .../components/+workloads-jobs/jobs.tsx | 83 + .../overview-statuses.scss | 35 + .../+workloads-overview/overview-statuses.tsx | 65 + .../overview-workload-status.scss | 15 + .../overview-workload-status.tsx | 79 + .../+workloads-overview/overview.scss | 4 + .../+workloads-overview/overview.tsx | 74 + .../+workloads-pods/container-charts.tsx | 103 + .../components/+workloads-pods/index.ts | 2 + .../components/+workloads-pods/pod-charts.tsx | 131 + .../+workloads-pods/pod-container-env.scss | 20 + .../+workloads-pods/pod-container-env.tsx | 137 + .../pod-details-affinities.scss | 5 + .../pod-details-affinities.tsx | 34 + .../pod-details-container.scss | 44 + .../+workloads-pods/pod-details-container.tsx | 127 + .../+workloads-pods/pod-details-list.scss | 53 + .../+workloads-pods/pod-details-list.tsx | 155 + .../+workloads-pods/pod-details-secrets.scss | 10 + .../+workloads-pods/pod-details-secrets.tsx | 44 + .../+workloads-pods/pod-details-statuses.scss | 18 + .../+workloads-pods/pod-details-statuses.tsx | 28 + .../pod-details-tolerations.scss | 5 + .../pod-details-tolerations.tsx | 36 + .../+workloads-pods/pod-details.scss | 13 + .../+workloads-pods/pod-details.tsx | 212 + .../+workloads-pods/pod-logs-dialog.scss | 110 + .../+workloads-pods/pod-logs-dialog.tsx | 304 + .../components/+workloads-pods/pod-menu.scss | 10 + .../components/+workloads-pods/pod-menu.tsx | 111 + .../components/+workloads-pods/pods.scss | 27 + .../components/+workloads-pods/pods.store.ts | 79 + .../components/+workloads-pods/pods.tsx | 130 + .../+workloads-replicasets/index.ts | 2 + .../replicaset-details.scss | 2 + .../replicaset-details.tsx | 102 + .../+workloads-replicasets/replicasets.scss | 30 + .../replicasets.store.ts | 36 + .../+workloads-replicasets/replicasets.tsx | 98 + .../+workloads-statefulsets/index.ts | 2 + .../statefulset-details.scss | 2 + .../statefulset-details.tsx | 100 + .../statefulset.store.ts | 47 + .../+workloads-statefulsets/statefulsets.scss | 15 + .../+workloads-statefulsets/statefulsets.tsx | 79 + .../client/components/+workloads/index.ts | 3 + .../+workloads/workloads-mixins.scss | 81 + .../components/+workloads/workloads.route.ts | 64 + .../components/+workloads/workloads.scss | 2 + .../components/+workloads/workloads.tsx | 83 + .../components/ace-editor/ace-editor.scss | 61 + .../components/ace-editor/ace-editor.tsx | 190 + .../client/components/ace-editor/index.ts | 1 + .../add-remove-buttons.scss | 15 + .../add-remove-buttons/add-remove-buttons.tsx | 52 + .../components/add-remove-buttons/index.ts | 1 + .../client/components/animate/animate.scss | 68 + .../client/components/animate/animate.tsx | 94 + dashboard/client/components/animate/index.ts | 1 + .../client/components/app-init/app-init.scss | 8 + .../client/components/app-init/app-init.tsx | 52 + dashboard/client/components/app.scss | 192 + dashboard/client/components/app.tsx | 84 + dashboard/client/components/badge/badge.scss | 15 + dashboard/client/components/badge/badge.tsx | 23 + dashboard/client/components/badge/index.ts | 1 + .../client/components/button/button.scss | 115 + dashboard/client/components/button/button.tsx | 57 + dashboard/client/components/button/index.ts | 1 + .../chart/background-block.plugin.ts | 42 + .../client/components/chart/bar-chart.tsx | 215 + dashboard/client/components/chart/chart.scss | 27 + dashboard/client/components/chart/chart.tsx | 211 + dashboard/client/components/chart/index.ts | 3 + .../client/components/chart/pie-chart.scss | 18 + .../client/components/chart/pie-chart.tsx | 63 + .../components/chart/useRealTimeMetrics.ts | 43 + .../components/chart/zebra-stripes.plugin.ts | 95 + .../client/components/checkbox/checkbox.scss | 86 + .../client/components/checkbox/checkbox.tsx | 51 + dashboard/client/components/checkbox/index.ts | 1 + dashboard/client/components/colors.scss | 300 + .../confirm-dialog/confirm-dialog.scss | 50 + .../confirm-dialog/confirm-dialog.tsx | 101 + .../client/components/confirm-dialog/index.ts | 1 + .../client/components/dialog/dialog.scss | 18 + dashboard/client/components/dialog/dialog.tsx | 145 + dashboard/client/components/dialog/index.ts | 1 + .../client/components/dialog/logs-dialog.scss | 15 + .../client/components/dialog/logs-dialog.tsx | 52 + .../components/dock/create-resource.scss | 2 + .../components/dock/create-resource.store.ts | 26 + .../components/dock/create-resource.tsx | 85 + .../client/components/dock/dock-tab.scss | 34 + .../client/components/dock/dock-tab.store.ts | 58 + dashboard/client/components/dock/dock-tab.tsx | 51 + dashboard/client/components/dock/dock.scss | 80 + .../client/components/dock/dock.store.ts | 192 + dashboard/client/components/dock/dock.tsx | 153 + .../client/components/dock/edit-resource.scss | 2 + .../components/dock/edit-resource.store.ts | 90 + .../client/components/dock/edit-resource.tsx | 115 + .../client/components/dock/editor-panel.tsx | 81 + dashboard/client/components/dock/index.ts | 1 + .../client/components/dock/info-panel.scss | 48 + .../client/components/dock/info-panel.tsx | 137 + .../client/components/dock/install-chart.scss | 16 + .../components/dock/install-chart.store.ts | 97 + .../client/components/dock/install-chart.tsx | 194 + .../client/components/dock/terminal-tab.scss | 3 + .../client/components/dock/terminal-tab.tsx | 51 + .../components/dock/terminal-window.scss | 28 + .../components/dock/terminal-window.tsx | 45 + .../client/components/dock/terminal.store.ts | 119 + dashboard/client/components/dock/terminal.ts | 189 + .../client/components/dock/upgrade-chart.scss | 2 + .../components/dock/upgrade-chart.store.ts | 123 + .../client/components/dock/upgrade-chart.tsx | 133 + .../components/draggable/draggable.scss | 5 + .../client/components/draggable/draggable.tsx | 119 + .../client/components/draggable/index.ts | 1 + .../components/drawer/drawer-item-labels.tsx | 19 + .../client/components/drawer/drawer-item.scss | 68 + .../client/components/drawer/drawer-item.tsx | 26 + .../drawer/drawer-param-toggler.scss | 22 + .../drawer/drawer-param-toggler.tsx | 40 + .../components/drawer/drawer-title.scss | 5 + .../client/components/drawer/drawer-title.tsx | 19 + .../client/components/drawer/drawer.scss | 90 + dashboard/client/components/drawer/drawer.tsx | 125 + dashboard/client/components/drawer/index.ts | 5 + .../error-boundary/error-boundary.scss | 14 + .../error-boundary/error-boundary.tsx | 75 + .../client/components/error-boundary/index.ts | 1 + dashboard/client/components/fonts.scss | 117 + .../fonts/MaterialIcons-Regular.woff2 | Bin 0 -> 60840 bytes .../components/fonts/roboto-mono-nerd.ttf | Bin 0 -> 956172 bytes .../fonts/roboto-v20-cyrillic_latin-100.woff2 | Bin 0 -> 22012 bytes .../roboto-v20-cyrillic_latin-100italic.woff2 | Bin 0 -> 23816 bytes .../fonts/roboto-v20-cyrillic_latin-300.woff2 | Bin 0 -> 22376 bytes .../roboto-v20-cyrillic_latin-300italic.woff2 | Bin 0 -> 24808 bytes .../fonts/roboto-v20-cyrillic_latin-500.woff2 | Bin 0 -> 22880 bytes .../roboto-v20-cyrillic_latin-500italic.woff2 | Bin 0 -> 24776 bytes .../fonts/roboto-v20-cyrillic_latin-700.woff2 | Bin 0 -> 22536 bytes .../roboto-v20-cyrillic_latin-700italic.woff2 | Bin 0 -> 24164 bytes .../roboto-v20-cyrillic_latin-italic.woff2 | Bin 0 -> 24380 bytes .../roboto-v20-cyrillic_latin-regular.woff2 | Bin 0 -> 22428 bytes .../client/components/icon/configuration.svg | 1 + dashboard/client/components/icon/group.svg | 1 + dashboard/client/components/icon/icon.scss | 119 + dashboard/client/components/icon/icon.tsx | 115 + dashboard/client/components/icon/index.ts | 1 + dashboard/client/components/icon/install.svg | 1 + dashboard/client/components/icon/kube.svg | 1 + dashboard/client/components/icon/license.svg | 1 + .../client/components/icon/logo-full.svg | 21 + dashboard/client/components/icon/logo.svg | 7 + dashboard/client/components/icon/logout.svg | 1 + dashboard/client/components/icon/nodes.svg | 1 + dashboard/client/components/icon/push-pin.svg | 1 + dashboard/client/components/icon/spinner.svg | 29 + dashboard/client/components/icon/ssh.svg | 4 + dashboard/client/components/icon/storage.svg | 1 + dashboard/client/components/icon/terminal.svg | 10 + dashboard/client/components/icon/user.svg | 1 + dashboard/client/components/icon/users.svg | 1 + dashboard/client/components/icon/wheel.svg | 46 + .../client/components/icon/workloads.svg | 2 + dashboard/client/components/input/index.ts | 2 + dashboard/client/components/input/input.scss | 109 + dashboard/client/components/input/input.tsx | 297 + .../components/input/input.validators.ts | 68 + .../client/components/input/search-input.scss | 30 + .../client/components/input/search-input.tsx | 81 + .../item-object-list/filter-icon.tsx | 21 + .../components/item-object-list/index.tsx | 1 + .../item-object-list/item-list-layout.scss | 39 + .../item-object-list/item-list-layout.tsx | 443 + .../item-object-list/page-filters-list.scss | 41 + .../item-object-list/page-filters-list.tsx | 73 + .../item-object-list/page-filters-select.tsx | 103 + .../item-object-list/page-filters.store.ts | 117 + .../client/components/items-list/index.ts | 1 + .../components/items-list/items-list.scss | 45 + .../components/items-list/items-list.tsx | 121 + .../client/components/kube-object/index.ts | 3 + .../kube-object/kube-object-details.scss | 3 + .../kube-object/kube-object-details.tsx | 91 + .../kube-object/kube-object-list-layout.tsx | 40 + .../kube-object/kube-object-menu.tsx | 70 + .../kube-object/kube-object-meta.tsx | 65 + .../components/kubeconfig-dialog/index.ts | 1 + .../kubeconfig-dialog/kubeconfig-dialog.scss | 20 + .../kubeconfig-dialog/kubeconfig-dialog.tsx | 129 + .../components/layout/login-layout.scss | 50 + .../client/components/layout/login-layout.tsx | 36 + .../client/components/layout/main-layout.scss | 86 + .../client/components/layout/main-layout.tsx | 97 + .../client/components/layout/sidebar.scss | 164 + .../client/components/layout/sidebar.tsx | 257 + .../client/components/layout/sub-header.scss | 13 + .../client/components/layout/sub-header.tsx | 25 + .../client/components/layout/sub-title.scss | 14 + .../client/components/layout/sub-title.tsx | 24 + .../client/components/line-progress/index.ts | 1 + .../line-progress/line-progress.scss | 25 + .../line-progress/line-progress.tsx | 37 + .../components/markdown-viewer/index.ts | 1 + .../markdown-viewer/markdown-viewer.scss | 680 + .../markdown-viewer/markdown-viewer.tsx | 37 + dashboard/client/components/media.scss | 8 + dashboard/client/components/menu/index.ts | 2 + .../client/components/menu/menu-actions.scss | 62 + .../client/components/menu/menu-actions.tsx | 119 + .../client/components/menu/menu-picker.scss | 74 + .../client/components/menu/menu-picker.tsx | 42 + dashboard/client/components/menu/menu.scss | 101 + dashboard/client/components/menu/menu.tsx | 337 + dashboard/client/components/mixins.scss | 91 + dashboard/client/components/no-items/index.ts | 1 + .../client/components/no-items/no-items.scss | 4 + .../client/components/no-items/no-items.tsx | 21 + .../client/components/notifications/index.ts | 1 + .../notifications/notifications.scss | 45 + .../notifications/notifications.store.ts | 61 + .../notifications/notifications.tsx | 96 + dashboard/client/components/radio/index.ts | 1 + dashboard/client/components/radio/radio.scss | 110 + dashboard/client/components/radio/radio.tsx | 86 + .../components/resource-metrics/index.ts | 2 + .../resource-metrics/no-metrics.tsx | 11 + .../resource-metrics-text.tsx | 31 + .../resource-metrics/resource-metrics.scss | 38 + .../resource-metrics/resource-metrics.tsx | 78 + dashboard/client/components/select/index.ts | 1 + .../client/components/select/select.scss | 183 + dashboard/client/components/select/select.tsx | 124 + dashboard/client/components/slider/index.ts | 1 + .../client/components/slider/slider.scss | 17 + dashboard/client/components/slider/slider.tsx | 41 + .../components/spinner/cube-spinner.scss | 86 + .../components/spinner/cube-spinner.tsx | 28 + dashboard/client/components/spinner/index.ts | 2 + .../client/components/spinner/spinner.scss | 65 + .../client/components/spinner/spinner.tsx | 28 + .../client/components/status-brick/index.ts | 1 + .../components/status-brick/status-brick.scss | 15 + .../components/status-brick/status-brick.tsx | 21 + dashboard/client/components/stepper/index.ts | 1 + .../client/components/stepper/stepper.scss | 53 + .../client/components/stepper/stepper.tsx | 40 + dashboard/client/components/table/index.ts | 5 + .../client/components/table/table-cell.scss | 69 + .../client/components/table/table-cell.tsx | 75 + .../client/components/table/table-head.scss | 37 + .../client/components/table/table-head.tsx | 32 + .../client/components/table/table-row.scss | 14 + .../client/components/table/table-row.tsx | 29 + .../client/components/table/table.mixins.scss | 35 + dashboard/client/components/table/table.scss | 53 + dashboard/client/components/table/table.tsx | 184 + dashboard/client/components/tabs/index.ts | 1 + dashboard/client/components/tabs/tabs.scss | 73 + dashboard/client/components/tabs/tabs.tsx | 145 + dashboard/client/components/tooltip/index.ts | 2 + .../client/components/tooltip/tooltip.scss | 104 + .../client/components/tooltip/tooltip.tsx | 151 + .../client/components/tooltip/withTooltip.tsx | 48 + dashboard/client/components/vars.scss | 37 + .../client/components/virtual-list/index.ts | 1 + .../components/virtual-list/virtual-list.scss | 15 + .../components/virtual-list/virtual-list.tsx | 125 + dashboard/client/components/wizard/index.ts | 1 + .../client/components/wizard/wizard.scss | 84 + dashboard/client/components/wizard/wizard.tsx | 230 + dashboard/client/config.store.ts | 58 + .../client/favicon/android-chrome-512x512.png | Bin 0 -> 6218 bytes dashboard/client/favicon/apple-touch-icon.png | Bin 0 -> 1027 bytes dashboard/client/favicon/favicon-16x16.png | Bin 0 -> 1241 bytes dashboard/client/favicon/favicon-32x32.png | Bin 0 -> 1606 bytes .../client/favicon/safari-pinned-tab.svg | 1 + dashboard/client/hooks/index.ts | 5 + dashboard/client/hooks/useInterval.ts | 18 + dashboard/client/hooks/useOnUnmount.ts | 5 + dashboard/client/hooks/useStorage.ts | 12 + dashboard/client/i18n.ts | 62 + dashboard/client/item.store.ts | 165 + dashboard/client/kube-object.store.ts | 208 + dashboard/client/navigation.ts | 85 + dashboard/client/theme.store.ts | 195 + dashboard/client/themes/kontena-dark.json | 105 + dashboard/client/themes/kontena-light.json | 106 + dashboard/client/themes/theme-vars.scss | 122 + dashboard/client/tsconfig.json | 30 + .../client/utils/__tests__/convertCpu.test.ts | 22 + .../utils/__tests__/convertMemory.test.ts | 89 + dashboard/client/utils/autobind.ts | 44 + dashboard/client/utils/base64.ts | 12 + dashboard/client/utils/camelCase.ts | 18 + dashboard/client/utils/cancelableFetch.ts | 34 + dashboard/client/utils/convertCpu.ts | 10 + dashboard/client/utils/convertMemory.ts | 28 + dashboard/client/utils/copyToClipboard.ts | 21 + dashboard/client/utils/createStorage.ts | 67 + dashboard/client/utils/cssNames.ts | 22 + dashboard/client/utils/cssVar.ts | 17 + dashboard/client/utils/debouncePromise.ts | 9 + dashboard/client/utils/downloadFile.ts | 12 + dashboard/client/utils/eventEmitter.ts | 40 + dashboard/client/utils/formatDuration.ts | 24 + dashboard/client/utils/index.ts | 21 + dashboard/client/utils/interval.ts | 32 + dashboard/client/utils/isReactNode.ts | 9 + dashboard/client/utils/prevDefault.ts | 25 + dashboard/index.html | 20 + dashboard/locales/en/messages.po | 2443 ++++ dashboard/locales/ru/messages.po | 2450 ++++ dashboard/package.json | 145 + dashboard/server/api/get-cert-auth-data.ts | 23 + dashboard/server/api/get-cluster-info.ts | 39 + dashboard/server/api/get-namespaces.ts | 59 + .../server/api/get-service-account-token.ts | 22 + dashboard/server/api/is-cluster-admin.ts | 19 + dashboard/server/api/kube-request.ts | 58 + .../server/api/review-resource-access.ts | 47 + dashboard/server/api/review-token.ts | 39 + dashboard/server/app.ts | 76 + dashboard/server/common/cluster.ts | 9 + dashboard/server/common/config.ts | 11 + dashboard/server/common/kubewatch.ts | 14 + dashboard/server/common/license.ts | 12 + dashboard/server/common/metrics.ts | 4 + dashboard/server/config.ts | 74 + dashboard/server/middlewares/index.ts | 3 + dashboard/server/middlewares/kube-proxy.ts | 24 + .../server/middlewares/terminal-proxy.ts | 19 + .../server/middlewares/use-header-token.ts | 20 + dashboard/server/routes/config-route.ts | 48 + dashboard/server/routes/index.ts | 5 + dashboard/server/routes/kubeconfig-route.ts | 100 + dashboard/server/routes/kubewatch-route.ts | 139 + dashboard/server/routes/metrics-route.ts | 82 + dashboard/server/routes/ready-state-route.ts | 16 + dashboard/server/tsconfig.json | 14 + dashboard/server/user-session.ts | 24 + dashboard/server/utils/kube-config.dev.ts | 32 + dashboard/server/utils/logger.ts | 36 + dashboard/server/utils/parse-jwt.ts | 30 + dashboard/test/jest.config.js | 28 + dashboard/test/setup-tests.js | 4 + dashboard/test/tsconfig.json | 8 + dashboard/tools/port-forward.ts | 125 + dashboard/webpack.config.ts | 144 + dashboard/yarn.lock | 10691 ++++++++++++++ package.json | 223 + patches/@kubernetes+client-node+0.11.0.patch | 30 + spec/src/common/cluster-store_spec.ts | 344 + spec/src/common/user-store_spec.ts | 87 + spec/src/main/port_spec.ts | 39 + src/common/.gitkeep | 0 src/common/app-utils.ts | 14 + src/common/cluster-store.ts | 113 + .../migrations/cluster-store/2.0.0-beta.2.ts | 13 + src/common/migrations/cluster-store/2.4.1.ts | 11 + .../migrations/cluster-store/2.6.0-beta.2.ts | 15 + .../migrations/cluster-store/2.6.0-beta.3.ts | 35 + .../migrations/cluster-store/2.7.0-beta.0.ts | 11 + .../migrations/cluster-store/2.7.0-beta.1.ts | 22 + .../migrations/user-store/2.1.0-beta.4.ts | 4 + src/common/request.ts | 12 + src/common/system-ca.ts | 6 + src/common/tracker.ts | 42 + src/common/user-store.ts | 94 + src/common/workspace-store.ts | 74 + src/features/metrics.ts | 104 + .../features}/metrics/01-namespace.yml | 0 .../features/metrics/02-configmap.yml.hb | 28 +- .../features}/metrics/03-service.yml | 0 .../features/metrics/03-statefulset.yml.hb | 48 +- .../features}/metrics/04-rules.yml | 0 .../features}/metrics/05-clusterrole.yml | 0 .../features}/metrics/05-service-account.yml | 0 .../metrics/06-clusterrole-binding.yml | 0 .../metrics/10-node-exporter-ds.yml.hb | 18 +- .../metrics/11-node-exporter-svc.yml | 0 .../12-kube-state-metrics-clusterrole.yml | 0 .../metrics/12.kube-state-metrics-sa.yml | 0 ...kube-state-metrics-clusterrole-binding.yml | 0 .../14-kube-state-metrics-deployment.yml.hb | 18 +- .../metrics/14-kube-state-metrics-svc.yml | 0 src/features/user-mode.ts | 46 + src/features/user-mode/01-clusterrole.yml | 11 + .../user-mode/02-clusterrrolebinding.yml | 12 + src/main/app-updater.ts | 19 + src/main/cluster-manager.ts | 278 + src/main/cluster.ts | 311 + src/main/context-handler.ts | 245 + src/main/feature-manager.ts | 49 + src/main/feature.ts | 106 + src/main/file-helpers.ts | 11 + src/main/helm-api.ts | 114 + src/main/helm-chart-manager.ts | 73 + src/main/helm-cli.ts | 31 + src/main/helm-release-manager.ts | 112 + src/main/helm-repo-manager.ts | 155 + src/main/helm-service.ts | 105 + src/main/index.ts | 127 + src/main/k8s.ts | 155 + src/main/kube-auth-proxy.ts | 109 + src/main/kubeconfig-manager.ts | 32 + src/main/kubectl.ts | 293 + src/main/lens-api.ts | 17 + src/main/lens-binary.ts | 167 + src/main/lens-server.ts | 63 + src/main/logger.ts | 20 + src/main/menu.ts | 212 + src/main/node-shell-session.ts | 143 + src/main/port.ts | 27 + src/main/promise-exec.ts | 4 + src/main/proxy-env.ts | 18 + src/main/proxy.ts | 203 + src/main/resource-applier-api.ts | 17 + src/main/resource-applier.ts | 101 + src/main/router.ts | 85 + src/main/shell-session.ts | 161 + src/main/shell-sync.ts | 18 + src/main/tracker.ts | 4 + src/main/webcontents.ts | 7 + src/main/window-manager.ts | 91 + src/renderer/App.vue | 37 + src/renderer/assets/css/app.scss | 191 + src/renderer/assets/css/custom.scss | 72 + src/renderer/assets/css/fonts.scss | 2 + src/renderer/assets/img/crane.svg | 182 + src/renderer/assets/img/lens-logo.svg | 1 + src/renderer/assets/img/planet.png | Bin 0 -> 247978 bytes src/renderer/components/AddClusterPage.vue | 274 + src/renderer/components/AddWorkspacePage.vue | 115 + .../components/BottomBar/BottomBar.vue | 88 + src/renderer/components/ClusterPage.vue | 186 + .../Features/Components/Metrics.vue | 134 + .../Features/Components/UserMode.vue | 131 + .../Features/Components/index.js | 7 + .../ClusterSettings/Features/index.vue | 51 + .../ClusterSettings/General/ClusterIcon.vue | 148 + .../ClusterSettings/General/ClusterName.vue | 49 + .../General/ClusterWorkspace.vue | 52 + .../ClusterSettings/General/index.vue | 35 + .../ClusterSettings/Overview/index.vue | 69 + .../ClusterSettings/Preferences/index.vue | 134 + .../components/ClusterSettings/index.vue | 185 + src/renderer/components/CubeSpinner.vue | 116 + src/renderer/components/EditWorkspacePage.vue | 124 + src/renderer/components/LandingPage.vue | 71 + .../MainMenu/AddClusterMenuItem.vue | 72 + .../components/MainMenu/ClusterMenuItem.vue | 166 + src/renderer/components/MainMenu/MainMenu.vue | 100 + src/renderer/components/PreferencesPage.vue | 289 + src/renderer/components/WhatsNewPage.vue | 112 + src/renderer/components/WorkspacesPage.vue | 156 + .../components/common/ClosePageButton.vue | 40 + src/renderer/components/hashicon/hashicon.vue | 28 + src/renderer/index.js | 84 + src/renderer/mixins/ClustersMixin.js | 16 + src/renderer/router/index.js | 99 + src/renderer/router/routeguard/index.js | 26 + src/renderer/store/index.js | 89 + src/renderer/store/modules/clusters.ts | 266 + src/renderer/store/modules/helm-repos.ts | 54 + src/renderer/store/modules/kube-contexts.js | 46 + src/renderer/store/modules/workspaces.ts | 49 + static/RELEASE_NOTES.md | 202 + static/splash.html | 142 + tsconfig.json | 21 + types/electron-promise-ipc/index.d.ts | 1 + types/fix-path/index.d.ts | 1 + types/http-proxy/index.d.ts | 236 + types/mac-ca/index.d.ts | 1 + types/ssl-root-cas/index.d.ts | 1 + types/win-ca/api/index.d.ts | 1 + types/win-ca/index.d.ts | 1 + yarn.lock | 11970 ++++++++++++++++ 797 files changed, 73714 insertions(+), 215 deletions(-) create mode 100644 .azure-pipelines.yml create mode 100644 .eslintrc.js create mode 100644 .gitignore create mode 100644 .yarnrc create mode 100644 Makefile create mode 100644 build/download_kubectl.ts create mode 100644 build/entitlements.mac.plist create mode 100755 build/icon.ico create mode 100644 build/icon.png create mode 100644 build/icons/512x512.png create mode 100644 build/notarize.js create mode 100644 dashboard/.babelrc create mode 100644 dashboard/.dockerignore create mode 100755 dashboard/.gitignore create mode 100644 dashboard/.linguirc create mode 100644 dashboard/client/api/api-manager.ts create mode 100644 dashboard/client/api/endpoints/__tests__/cron-job.api.test.ts create mode 100644 dashboard/client/api/endpoints/cert-manager.api.ts create mode 100644 dashboard/client/api/endpoints/cluster-role-binding.api.ts create mode 100644 dashboard/client/api/endpoints/cluster-role.api.ts create mode 100644 dashboard/client/api/endpoints/cluster.api.ts create mode 100644 dashboard/client/api/endpoints/component-status.api.ts create mode 100644 dashboard/client/api/endpoints/config.api.ts create mode 100644 dashboard/client/api/endpoints/configmap.api.ts create mode 100644 dashboard/client/api/endpoints/crd.api.ts create mode 100644 dashboard/client/api/endpoints/cron-job.api.ts create mode 100644 dashboard/client/api/endpoints/daemon-set.api.ts create mode 100644 dashboard/client/api/endpoints/deployment.api.ts create mode 100644 dashboard/client/api/endpoints/events.api.ts create mode 100644 dashboard/client/api/endpoints/helm-charts.api.ts create mode 100644 dashboard/client/api/endpoints/helm-releases.api.ts create mode 100644 dashboard/client/api/endpoints/hpa.api.ts create mode 100644 dashboard/client/api/endpoints/index.ts create mode 100644 dashboard/client/api/endpoints/ingress.api.ts create mode 100644 dashboard/client/api/endpoints/job.api.ts create mode 100644 dashboard/client/api/endpoints/kubeconfig.api.ts create mode 100644 dashboard/client/api/endpoints/metrics.api.ts create mode 100644 dashboard/client/api/endpoints/namespaces.api.ts create mode 100644 dashboard/client/api/endpoints/network-policy.api.ts create mode 100644 dashboard/client/api/endpoints/nodes.api.ts create mode 100644 dashboard/client/api/endpoints/persistent-volume-claims.api.ts create mode 100644 dashboard/client/api/endpoints/persistent-volume.api.ts create mode 100644 dashboard/client/api/endpoints/pod-metrics.api.ts create mode 100644 dashboard/client/api/endpoints/pods.api.ts create mode 100644 dashboard/client/api/endpoints/podsecuritypolicy.api.ts create mode 100644 dashboard/client/api/endpoints/replica-set.api.ts create mode 100644 dashboard/client/api/endpoints/resource-applier.api.ts create mode 100644 dashboard/client/api/endpoints/resource-quota.api.ts create mode 100644 dashboard/client/api/endpoints/role-binding.api.ts create mode 100644 dashboard/client/api/endpoints/role.api.ts create mode 100644 dashboard/client/api/endpoints/secret.api.ts create mode 100644 dashboard/client/api/endpoints/selfsubjectrulesreviews.api.ts create mode 100644 dashboard/client/api/endpoints/service-accounts.api.ts create mode 100644 dashboard/client/api/endpoints/service.api.ts create mode 100644 dashboard/client/api/endpoints/stateful-set.api.ts create mode 100644 dashboard/client/api/endpoints/storage-class.api.ts create mode 100644 dashboard/client/api/index.ts create mode 100644 dashboard/client/api/json-api.ts create mode 100644 dashboard/client/api/kube-api.ts create mode 100644 dashboard/client/api/kube-json-api.ts create mode 100644 dashboard/client/api/kube-object.ts create mode 100644 dashboard/client/api/kube-watch-api.ts create mode 100644 dashboard/client/api/terminal-api.ts create mode 100644 dashboard/client/api/websocket-api.ts create mode 100644 dashboard/client/api/workload-kube-object.ts create mode 100644 dashboard/client/browser-check.tsx create mode 100644 dashboard/client/components/+404/index.ts create mode 100644 dashboard/client/components/+404/not-found.tsx create mode 100644 dashboard/client/components/+apps-helm-charts/helm-chart-details.scss create mode 100644 dashboard/client/components/+apps-helm-charts/helm-chart-details.tsx create mode 100644 dashboard/client/components/+apps-helm-charts/helm-chart.store.ts create mode 100644 dashboard/client/components/+apps-helm-charts/helm-charts.route.ts create mode 100644 dashboard/client/components/+apps-helm-charts/helm-charts.scss create mode 100644 dashboard/client/components/+apps-helm-charts/helm-charts.tsx create mode 100644 dashboard/client/components/+apps-helm-charts/helm-placeholder.svg create mode 100644 dashboard/client/components/+apps-helm-charts/index.ts create mode 100644 dashboard/client/components/+apps-releases/index.ts create mode 100644 dashboard/client/components/+apps-releases/release-details.scss create mode 100644 dashboard/client/components/+apps-releases/release-details.tsx create mode 100644 dashboard/client/components/+apps-releases/release-menu.tsx create mode 100644 dashboard/client/components/+apps-releases/release-rollback-dialog.scss create mode 100644 dashboard/client/components/+apps-releases/release-rollback-dialog.tsx create mode 100644 dashboard/client/components/+apps-releases/release.mixins.scss create mode 100644 dashboard/client/components/+apps-releases/release.route.ts create mode 100644 dashboard/client/components/+apps-releases/release.store.ts create mode 100644 dashboard/client/components/+apps-releases/releases.scss create mode 100644 dashboard/client/components/+apps-releases/releases.tsx create mode 100644 dashboard/client/components/+apps/apps.route.ts create mode 100644 dashboard/client/components/+apps/apps.tsx create mode 100644 dashboard/client/components/+apps/index.ts create mode 100644 dashboard/client/components/+cluster/cluster-issues.scss create mode 100644 dashboard/client/components/+cluster/cluster-issues.tsx create mode 100644 dashboard/client/components/+cluster/cluster-metric-switchers.scss create mode 100644 dashboard/client/components/+cluster/cluster-metric-switchers.tsx create mode 100644 dashboard/client/components/+cluster/cluster-metrics.scss create mode 100644 dashboard/client/components/+cluster/cluster-metrics.tsx create mode 100644 dashboard/client/components/+cluster/cluster-no-metrics.tsx create mode 100644 dashboard/client/components/+cluster/cluster-pie-charts.scss create mode 100644 dashboard/client/components/+cluster/cluster-pie-charts.tsx create mode 100644 dashboard/client/components/+cluster/cluster.routes.ts create mode 100644 dashboard/client/components/+cluster/cluster.scss create mode 100644 dashboard/client/components/+cluster/cluster.store.ts create mode 100644 dashboard/client/components/+cluster/cluster.tsx create mode 100644 dashboard/client/components/+cluster/index.ts create mode 100644 dashboard/client/components/+config-autoscalers/autoscaler.mixins.scss create mode 100644 dashboard/client/components/+config-autoscalers/hpa-details.scss create mode 100644 dashboard/client/components/+config-autoscalers/hpa-details.tsx create mode 100644 dashboard/client/components/+config-autoscalers/hpa.route.ts create mode 100644 dashboard/client/components/+config-autoscalers/hpa.scss create mode 100644 dashboard/client/components/+config-autoscalers/hpa.store.ts create mode 100644 dashboard/client/components/+config-autoscalers/hpa.tsx create mode 100644 dashboard/client/components/+config-autoscalers/index.ts create mode 100644 dashboard/client/components/+config-maps/config-map-details.scss create mode 100644 dashboard/client/components/+config-maps/config-map-details.tsx create mode 100644 dashboard/client/components/+config-maps/config-maps.route.ts create mode 100644 dashboard/client/components/+config-maps/config-maps.scss create mode 100644 dashboard/client/components/+config-maps/config-maps.store.ts create mode 100644 dashboard/client/components/+config-maps/config-maps.tsx create mode 100644 dashboard/client/components/+config-maps/index.ts create mode 100644 dashboard/client/components/+config-resource-quotas/add-quota-dialog.scss create mode 100644 dashboard/client/components/+config-resource-quotas/add-quota-dialog.tsx create mode 100644 dashboard/client/components/+config-resource-quotas/index.ts create mode 100644 dashboard/client/components/+config-resource-quotas/resource-quota-details.scss create mode 100644 dashboard/client/components/+config-resource-quotas/resource-quota-details.tsx create mode 100644 dashboard/client/components/+config-resource-quotas/resource-quotas.route.ts create mode 100644 dashboard/client/components/+config-resource-quotas/resource-quotas.scss create mode 100644 dashboard/client/components/+config-resource-quotas/resource-quotas.store.ts create mode 100644 dashboard/client/components/+config-resource-quotas/resource-quotas.tsx create mode 100644 dashboard/client/components/+config-secrets/add-secret-dialog.scss create mode 100644 dashboard/client/components/+config-secrets/add-secret-dialog.tsx create mode 100644 dashboard/client/components/+config-secrets/index.ts create mode 100644 dashboard/client/components/+config-secrets/secret-details.scss create mode 100644 dashboard/client/components/+config-secrets/secret-details.tsx create mode 100644 dashboard/client/components/+config-secrets/secrets.route.ts create mode 100644 dashboard/client/components/+config-secrets/secrets.scss create mode 100644 dashboard/client/components/+config-secrets/secrets.store.ts create mode 100644 dashboard/client/components/+config-secrets/secrets.tsx create mode 100644 dashboard/client/components/+config/config.route.ts create mode 100644 dashboard/client/components/+config/config.tsx create mode 100644 dashboard/client/components/+config/index.ts create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/cert-manager.mixins.scss create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/certificate-details.scss create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/certificates.scss create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/certificates.tsx create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/index.ts create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/issuer-details.scss create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/issuers.scss create mode 100644 dashboard/client/components/+custom-resources/certmanager.k8s.io/issuers.tsx create mode 100644 dashboard/client/components/+custom-resources/crd-details.scss create mode 100644 dashboard/client/components/+custom-resources/crd-details.tsx create mode 100644 dashboard/client/components/+custom-resources/crd-list.scss create mode 100644 dashboard/client/components/+custom-resources/crd-list.tsx create mode 100644 dashboard/client/components/+custom-resources/crd-resource-details.scss create mode 100644 dashboard/client/components/+custom-resources/crd-resource-details.tsx create mode 100644 dashboard/client/components/+custom-resources/crd-resource.store.ts create mode 100644 dashboard/client/components/+custom-resources/crd-resources.scss create mode 100644 dashboard/client/components/+custom-resources/crd-resources.tsx create mode 100644 dashboard/client/components/+custom-resources/crd.mixins.scss create mode 100644 dashboard/client/components/+custom-resources/crd.route.ts create mode 100644 dashboard/client/components/+custom-resources/crd.store.ts create mode 100644 dashboard/client/components/+custom-resources/custom-resources.tsx create mode 100644 dashboard/client/components/+custom-resources/index.ts create mode 100644 dashboard/client/components/+events/event-details.scss create mode 100644 dashboard/client/components/+events/event-details.tsx create mode 100644 dashboard/client/components/+events/event.store.ts create mode 100644 dashboard/client/components/+events/events.route.ts create mode 100644 dashboard/client/components/+events/events.scss create mode 100644 dashboard/client/components/+events/events.tsx create mode 100644 dashboard/client/components/+events/index.ts create mode 100644 dashboard/client/components/+events/kube-event-details.scss create mode 100644 dashboard/client/components/+events/kube-event-details.tsx create mode 100644 dashboard/client/components/+events/kube-event-icon.scss create mode 100644 dashboard/client/components/+events/kube-event-icon.tsx create mode 100644 dashboard/client/components/+namespaces/add-namespace-dialog.scss create mode 100644 dashboard/client/components/+namespaces/add-namespace-dialog.tsx create mode 100644 dashboard/client/components/+namespaces/index.ts create mode 100644 dashboard/client/components/+namespaces/namespace-details.scss create mode 100644 dashboard/client/components/+namespaces/namespace-details.tsx create mode 100644 dashboard/client/components/+namespaces/namespace-select.scss create mode 100644 dashboard/client/components/+namespaces/namespace-select.tsx create mode 100644 dashboard/client/components/+namespaces/namespace.store.ts create mode 100644 dashboard/client/components/+namespaces/namespaces-mixins.scss create mode 100644 dashboard/client/components/+namespaces/namespaces.route.ts create mode 100644 dashboard/client/components/+namespaces/namespaces.scss create mode 100644 dashboard/client/components/+namespaces/namespaces.tsx create mode 100644 dashboard/client/components/+network-ingresses/index.ts create mode 100644 dashboard/client/components/+network-ingresses/ingress-charts.tsx create mode 100644 dashboard/client/components/+network-ingresses/ingress-details.scss create mode 100644 dashboard/client/components/+network-ingresses/ingress-details.tsx create mode 100644 dashboard/client/components/+network-ingresses/ingress.store.ts create mode 100644 dashboard/client/components/+network-ingresses/ingresses.route.ts create mode 100644 dashboard/client/components/+network-ingresses/ingresses.scss create mode 100644 dashboard/client/components/+network-ingresses/ingresses.tsx create mode 100644 dashboard/client/components/+network-policies/index.ts create mode 100644 dashboard/client/components/+network-policies/network-policies.route.ts create mode 100644 dashboard/client/components/+network-policies/network-policies.scss create mode 100644 dashboard/client/components/+network-policies/network-policies.tsx create mode 100644 dashboard/client/components/+network-policies/network-policy-details.scss create mode 100644 dashboard/client/components/+network-policies/network-policy-details.tsx create mode 100644 dashboard/client/components/+network-policies/network-policy.store.ts create mode 100644 dashboard/client/components/+network-services/index.ts create mode 100644 dashboard/client/components/+network-services/service-details.scss create mode 100644 dashboard/client/components/+network-services/service-details.tsx create mode 100644 dashboard/client/components/+network-services/services.route.ts create mode 100644 dashboard/client/components/+network-services/services.scss create mode 100644 dashboard/client/components/+network-services/services.store.ts create mode 100644 dashboard/client/components/+network-services/services.tsx create mode 100644 dashboard/client/components/+network/index.ts create mode 100644 dashboard/client/components/+network/network-mixins.scss create mode 100644 dashboard/client/components/+network/network.route.ts create mode 100644 dashboard/client/components/+network/network.scss create mode 100644 dashboard/client/components/+network/network.tsx create mode 100644 dashboard/client/components/+nodes/index.ts create mode 100644 dashboard/client/components/+nodes/node-charts.tsx create mode 100644 dashboard/client/components/+nodes/node-details.scss create mode 100644 dashboard/client/components/+nodes/node-details.tsx create mode 100644 dashboard/client/components/+nodes/node-menu.tsx create mode 100644 dashboard/client/components/+nodes/nodes-mixins.scss create mode 100644 dashboard/client/components/+nodes/nodes.route.ts create mode 100644 dashboard/client/components/+nodes/nodes.scss create mode 100644 dashboard/client/components/+nodes/nodes.store.ts create mode 100644 dashboard/client/components/+nodes/nodes.tsx create mode 100644 dashboard/client/components/+pod-security-policies/index.ts create mode 100644 dashboard/client/components/+pod-security-policies/pod-security-policies.route.ts create mode 100644 dashboard/client/components/+pod-security-policies/pod-security-policies.scss create mode 100644 dashboard/client/components/+pod-security-policies/pod-security-policies.store.ts create mode 100644 dashboard/client/components/+pod-security-policies/pod-security-policies.tsx create mode 100644 dashboard/client/components/+pod-security-policies/pod-security-policy-details.scss create mode 100644 dashboard/client/components/+pod-security-policies/pod-security-policy-details.tsx create mode 100644 dashboard/client/components/+storage-classes/index.ts create mode 100644 dashboard/client/components/+storage-classes/storage-class-details.scss create mode 100644 dashboard/client/components/+storage-classes/storage-class-details.tsx create mode 100644 dashboard/client/components/+storage-classes/storage-class.store.ts create mode 100644 dashboard/client/components/+storage-classes/storage-classes.route.ts create mode 100644 dashboard/client/components/+storage-classes/storage-classes.scss create mode 100644 dashboard/client/components/+storage-classes/storage-classes.tsx create mode 100644 dashboard/client/components/+storage-volume-claims/index.ts create mode 100644 dashboard/client/components/+storage-volume-claims/volume-claim-details.scss create mode 100644 dashboard/client/components/+storage-volume-claims/volume-claim-details.tsx create mode 100644 dashboard/client/components/+storage-volume-claims/volume-claim-disk-chart.tsx create mode 100644 dashboard/client/components/+storage-volume-claims/volume-claim.store.ts create mode 100644 dashboard/client/components/+storage-volume-claims/volume-claims.route.ts create mode 100644 dashboard/client/components/+storage-volume-claims/volume-claims.scss create mode 100644 dashboard/client/components/+storage-volume-claims/volume-claims.tsx create mode 100644 dashboard/client/components/+storage-volumes/index.ts create mode 100644 dashboard/client/components/+storage-volumes/volume-details.scss create mode 100644 dashboard/client/components/+storage-volumes/volume-details.tsx create mode 100644 dashboard/client/components/+storage-volumes/volumes.route.ts create mode 100644 dashboard/client/components/+storage-volumes/volumes.scss create mode 100644 dashboard/client/components/+storage-volumes/volumes.store.ts create mode 100644 dashboard/client/components/+storage-volumes/volumes.tsx create mode 100644 dashboard/client/components/+storage/index.ts create mode 100644 dashboard/client/components/+storage/storage-mixins.scss create mode 100644 dashboard/client/components/+storage/storage.route.ts create mode 100644 dashboard/client/components/+storage/storage.scss create mode 100644 dashboard/client/components/+storage/storage.tsx create mode 100644 dashboard/client/components/+user-management-roles-bindings/add-role-binding-dialog.scss create mode 100644 dashboard/client/components/+user-management-roles-bindings/add-role-binding-dialog.tsx create mode 100644 dashboard/client/components/+user-management-roles-bindings/index.ts create mode 100644 dashboard/client/components/+user-management-roles-bindings/role-binding-details.scss create mode 100644 dashboard/client/components/+user-management-roles-bindings/role-binding-details.tsx create mode 100644 dashboard/client/components/+user-management-roles-bindings/role-bindings.scss create mode 100644 dashboard/client/components/+user-management-roles-bindings/role-bindings.store.ts create mode 100644 dashboard/client/components/+user-management-roles-bindings/role-bindings.tsx create mode 100644 dashboard/client/components/+user-management-roles/add-role-dialog.scss create mode 100644 dashboard/client/components/+user-management-roles/add-role-dialog.tsx create mode 100644 dashboard/client/components/+user-management-roles/index.ts create mode 100644 dashboard/client/components/+user-management-roles/role-details.scss create mode 100644 dashboard/client/components/+user-management-roles/role-details.tsx create mode 100644 dashboard/client/components/+user-management-roles/roles.scss create mode 100644 dashboard/client/components/+user-management-roles/roles.store.ts create mode 100644 dashboard/client/components/+user-management-roles/roles.tsx create mode 100644 dashboard/client/components/+user-management-service-accounts/create-service-account-dialog.scss create mode 100644 dashboard/client/components/+user-management-service-accounts/create-service-account-dialog.tsx create mode 100644 dashboard/client/components/+user-management-service-accounts/index.ts create mode 100644 dashboard/client/components/+user-management-service-accounts/service-accounts-details.scss create mode 100644 dashboard/client/components/+user-management-service-accounts/service-accounts-details.tsx create mode 100644 dashboard/client/components/+user-management-service-accounts/service-accounts-secret.scss create mode 100644 dashboard/client/components/+user-management-service-accounts/service-accounts-secret.tsx create mode 100644 dashboard/client/components/+user-management-service-accounts/service-accounts.scss create mode 100644 dashboard/client/components/+user-management-service-accounts/service-accounts.store.ts create mode 100644 dashboard/client/components/+user-management-service-accounts/service-accounts.tsx create mode 100644 dashboard/client/components/+user-management/index.ts create mode 100644 dashboard/client/components/+user-management/user-management.routes.ts create mode 100644 dashboard/client/components/+user-management/user-management.scss create mode 100644 dashboard/client/components/+user-management/user-management.tsx create mode 100644 dashboard/client/components/+workloads-cronjobs/cronjob-details.scss create mode 100644 dashboard/client/components/+workloads-cronjobs/cronjob-details.tsx create mode 100644 dashboard/client/components/+workloads-cronjobs/cronjob.store.ts create mode 100644 dashboard/client/components/+workloads-cronjobs/cronjobs.scss create mode 100644 dashboard/client/components/+workloads-cronjobs/cronjobs.tsx create mode 100644 dashboard/client/components/+workloads-cronjobs/index.ts create mode 100644 dashboard/client/components/+workloads-daemonsets/daemonset-details.scss create mode 100644 dashboard/client/components/+workloads-daemonsets/daemonset-details.tsx create mode 100644 dashboard/client/components/+workloads-daemonsets/daemonsets.scss create mode 100644 dashboard/client/components/+workloads-daemonsets/daemonsets.store.ts create mode 100644 dashboard/client/components/+workloads-daemonsets/daemonsets.tsx create mode 100644 dashboard/client/components/+workloads-daemonsets/index.ts create mode 100644 dashboard/client/components/+workloads-deployments/deployment-details.scss create mode 100644 dashboard/client/components/+workloads-deployments/deployment-details.tsx create mode 100644 dashboard/client/components/+workloads-deployments/deployment-scale-dialog.scss create mode 100644 dashboard/client/components/+workloads-deployments/deployment-scale-dialog.tsx create mode 100644 dashboard/client/components/+workloads-deployments/deployments.scss create mode 100644 dashboard/client/components/+workloads-deployments/deployments.store.ts create mode 100644 dashboard/client/components/+workloads-deployments/deployments.tsx create mode 100644 dashboard/client/components/+workloads-deployments/index.ts create mode 100644 dashboard/client/components/+workloads-jobs/index.ts create mode 100644 dashboard/client/components/+workloads-jobs/job-details.scss create mode 100644 dashboard/client/components/+workloads-jobs/job-details.tsx create mode 100644 dashboard/client/components/+workloads-jobs/job.store.ts create mode 100644 dashboard/client/components/+workloads-jobs/jobs.scss create mode 100644 dashboard/client/components/+workloads-jobs/jobs.tsx create mode 100644 dashboard/client/components/+workloads-overview/overview-statuses.scss create mode 100644 dashboard/client/components/+workloads-overview/overview-statuses.tsx create mode 100644 dashboard/client/components/+workloads-overview/overview-workload-status.scss create mode 100644 dashboard/client/components/+workloads-overview/overview-workload-status.tsx create mode 100644 dashboard/client/components/+workloads-overview/overview.scss create mode 100644 dashboard/client/components/+workloads-overview/overview.tsx create mode 100644 dashboard/client/components/+workloads-pods/container-charts.tsx create mode 100644 dashboard/client/components/+workloads-pods/index.ts create mode 100644 dashboard/client/components/+workloads-pods/pod-charts.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-container-env.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-container-env.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-details-affinities.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-details-affinities.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-details-container.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-details-container.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-details-list.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-details-list.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-details-secrets.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-details-secrets.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-details-statuses.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-details-statuses.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-details-tolerations.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-details-tolerations.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-details.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-details.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-logs-dialog.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-logs-dialog.tsx create mode 100644 dashboard/client/components/+workloads-pods/pod-menu.scss create mode 100644 dashboard/client/components/+workloads-pods/pod-menu.tsx create mode 100644 dashboard/client/components/+workloads-pods/pods.scss create mode 100644 dashboard/client/components/+workloads-pods/pods.store.ts create mode 100644 dashboard/client/components/+workloads-pods/pods.tsx create mode 100644 dashboard/client/components/+workloads-replicasets/index.ts create mode 100644 dashboard/client/components/+workloads-replicasets/replicaset-details.scss create mode 100644 dashboard/client/components/+workloads-replicasets/replicaset-details.tsx create mode 100644 dashboard/client/components/+workloads-replicasets/replicasets.scss create mode 100644 dashboard/client/components/+workloads-replicasets/replicasets.store.ts create mode 100644 dashboard/client/components/+workloads-replicasets/replicasets.tsx create mode 100644 dashboard/client/components/+workloads-statefulsets/index.ts create mode 100644 dashboard/client/components/+workloads-statefulsets/statefulset-details.scss create mode 100644 dashboard/client/components/+workloads-statefulsets/statefulset-details.tsx create mode 100644 dashboard/client/components/+workloads-statefulsets/statefulset.store.ts create mode 100644 dashboard/client/components/+workloads-statefulsets/statefulsets.scss create mode 100644 dashboard/client/components/+workloads-statefulsets/statefulsets.tsx create mode 100644 dashboard/client/components/+workloads/index.ts create mode 100644 dashboard/client/components/+workloads/workloads-mixins.scss create mode 100644 dashboard/client/components/+workloads/workloads.route.ts create mode 100644 dashboard/client/components/+workloads/workloads.scss create mode 100644 dashboard/client/components/+workloads/workloads.tsx create mode 100644 dashboard/client/components/ace-editor/ace-editor.scss create mode 100644 dashboard/client/components/ace-editor/ace-editor.tsx create mode 100644 dashboard/client/components/ace-editor/index.ts create mode 100644 dashboard/client/components/add-remove-buttons/add-remove-buttons.scss create mode 100644 dashboard/client/components/add-remove-buttons/add-remove-buttons.tsx create mode 100644 dashboard/client/components/add-remove-buttons/index.ts create mode 100644 dashboard/client/components/animate/animate.scss create mode 100644 dashboard/client/components/animate/animate.tsx create mode 100644 dashboard/client/components/animate/index.ts create mode 100644 dashboard/client/components/app-init/app-init.scss create mode 100644 dashboard/client/components/app-init/app-init.tsx create mode 100755 dashboard/client/components/app.scss create mode 100755 dashboard/client/components/app.tsx create mode 100644 dashboard/client/components/badge/badge.scss create mode 100644 dashboard/client/components/badge/badge.tsx create mode 100644 dashboard/client/components/badge/index.ts create mode 100644 dashboard/client/components/button/button.scss create mode 100644 dashboard/client/components/button/button.tsx create mode 100644 dashboard/client/components/button/index.ts create mode 100644 dashboard/client/components/chart/background-block.plugin.ts create mode 100644 dashboard/client/components/chart/bar-chart.tsx create mode 100644 dashboard/client/components/chart/chart.scss create mode 100644 dashboard/client/components/chart/chart.tsx create mode 100644 dashboard/client/components/chart/index.ts create mode 100644 dashboard/client/components/chart/pie-chart.scss create mode 100644 dashboard/client/components/chart/pie-chart.tsx create mode 100644 dashboard/client/components/chart/useRealTimeMetrics.ts create mode 100644 dashboard/client/components/chart/zebra-stripes.plugin.ts create mode 100644 dashboard/client/components/checkbox/checkbox.scss create mode 100644 dashboard/client/components/checkbox/checkbox.tsx create mode 100644 dashboard/client/components/checkbox/index.ts create mode 100644 dashboard/client/components/colors.scss create mode 100644 dashboard/client/components/confirm-dialog/confirm-dialog.scss create mode 100644 dashboard/client/components/confirm-dialog/confirm-dialog.tsx create mode 100644 dashboard/client/components/confirm-dialog/index.ts create mode 100644 dashboard/client/components/dialog/dialog.scss create mode 100644 dashboard/client/components/dialog/dialog.tsx create mode 100644 dashboard/client/components/dialog/index.ts create mode 100644 dashboard/client/components/dialog/logs-dialog.scss create mode 100644 dashboard/client/components/dialog/logs-dialog.tsx create mode 100644 dashboard/client/components/dock/create-resource.scss create mode 100644 dashboard/client/components/dock/create-resource.store.ts create mode 100644 dashboard/client/components/dock/create-resource.tsx create mode 100644 dashboard/client/components/dock/dock-tab.scss create mode 100644 dashboard/client/components/dock/dock-tab.store.ts create mode 100644 dashboard/client/components/dock/dock-tab.tsx create mode 100644 dashboard/client/components/dock/dock.scss create mode 100644 dashboard/client/components/dock/dock.store.ts create mode 100644 dashboard/client/components/dock/dock.tsx create mode 100644 dashboard/client/components/dock/edit-resource.scss create mode 100644 dashboard/client/components/dock/edit-resource.store.ts create mode 100644 dashboard/client/components/dock/edit-resource.tsx create mode 100644 dashboard/client/components/dock/editor-panel.tsx create mode 100644 dashboard/client/components/dock/index.ts create mode 100644 dashboard/client/components/dock/info-panel.scss create mode 100644 dashboard/client/components/dock/info-panel.tsx create mode 100644 dashboard/client/components/dock/install-chart.scss create mode 100644 dashboard/client/components/dock/install-chart.store.ts create mode 100644 dashboard/client/components/dock/install-chart.tsx create mode 100644 dashboard/client/components/dock/terminal-tab.scss create mode 100644 dashboard/client/components/dock/terminal-tab.tsx create mode 100644 dashboard/client/components/dock/terminal-window.scss create mode 100644 dashboard/client/components/dock/terminal-window.tsx create mode 100644 dashboard/client/components/dock/terminal.store.ts create mode 100644 dashboard/client/components/dock/terminal.ts create mode 100644 dashboard/client/components/dock/upgrade-chart.scss create mode 100644 dashboard/client/components/dock/upgrade-chart.store.ts create mode 100644 dashboard/client/components/dock/upgrade-chart.tsx create mode 100644 dashboard/client/components/draggable/draggable.scss create mode 100644 dashboard/client/components/draggable/draggable.tsx create mode 100644 dashboard/client/components/draggable/index.ts create mode 100644 dashboard/client/components/drawer/drawer-item-labels.tsx create mode 100644 dashboard/client/components/drawer/drawer-item.scss create mode 100644 dashboard/client/components/drawer/drawer-item.tsx create mode 100644 dashboard/client/components/drawer/drawer-param-toggler.scss create mode 100644 dashboard/client/components/drawer/drawer-param-toggler.tsx create mode 100644 dashboard/client/components/drawer/drawer-title.scss create mode 100644 dashboard/client/components/drawer/drawer-title.tsx create mode 100644 dashboard/client/components/drawer/drawer.scss create mode 100644 dashboard/client/components/drawer/drawer.tsx create mode 100644 dashboard/client/components/drawer/index.ts create mode 100644 dashboard/client/components/error-boundary/error-boundary.scss create mode 100644 dashboard/client/components/error-boundary/error-boundary.tsx create mode 100644 dashboard/client/components/error-boundary/index.ts create mode 100644 dashboard/client/components/fonts.scss create mode 100644 dashboard/client/components/fonts/MaterialIcons-Regular.woff2 create mode 100644 dashboard/client/components/fonts/roboto-mono-nerd.ttf create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-100.woff2 create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-100italic.woff2 create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-300.woff2 create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-300italic.woff2 create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-500.woff2 create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-500italic.woff2 create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-700.woff2 create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-700italic.woff2 create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-italic.woff2 create mode 100644 dashboard/client/components/fonts/roboto-v20-cyrillic_latin-regular.woff2 create mode 100644 dashboard/client/components/icon/configuration.svg create mode 100644 dashboard/client/components/icon/group.svg create mode 100644 dashboard/client/components/icon/icon.scss create mode 100644 dashboard/client/components/icon/icon.tsx create mode 100644 dashboard/client/components/icon/index.ts create mode 100644 dashboard/client/components/icon/install.svg create mode 100644 dashboard/client/components/icon/kube.svg create mode 100644 dashboard/client/components/icon/license.svg create mode 100644 dashboard/client/components/icon/logo-full.svg create mode 100644 dashboard/client/components/icon/logo.svg create mode 100644 dashboard/client/components/icon/logout.svg create mode 100644 dashboard/client/components/icon/nodes.svg create mode 100644 dashboard/client/components/icon/push-pin.svg create mode 100644 dashboard/client/components/icon/spinner.svg create mode 100644 dashboard/client/components/icon/ssh.svg create mode 100644 dashboard/client/components/icon/storage.svg create mode 100644 dashboard/client/components/icon/terminal.svg create mode 100644 dashboard/client/components/icon/user.svg create mode 100644 dashboard/client/components/icon/users.svg create mode 100644 dashboard/client/components/icon/wheel.svg create mode 100644 dashboard/client/components/icon/workloads.svg create mode 100644 dashboard/client/components/input/index.ts create mode 100644 dashboard/client/components/input/input.scss create mode 100644 dashboard/client/components/input/input.tsx create mode 100644 dashboard/client/components/input/input.validators.ts create mode 100644 dashboard/client/components/input/search-input.scss create mode 100644 dashboard/client/components/input/search-input.tsx create mode 100644 dashboard/client/components/item-object-list/filter-icon.tsx create mode 100644 dashboard/client/components/item-object-list/index.tsx create mode 100644 dashboard/client/components/item-object-list/item-list-layout.scss create mode 100644 dashboard/client/components/item-object-list/item-list-layout.tsx create mode 100644 dashboard/client/components/item-object-list/page-filters-list.scss create mode 100644 dashboard/client/components/item-object-list/page-filters-list.tsx create mode 100644 dashboard/client/components/item-object-list/page-filters-select.tsx create mode 100644 dashboard/client/components/item-object-list/page-filters.store.ts create mode 100644 dashboard/client/components/items-list/index.ts create mode 100644 dashboard/client/components/items-list/items-list.scss create mode 100644 dashboard/client/components/items-list/items-list.tsx create mode 100644 dashboard/client/components/kube-object/index.ts create mode 100644 dashboard/client/components/kube-object/kube-object-details.scss create mode 100644 dashboard/client/components/kube-object/kube-object-details.tsx create mode 100644 dashboard/client/components/kube-object/kube-object-list-layout.tsx create mode 100644 dashboard/client/components/kube-object/kube-object-menu.tsx create mode 100644 dashboard/client/components/kube-object/kube-object-meta.tsx create mode 100644 dashboard/client/components/kubeconfig-dialog/index.ts create mode 100644 dashboard/client/components/kubeconfig-dialog/kubeconfig-dialog.scss create mode 100644 dashboard/client/components/kubeconfig-dialog/kubeconfig-dialog.tsx create mode 100755 dashboard/client/components/layout/login-layout.scss create mode 100755 dashboard/client/components/layout/login-layout.tsx create mode 100755 dashboard/client/components/layout/main-layout.scss create mode 100755 dashboard/client/components/layout/main-layout.tsx create mode 100644 dashboard/client/components/layout/sidebar.scss create mode 100644 dashboard/client/components/layout/sidebar.tsx create mode 100755 dashboard/client/components/layout/sub-header.scss create mode 100644 dashboard/client/components/layout/sub-header.tsx create mode 100755 dashboard/client/components/layout/sub-title.scss create mode 100644 dashboard/client/components/layout/sub-title.tsx create mode 100644 dashboard/client/components/line-progress/index.ts create mode 100644 dashboard/client/components/line-progress/line-progress.scss create mode 100644 dashboard/client/components/line-progress/line-progress.tsx create mode 100644 dashboard/client/components/markdown-viewer/index.ts create mode 100644 dashboard/client/components/markdown-viewer/markdown-viewer.scss create mode 100644 dashboard/client/components/markdown-viewer/markdown-viewer.tsx create mode 100755 dashboard/client/components/media.scss create mode 100644 dashboard/client/components/menu/index.ts create mode 100644 dashboard/client/components/menu/menu-actions.scss create mode 100644 dashboard/client/components/menu/menu-actions.tsx create mode 100644 dashboard/client/components/menu/menu-picker.scss create mode 100644 dashboard/client/components/menu/menu-picker.tsx create mode 100644 dashboard/client/components/menu/menu.scss create mode 100644 dashboard/client/components/menu/menu.tsx create mode 100755 dashboard/client/components/mixins.scss create mode 100644 dashboard/client/components/no-items/index.ts create mode 100644 dashboard/client/components/no-items/no-items.scss create mode 100644 dashboard/client/components/no-items/no-items.tsx create mode 100644 dashboard/client/components/notifications/index.ts create mode 100644 dashboard/client/components/notifications/notifications.scss create mode 100644 dashboard/client/components/notifications/notifications.store.ts create mode 100644 dashboard/client/components/notifications/notifications.tsx create mode 100644 dashboard/client/components/radio/index.ts create mode 100644 dashboard/client/components/radio/radio.scss create mode 100644 dashboard/client/components/radio/radio.tsx create mode 100644 dashboard/client/components/resource-metrics/index.ts create mode 100644 dashboard/client/components/resource-metrics/no-metrics.tsx create mode 100644 dashboard/client/components/resource-metrics/resource-metrics-text.tsx create mode 100644 dashboard/client/components/resource-metrics/resource-metrics.scss create mode 100644 dashboard/client/components/resource-metrics/resource-metrics.tsx create mode 100644 dashboard/client/components/select/index.ts create mode 100644 dashboard/client/components/select/select.scss create mode 100644 dashboard/client/components/select/select.tsx create mode 100644 dashboard/client/components/slider/index.ts create mode 100644 dashboard/client/components/slider/slider.scss create mode 100644 dashboard/client/components/slider/slider.tsx create mode 100644 dashboard/client/components/spinner/cube-spinner.scss create mode 100644 dashboard/client/components/spinner/cube-spinner.tsx create mode 100644 dashboard/client/components/spinner/index.ts create mode 100644 dashboard/client/components/spinner/spinner.scss create mode 100644 dashboard/client/components/spinner/spinner.tsx create mode 100644 dashboard/client/components/status-brick/index.ts create mode 100644 dashboard/client/components/status-brick/status-brick.scss create mode 100644 dashboard/client/components/status-brick/status-brick.tsx create mode 100644 dashboard/client/components/stepper/index.ts create mode 100644 dashboard/client/components/stepper/stepper.scss create mode 100644 dashboard/client/components/stepper/stepper.tsx create mode 100644 dashboard/client/components/table/index.ts create mode 100644 dashboard/client/components/table/table-cell.scss create mode 100644 dashboard/client/components/table/table-cell.tsx create mode 100644 dashboard/client/components/table/table-head.scss create mode 100644 dashboard/client/components/table/table-head.tsx create mode 100644 dashboard/client/components/table/table-row.scss create mode 100644 dashboard/client/components/table/table-row.tsx create mode 100644 dashboard/client/components/table/table.mixins.scss create mode 100644 dashboard/client/components/table/table.scss create mode 100644 dashboard/client/components/table/table.tsx create mode 100644 dashboard/client/components/tabs/index.ts create mode 100644 dashboard/client/components/tabs/tabs.scss create mode 100644 dashboard/client/components/tabs/tabs.tsx create mode 100644 dashboard/client/components/tooltip/index.ts create mode 100644 dashboard/client/components/tooltip/tooltip.scss create mode 100644 dashboard/client/components/tooltip/tooltip.tsx create mode 100644 dashboard/client/components/tooltip/withTooltip.tsx create mode 100755 dashboard/client/components/vars.scss create mode 100644 dashboard/client/components/virtual-list/index.ts create mode 100644 dashboard/client/components/virtual-list/virtual-list.scss create mode 100644 dashboard/client/components/virtual-list/virtual-list.tsx create mode 100644 dashboard/client/components/wizard/index.ts create mode 100755 dashboard/client/components/wizard/wizard.scss create mode 100755 dashboard/client/components/wizard/wizard.tsx create mode 100755 dashboard/client/config.store.ts create mode 100644 dashboard/client/favicon/android-chrome-512x512.png create mode 100644 dashboard/client/favicon/apple-touch-icon.png create mode 100644 dashboard/client/favicon/favicon-16x16.png create mode 100644 dashboard/client/favicon/favicon-32x32.png create mode 100644 dashboard/client/favicon/safari-pinned-tab.svg create mode 100644 dashboard/client/hooks/index.ts create mode 100644 dashboard/client/hooks/useInterval.ts create mode 100644 dashboard/client/hooks/useOnUnmount.ts create mode 100644 dashboard/client/hooks/useStorage.ts create mode 100644 dashboard/client/i18n.ts create mode 100644 dashboard/client/item.store.ts create mode 100644 dashboard/client/kube-object.store.ts create mode 100644 dashboard/client/navigation.ts create mode 100644 dashboard/client/theme.store.ts create mode 100644 dashboard/client/themes/kontena-dark.json create mode 100644 dashboard/client/themes/kontena-light.json create mode 100644 dashboard/client/themes/theme-vars.scss create mode 100755 dashboard/client/tsconfig.json create mode 100644 dashboard/client/utils/__tests__/convertCpu.test.ts create mode 100644 dashboard/client/utils/__tests__/convertMemory.test.ts create mode 100644 dashboard/client/utils/autobind.ts create mode 100755 dashboard/client/utils/base64.ts create mode 100644 dashboard/client/utils/camelCase.ts create mode 100644 dashboard/client/utils/cancelableFetch.ts create mode 100644 dashboard/client/utils/convertCpu.ts create mode 100644 dashboard/client/utils/convertMemory.ts create mode 100644 dashboard/client/utils/copyToClipboard.ts create mode 100755 dashboard/client/utils/createStorage.ts create mode 100755 dashboard/client/utils/cssNames.ts create mode 100755 dashboard/client/utils/cssVar.ts create mode 100755 dashboard/client/utils/debouncePromise.ts create mode 100644 dashboard/client/utils/downloadFile.ts create mode 100644 dashboard/client/utils/eventEmitter.ts create mode 100644 dashboard/client/utils/formatDuration.ts create mode 100755 dashboard/client/utils/index.ts create mode 100644 dashboard/client/utils/interval.ts create mode 100755 dashboard/client/utils/isReactNode.ts create mode 100644 dashboard/client/utils/prevDefault.ts create mode 100755 dashboard/index.html create mode 100644 dashboard/locales/en/messages.po create mode 100644 dashboard/locales/ru/messages.po create mode 100644 dashboard/package.json create mode 100644 dashboard/server/api/get-cert-auth-data.ts create mode 100644 dashboard/server/api/get-cluster-info.ts create mode 100644 dashboard/server/api/get-namespaces.ts create mode 100644 dashboard/server/api/get-service-account-token.ts create mode 100644 dashboard/server/api/is-cluster-admin.ts create mode 100644 dashboard/server/api/kube-request.ts create mode 100644 dashboard/server/api/review-resource-access.ts create mode 100644 dashboard/server/api/review-token.ts create mode 100644 dashboard/server/app.ts create mode 100644 dashboard/server/common/cluster.ts create mode 100644 dashboard/server/common/config.ts create mode 100644 dashboard/server/common/kubewatch.ts create mode 100644 dashboard/server/common/license.ts create mode 100644 dashboard/server/common/metrics.ts create mode 100644 dashboard/server/config.ts create mode 100644 dashboard/server/middlewares/index.ts create mode 100644 dashboard/server/middlewares/kube-proxy.ts create mode 100644 dashboard/server/middlewares/terminal-proxy.ts create mode 100644 dashboard/server/middlewares/use-header-token.ts create mode 100644 dashboard/server/routes/config-route.ts create mode 100644 dashboard/server/routes/index.ts create mode 100644 dashboard/server/routes/kubeconfig-route.ts create mode 100644 dashboard/server/routes/kubewatch-route.ts create mode 100644 dashboard/server/routes/metrics-route.ts create mode 100644 dashboard/server/routes/ready-state-route.ts create mode 100755 dashboard/server/tsconfig.json create mode 100644 dashboard/server/user-session.ts create mode 100644 dashboard/server/utils/kube-config.dev.ts create mode 100644 dashboard/server/utils/logger.ts create mode 100644 dashboard/server/utils/parse-jwt.ts create mode 100644 dashboard/test/jest.config.js create mode 100644 dashboard/test/setup-tests.js create mode 100644 dashboard/test/tsconfig.json create mode 100644 dashboard/tools/port-forward.ts create mode 100755 dashboard/webpack.config.ts create mode 100644 dashboard/yarn.lock create mode 100644 package.json create mode 100644 patches/@kubernetes+client-node+0.11.0.patch create mode 100644 spec/src/common/cluster-store_spec.ts create mode 100644 spec/src/common/user-store_spec.ts create mode 100644 spec/src/main/port_spec.ts create mode 100644 src/common/.gitkeep create mode 100644 src/common/app-utils.ts create mode 100644 src/common/cluster-store.ts create mode 100644 src/common/migrations/cluster-store/2.0.0-beta.2.ts create mode 100644 src/common/migrations/cluster-store/2.4.1.ts create mode 100644 src/common/migrations/cluster-store/2.6.0-beta.2.ts create mode 100644 src/common/migrations/cluster-store/2.6.0-beta.3.ts create mode 100644 src/common/migrations/cluster-store/2.7.0-beta.0.ts create mode 100644 src/common/migrations/cluster-store/2.7.0-beta.1.ts create mode 100644 src/common/migrations/user-store/2.1.0-beta.4.ts create mode 100644 src/common/request.ts create mode 100644 src/common/system-ca.ts create mode 100644 src/common/tracker.ts create mode 100644 src/common/user-store.ts create mode 100644 src/common/workspace-store.ts create mode 100644 src/features/metrics.ts rename {app/manifests => src/features}/metrics/01-namespace.yml (100%) rename app/manifests/metrics/02-configmap.yml => src/features/metrics/02-configmap.yml.hb (94%) rename {app/manifests => src/features}/metrics/03-service.yml (100%) rename app/manifests/metrics/03-statefulset.yml => src/features/metrics/03-statefulset.yml.hb (59%) rename {app/manifests => src/features}/metrics/04-rules.yml (100%) rename {app/manifests => src/features}/metrics/05-clusterrole.yml (100%) rename {app/manifests => src/features}/metrics/05-service-account.yml (100%) rename {app/manifests => src/features}/metrics/06-clusterrole-binding.yml (100%) rename app/manifests/metrics/10-node-exporter-ds.yml => src/features/metrics/10-node-exporter-ds.yml.hb (79%) rename {app/manifests => src/features}/metrics/11-node-exporter-svc.yml (100%) rename {app/manifests => src/features}/metrics/12-kube-state-metrics-clusterrole.yml (100%) rename {app/manifests => src/features}/metrics/12.kube-state-metrics-sa.yml (100%) rename {app/manifests => src/features}/metrics/13-kube-state-metrics-clusterrole-binding.yml (100%) rename app/manifests/metrics/14-kube-state-metrics-deployment.yml => src/features/metrics/14-kube-state-metrics-deployment.yml.hb (62%) rename {app/manifests => src/features}/metrics/14-kube-state-metrics-svc.yml (100%) create mode 100644 src/features/user-mode.ts create mode 100644 src/features/user-mode/01-clusterrole.yml create mode 100644 src/features/user-mode/02-clusterrrolebinding.yml create mode 100644 src/main/app-updater.ts create mode 100644 src/main/cluster-manager.ts create mode 100644 src/main/cluster.ts create mode 100644 src/main/context-handler.ts create mode 100644 src/main/feature-manager.ts create mode 100644 src/main/feature.ts create mode 100644 src/main/file-helpers.ts create mode 100644 src/main/helm-api.ts create mode 100644 src/main/helm-chart-manager.ts create mode 100644 src/main/helm-cli.ts create mode 100644 src/main/helm-release-manager.ts create mode 100644 src/main/helm-repo-manager.ts create mode 100644 src/main/helm-service.ts create mode 100644 src/main/index.ts create mode 100644 src/main/k8s.ts create mode 100644 src/main/kube-auth-proxy.ts create mode 100644 src/main/kubeconfig-manager.ts create mode 100644 src/main/kubectl.ts create mode 100644 src/main/lens-api.ts create mode 100644 src/main/lens-binary.ts create mode 100644 src/main/lens-server.ts create mode 100644 src/main/logger.ts create mode 100644 src/main/menu.ts create mode 100644 src/main/node-shell-session.ts create mode 100644 src/main/port.ts create mode 100644 src/main/promise-exec.ts create mode 100644 src/main/proxy-env.ts create mode 100644 src/main/proxy.ts create mode 100644 src/main/resource-applier-api.ts create mode 100644 src/main/resource-applier.ts create mode 100644 src/main/router.ts create mode 100644 src/main/shell-session.ts create mode 100644 src/main/shell-sync.ts create mode 100644 src/main/tracker.ts create mode 100644 src/main/webcontents.ts create mode 100644 src/main/window-manager.ts create mode 100644 src/renderer/App.vue create mode 100644 src/renderer/assets/css/app.scss create mode 100644 src/renderer/assets/css/custom.scss create mode 100644 src/renderer/assets/css/fonts.scss create mode 100644 src/renderer/assets/img/crane.svg create mode 100644 src/renderer/assets/img/lens-logo.svg create mode 100644 src/renderer/assets/img/planet.png create mode 100644 src/renderer/components/AddClusterPage.vue create mode 100644 src/renderer/components/AddWorkspacePage.vue create mode 100644 src/renderer/components/BottomBar/BottomBar.vue create mode 100644 src/renderer/components/ClusterPage.vue create mode 100644 src/renderer/components/ClusterSettings/Features/Components/Metrics.vue create mode 100644 src/renderer/components/ClusterSettings/Features/Components/UserMode.vue create mode 100644 src/renderer/components/ClusterSettings/Features/Components/index.js create mode 100644 src/renderer/components/ClusterSettings/Features/index.vue create mode 100644 src/renderer/components/ClusterSettings/General/ClusterIcon.vue create mode 100644 src/renderer/components/ClusterSettings/General/ClusterName.vue create mode 100644 src/renderer/components/ClusterSettings/General/ClusterWorkspace.vue create mode 100644 src/renderer/components/ClusterSettings/General/index.vue create mode 100644 src/renderer/components/ClusterSettings/Overview/index.vue create mode 100644 src/renderer/components/ClusterSettings/Preferences/index.vue create mode 100644 src/renderer/components/ClusterSettings/index.vue create mode 100644 src/renderer/components/CubeSpinner.vue create mode 100644 src/renderer/components/EditWorkspacePage.vue create mode 100644 src/renderer/components/LandingPage.vue create mode 100644 src/renderer/components/MainMenu/AddClusterMenuItem.vue create mode 100644 src/renderer/components/MainMenu/ClusterMenuItem.vue create mode 100644 src/renderer/components/MainMenu/MainMenu.vue create mode 100644 src/renderer/components/PreferencesPage.vue create mode 100644 src/renderer/components/WhatsNewPage.vue create mode 100644 src/renderer/components/WorkspacesPage.vue create mode 100644 src/renderer/components/common/ClosePageButton.vue create mode 100644 src/renderer/components/hashicon/hashicon.vue create mode 100644 src/renderer/index.js create mode 100644 src/renderer/mixins/ClustersMixin.js create mode 100644 src/renderer/router/index.js create mode 100644 src/renderer/router/routeguard/index.js create mode 100644 src/renderer/store/index.js create mode 100644 src/renderer/store/modules/clusters.ts create mode 100644 src/renderer/store/modules/helm-repos.ts create mode 100644 src/renderer/store/modules/kube-contexts.js create mode 100644 src/renderer/store/modules/workspaces.ts create mode 100644 static/RELEASE_NOTES.md create mode 100644 static/splash.html create mode 100644 tsconfig.json create mode 100644 types/electron-promise-ipc/index.d.ts create mode 100644 types/fix-path/index.d.ts create mode 100644 types/http-proxy/index.d.ts create mode 100644 types/mac-ca/index.d.ts create mode 100644 types/ssl-root-cas/index.d.ts create mode 100644 types/win-ca/api/index.d.ts create mode 100644 types/win-ca/index.d.ts create mode 100644 yarn.lock diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml new file mode 100644 index 0000000000..622ea2ba51 --- /dev/null +++ b/.azure-pipelines.yml @@ -0,0 +1,141 @@ +variables: + YARN_CACHE_FOLDER: $(Pipeline.Workspace)/.yarn + AZURE_CACHE_FOLDER: $(Pipeline.Workspace)/.azure-cache +pr: none +trigger: + branches: + include: + - '*' + tags: + include: + - "*" +jobs: + - job: Windows + condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" + pool: + vmImage: windows-2019 + strategy: + matrix: + node_12.x: + node_version: 12.x + steps: + - powershell: | + $CI_BUILD_TAG = git describe --tags + Write-Output ("##vso[task.setvariable variable=CI_BUILD_TAG;]$CI_BUILD_TAG") + displayName: 'Set the tag name as an environment variable' + condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" + - task: NodeTool@0 + inputs: + versionSpec: $(node_version) + displayName: Install Node.js + - task: CacheBeta@0 + inputs: + key: yarn | $(Agent.OS) | yarn.lock + path: $(YARN_CACHE_FOLDER) + displayName: Cache Yarn packages + - script: make deps + displayName: Install dependencies + - script: make lint + displayName: Lint + - script: make test + displayName: Run tests + - script: make build + displayName: Build + env: + WIN_CSC_LINK: $(WIN_CSC_LINK) + WIN_CSC_KEY_PASSWORD: $(WIN_CSC_KEY_PASSWORD) + GH_TOKEN: $(GH_TOKEN) + - job: macOS + condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" + pool: + vmImage: macOS-10.14 + strategy: + matrix: + node_12.x: + node_version: 12.x + steps: + - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" + displayName: Set the tag name as an environment variable + - task: NodeTool@0 + inputs: + versionSpec: $(node_version) + displayName: Install Node.js + - task: CacheBeta@0 + inputs: + key: cache | $(Agent.OS) | yarn.lock + path: $(AZURE_CACHE_FOLDER) + cacheHitVar: CACHE_RESTORED + displayName: Cache Yarn packages + - bash: | + mkdir -p "$YARN_CACHE_FOLDER" + tar -xzf "$AZURE_CACHE_FOLDER/yarn-cache.tar.gz" -C / + displayName: "Unpack cache" + condition: eq(variables.CACHE_RESTORED, 'true') + - script: make deps + displayName: Install dependencies + - script: make lint + displayName: Lint + - script: make test + displayName: Run tests + - script: make build + displayName: Build + env: + APPLEID: $(APPLEID) + APPLEIDPASS: $(APPLEIDPASS) + CSC_LINK: $(CSC_LINK) + CSC_KEY_PASSWORD: $(CSC_KEY_PASSWORD) + GH_TOKEN: $(GH_TOKEN) + - bash: | + mkdir -p "$AZURE_CACHE_FOLDER" + tar -czf "$AZURE_CACHE_FOLDER/yarn-cache.tar.gz" "$YARN_CACHE_FOLDER" + displayName: Pack cache + - job: Linux + pool: + vmImage: ubuntu-16.04 + strategy: + matrix: + node_12.x: + node_version: 12.x + steps: + - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" + displayName: Set the tag name as an environment variable + condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" + - task: NodeTool@0 + inputs: + versionSpec: $(node_version) + displayName: Install Node.js + - task: CacheBeta@0 + inputs: + key: cache | $(Agent.OS) | yarn.lock + path: $(AZURE_CACHE_FOLDER) + cacheHitVar: CACHE_RESTORED + displayName: Cache Yarn packages + - bash: | + mkdir -p "$YARN_CACHE_FOLDER" + tar -xzf "$AZURE_CACHE_FOLDER/yarn-cache.tar.gz" -C / + displayName: "Unpack cache" + condition: eq(variables.CACHE_RESTORED, 'true') + - script: make deps + displayName: Install dependencies + - script: make lint + displayName: Lint + - script: make test + displayName: Run tests + - bash: | + sudo chown root:root / + sudo apt-get update && sudo apt-get install -y snapd + sudo snap install snapcraft --classic + echo -n "${SNAP_LOGIN}" | base64 -d > snap_login + snapcraft login --with snap_login + displayName: Setup snapcraft + condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" + env: + SNAP_LOGIN: $(SNAP_LOGIN) + - script: make build + displayName: Build + env: + GH_TOKEN: $(GH_TOKEN) + - bash: | + mkdir -p "$AZURE_CACHE_FOLDER" + tar -czf "$AZURE_CACHE_FOLDER/yarn-cache.tar.gz" "$YARN_CACHE_FOLDER" + displayName: Pack cache diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000000..46a0a458b5 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,75 @@ +module.exports = { + overrides: [ + { + files: [ + "src/renderer/**/*.js", + "build/**/*.js", + "src/renderer/**/*.vue" + ], + extends: [ + 'eslint:recommended', + 'plugin:vue/recommended' + ], + env: { + node: true + }, + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + }, + rules: { + "indent": ["error", 2], + "no-unused-vars": "off", + "vue/order-in-components": "off", + "vue/attributes-order": "off", + "vue/max-attributes-per-line": "off" + } + }, + { + files: [ + "src/**/*.ts", + "spec/**/*.ts" + ], + parser: "@typescript-eslint/parser", + extends: [ + 'plugin:@typescript-eslint/recommended', + ], + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + }, + rules: { + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "indent": ["error", 2] + }, + }, + { + files: [ + "dashboard/**/*.ts", + "dashboard/**/*.tsx", + ], + parser: "@typescript-eslint/parser", + extends: [ + 'plugin:@typescript-eslint/recommended', + ], + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + jsx: true, + }, + rules: { + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/ban-ts-ignore": "off", + "indent": ["error", 2] + }, + } + ] +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..3257c676c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +dist/ +node_modules/ +.DS_Store +yarn-error.log +coverage/ +tmp/ +static/build/client/ +binaries/client/ +binaries/server/ diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000000..c54f7d6d6e --- /dev/null +++ b/.yarnrc @@ -0,0 +1,3 @@ +disturl "https://atom.io/download/electron" +target "6.0.12" +runtime "electron" diff --git a/LICENSE b/LICENSE index c32f6fb821..a399a8372e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,191 +1,23 @@ +MIT License - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +Copyright (c) 2020 Lakend Labs, Inc. - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +All rights reserved. - 1. Definitions. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - Copyright 2019 Kontena, Inc. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..10184e370f --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +ifeq ($(OS),Windows_NT) + DETECTED_OS := Windows +else + DETECTED_OS := $(shell uname) +endif + +.PHONY: dev build test clean + +dev: app-deps dashboard-deps build-dashboard-server + yarn dev + +test: test-app test-dashboard + +lint: + yarn lint + +test-app: + yarn test + +deps: app-deps dashboard-deps + +app-deps: + yarn install --frozen-lockfile + +build: build-dashboard app-deps + yarn install +ifeq "$(DETECTED_OS)" "Windows" + yarn dist:win +else + yarn dist +endif + +dashboard-deps: + cd dashboard && yarn install --frozen-lockfile + +clean-dashboard: + rm -rf dashboard/build/ && rm -rf static/build/client + +test-dashboard: dashboard-deps + cd dashboard && yarn test + +build-dashboard: build-dashboard-server build-dashboard-client + +build-dashboard-server: dashboard-deps clean-dashboard + cd dashboard && yarn build-server +ifeq "$(DETECTED_OS)" "Linux" + rm binaries/server/linux/lens-server || true + cd dashboard && yarn pkg-server-linux +endif +ifeq "$(DETECTED_OS)" "Darwin" + rm binaries/server/darwin/lens-server || true + cd dashboard && yarn pkg-server-macos +endif +ifeq "$(DETECTED_OS)" "Windows" + rm binaries/server/windows/*.exe || true + cd dashboard && yarn pkg-server-win +endif + +build-dashboard-client: dashboard-deps clean-dashboard + cd dashboard && yarn build-client + +clean: + rm -rf dist/* diff --git a/README.md b/README.md index e4423ae3ce..0abbd03fdc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,8 @@ -# Kontena Lens +# Lens -[Kontena Lens](https://www.kontena.io/lens/) provides all necessary tools and technology to take control of your Kubernetes clusters. Ensure your cluster is properly setup and configured. Enjoy increased visibility and hands-on troubleshooting capabilities. Use built-in user management and integration APIs with support to most standard external authentication systems. +Lens - The free, smart desktop application for managing Kubernetes clusters. -[![Kontena Lens - The Ultimate Dashboard for Kubernetes](./images/screenshot.png)](https://youtu.be/04v2ODsmtIs) - - -## What makes Kontena Lens special? - -Many people might compare Kontena Lens to [Kubernetes Dashboard](https://github.com/kubernetes/dashboard) open source project since they both deliver UI to Kubernetes. But Kontena Lens is more than just an UI. It's the only management system you’ll ever need to take control of your Kubernetes clusters: +## What makes Lens special? * Amazing usability and end user experience * Real-time cluster state visualization @@ -15,11 +10,18 @@ Many people might compare Kontena Lens to [Kubernetes Dashboard](https://github. * Terminal access to nodes and containers * Fully featured role based access control management * Dashboard access and functionality limited by RBAC -* Professional support available -## Further Information -- [Website](https://www.kontena.io/lens) -- [Slack](https://slack.kontena.io/) +## Installation + +Download a pre-built package from the [releases](https://github.com/kontena/lens/releases) page. Lens can be also installed via [snapcraft](https://snapcraft.io/kontena-lens) (Linux only). + +## Development + +> Prerequisities: Nodejs v12, make, yarn + +* `make dev` - builds and starts the app +* `make test` - run tests ## Contributing + Bug reports and pull requests are welcome on GitHub at https://github.com/kontena/lens. diff --git a/build/download_kubectl.ts b/build/download_kubectl.ts new file mode 100644 index 0000000000..aa31ea6cae --- /dev/null +++ b/build/download_kubectl.ts @@ -0,0 +1,104 @@ +import * as request from "request" +import * as fs from "fs" +import { ensureDir, pathExists } from "fs-extra" +import * as md5File from "md5-file" +import * as requestPromise from "request-promise-native" +import * as path from "path" + +class KubectlDownloader { + public kubectlVersion: string + protected url: string + protected path: string; + protected dirname: string + + constructor(clusterVersion: string, platform: string, arch: string, target: string) { + this.kubectlVersion = clusterVersion; + const binaryName = platform === "windows" ? "kubectl.exe" : "kubectl" + this.url = `https://storage.googleapis.com/kubernetes-release/release/v${this.kubectlVersion}/bin/${platform}/${arch}/${binaryName}`; + this.dirname = path.dirname(target); + this.path = target; + } + + protected async urlEtag() { + const response = await requestPromise({ + method: "HEAD", + uri: this.url, + resolveWithFullResponse: true + }).catch((error) => { console.log(error) }) + + if (response.headers["etag"]) { + return response.headers["etag"].replace(/"/g, "") + } + return "" + } + + public async checkBinary() { + const exists = await pathExists(this.path) + if (exists) { + const hash = md5File.sync(this.path) + const etag = await this.urlEtag() + if(hash == etag) { + console.log("Kubectl md5sum matches the remote etag") + return true + } + + console.log("Kubectl md5sum " + hash + " does not match the remote etag " + etag + ", unlinking and downloading again") + await fs.promises.unlink(this.path) + } + + return false + } + + public async downloadKubectl() { + const exists = await this.checkBinary(); + if(exists) { + console.log("Already exists and is valid") + return + } + await ensureDir(path.dirname(this.path), 0o755) + + const file = fs.createWriteStream(this.path) + console.log(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`) + const requestOpts: request.UriOptions & request.CoreOptions = { + uri: this.url, + gzip: true + } + const stream = request(requestOpts) + + stream.on("complete", () => { + console.log("kubectl binary download finished") + file.end(() => {}) + }) + + stream.on("error", (error) => { + console.log(error) + fs.unlink(this.path, () => {}) + throw(error) + }) + return new Promise((resolve, reject) => { + file.on("close", () => { + console.log("kubectl binary download closed") + fs.chmod(this.path, 0o755, () => {}) + resolve() + }) + stream.pipe(file) + }) + } +} + +const downloadVersion: string = require("../package.json").config.bundledKubectlVersion +const baseDir = path.join(process.env.INIT_CWD, 'binaries', 'client') +const downloads = [ + { platform: 'linux', arch: 'amd64', target: path.join(baseDir, 'linux', 'x64', 'kubectl') }, + { platform: 'darwin', arch: 'amd64', target: path.join(baseDir, 'darwin', 'x64', 'kubectl') }, + { platform: 'windows', arch: 'amd64', target: path.join(baseDir, 'windows', 'x64', 'kubectl.exe') }, + { platform: 'windows', arch: '386', target: path.join(baseDir, 'windows', 'ia32', 'kubectl.exe') } +] + +downloads.forEach((dlOpts) => { + console.log(dlOpts) + const downloader = new KubectlDownloader(downloadVersion, dlOpts.platform, dlOpts.arch, dlOpts.target); + console.log("Downloading: " + JSON.stringify(dlOpts)); + downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete"))) +}) + diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000000..9a279dc836 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/build/icon.ico b/build/icon.ico new file mode 100755 index 0000000000000000000000000000000000000000..b49df2930d9b5eec7111f66f6484253c15af3f82 GIT binary patch literal 9033 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 O0IDyv6u&E2h5irL|HY>O literal 0 HcmV?d00001 diff --git a/build/icon.png b/build/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5c3b5e46a1b75f0fcc71bd302bdfae36ef4a805a GIT binary patch literal 12150 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 WemjE+&QN7N0LqUv6pJ5PzW5(!Ks99m literal 0 HcmV?d00001 diff --git a/build/icons/512x512.png b/build/icons/512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..680f546adff77ca2b4bab939ac3d339e8d91e301 GIT binary patch literal 13118 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 literal 0 HcmV?d00001 diff --git a/build/notarize.js b/build/notarize.js new file mode 100644 index 0000000000..3d97b152c5 --- /dev/null +++ b/build/notarize.js @@ -0,0 +1,20 @@ +const { notarize } = require('electron-notarize') + +exports.default = async function notarizing(context) { + const { electronPlatformName, appOutDir } = context; + if (electronPlatformName !== 'darwin') { + return; + } + if (!process.env.APPLEID || !process.env.APPLEIDPASS) { + return; + } + + const appName = context.packager.appInfo.productFilename; + + return await notarize({ + appBundleId: 'io.kontena.lens-app', + appPath: `${appOutDir}/${appName}.app`, + appleId: process.env.APPLEID, + appleIdPassword: process.env.APPLEIDPASS, + }); +}; diff --git a/dashboard/.babelrc b/dashboard/.babelrc new file mode 100644 index 0000000000..4c77732e07 --- /dev/null +++ b/dashboard/.babelrc @@ -0,0 +1,10 @@ +{ + "plugins": [ + "macros", + "@babel/plugin-transform-runtime", + ], + "presets": [ + "@babel/preset-env", + "@babel/preset-react" + ] +} diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 0000000000..fe3e5bbda2 --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,12 @@ +.idea +node_modules/ +build/ +dist/ +wireframes/ +backup +npm-debug.log +.vscode +.env +/tslint.json +*.DS_Store +docker-compose.yml \ No newline at end of file diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100755 index 0000000000..4a6bd8f2aa --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,14 @@ +.idea +node_modules +build/ +dist/ +wireframes/ +backup +npm-debug.log +.vscode +dump.rdb +*.env +/tslint.json +*.DS_Store +locales/_build/ +locales/**/*.js diff --git a/dashboard/.linguirc b/dashboard/.linguirc new file mode 100644 index 0000000000..746c9638e6 --- /dev/null +++ b/dashboard/.linguirc @@ -0,0 +1,18 @@ +{ + "locales": ["en", "ru"], + "sourceLocale": "en", + "fallbackLocale": "en", + "compileNamespace": "cjs", + "format": "po", + "extractBabelOptions": { + "plugins": [ + "@babel/plugin-syntax-dynamic-import" + ] + }, + "catalogs": [ + { + "path": "./locales/{locale}/messages", + "include": "./client" + } + ] +} \ No newline at end of file diff --git a/dashboard/client/api/api-manager.ts b/dashboard/client/api/api-manager.ts new file mode 100644 index 0000000000..3bfb0caebd --- /dev/null +++ b/dashboard/client/api/api-manager.ts @@ -0,0 +1,79 @@ +import React from "react"; +import { observable } from "mobx"; +import { autobind } from "../utils/autobind"; +import { KubeApi } from "./kube-api"; +import { KubeObjectStore } from "../kube-object.store"; +import { KubeObjectDetailsProps, KubeObjectListLayoutProps, KubeObjectMenuProps } from "../components/kube-object"; + +export interface ApiComponents { + List?: React.ComponentType; + Menu?: React.ComponentType; + Details?: React.ComponentType; +} + +@autobind() +export class ApiManager { + private apis = observable.map(); + private stores = observable.map(); + private views = observable.map(); + + getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { + const apis = this.apis; + if (typeof pathOrCallback === "string") { + let api = apis.get(pathOrCallback); + if (!api) { + const { apiBase } = KubeApi.parseApi(pathOrCallback); + api = apis.get(apiBase); + } + return api; + } + else { + return Array.from(apis.values()).find(pathOrCallback); + } + } + + registerApi(apiBase: string, api: KubeApi) { + if (this.apis.has(apiBase)) return; + this.apis.set(apiBase, api); + } + + protected resolveApi(api: string | KubeApi): KubeApi { + if (typeof api === "string") return this.getApi(api) + return api; + } + + unregisterApi(api: string | KubeApi) { + if (typeof api === "string") this.apis.delete(api); + else { + const apis = Array.from(this.apis.entries()); + const entry = apis.find(entry => entry[1] === api); + if (entry) this.unregisterApi(entry[0]); + } + } + + registerStore(api: KubeApi, store: KubeObjectStore) { + this.stores.set(api, store); + } + + getStore(api: string | KubeApi): KubeObjectStore { + return this.stores.get(this.resolveApi(api)); + } + + registerViews(api: KubeApi | KubeApi[], views: ApiComponents) { + if (Array.isArray(api)) { + api.forEach(api => this.registerViews(api, views)); + return; + } + const currentViews = this.views.get(api) || {}; + this.views.set(api, { + ...currentViews, + ...views, + }); + } + + getViews(api: string | KubeApi): ApiComponents { + return this.views.get(this.resolveApi(api)) || {} + } +} + +export const apiManager = new ApiManager(); diff --git a/dashboard/client/api/endpoints/__tests__/cron-job.api.test.ts b/dashboard/client/api/endpoints/__tests__/cron-job.api.test.ts new file mode 100644 index 0000000000..1d0bf66847 --- /dev/null +++ b/dashboard/client/api/endpoints/__tests__/cron-job.api.test.ts @@ -0,0 +1,47 @@ +import { CronJob } from "../"; + +//jest.mock('../../../components/+login/auth.store.ts', () => 'authStore'); +jest.mock('../../kube-watch-api.ts', () => 'kube-watch-api'); + +const cronJob = new CronJob({ + metadata: { + name: "hello", + namespace: "default", + selfLink: "/apis/batch/v1beta1/namespaces/default/cronjobs/hello", + uid: "cd3af13f-0b70-11ea-93da-9600002795a0", + resourceVersion: "51394448", + creationTimestamp: "2019-11-20T08:36:09Z", + }, + spec: { + schedule: "30 06 31 12 *", + concurrencyPolicy: "Allow", + suspend: false, + }, + status: {} +} as any) + +describe("Check for CronJob schedule never run", () => { + test("Should be false with normal schedule", () => { + expect(cronJob.isNeverRun()).toBeFalsy(); + }); + + test("Should be false with other normal schedule", () => { + cronJob.spec.schedule = "0 1 * * *"; + expect(cronJob.isNeverRun()).toBeFalsy(); + }); + + test("Should be true with date 31 of February", () => { + cronJob.spec.schedule = "30 06 31 2 *" + expect(cronJob.isNeverRun()).toBeTruthy(); + }); + + test("Should be true with date 32 of July", () => { + cronJob.spec.schedule = "0 30 06 32 7 *" + expect(cronJob.isNeverRun()).toBeTruthy(); + }); + + test("Should be false with predefined schedule", () => { + cronJob.spec.schedule = "@hourly"; + expect(cronJob.isNeverRun()).toBeFalsy(); + }); +}); diff --git a/dashboard/client/api/endpoints/cert-manager.api.ts b/dashboard/client/api/endpoints/cert-manager.api.ts new file mode 100644 index 0000000000..f3d3408441 --- /dev/null +++ b/dashboard/client/api/endpoints/cert-manager.api.ts @@ -0,0 +1,261 @@ +// Kubernetes certificate management controller apis +// Reference: https://docs.cert-manager.io/en/latest/reference/index.html +// API docs: https://docs.cert-manager.io/en/latest/reference/api-docs/index.html + +import { KubeObject } from "../kube-object"; +import { ISecretRef, secretsApi } from "./secret.api"; +import { getDetailsUrl } from "../../navigation"; +import { KubeApi } from "../kube-api"; + +export class Certificate extends KubeObject { + static kind = "Certificate" + + spec: { + secretName: string; + commonName?: string; + dnsNames?: string[]; + organization?: string[]; + ipAddresses?: string[]; + duration?: string; + renewBefore?: string; + isCA?: boolean; + keySize?: number; + keyAlgorithm?: "rsa" | "ecdsa"; + issuerRef: { + kind?: string; + name: string; + }; + acme?: { + config: { + domains: string[]; + http01: { + ingress?: string; + ingressClass?: string; + }; + dns01?: { + provider: string; + }; + }[]; + }; + } + status: { + conditions?: { + lastTransitionTime: string; // 2019-06-04T07:35:58Z, + message: string; // Certificate is up to date and has not expired, + reason: string; // Ready, + status: string; // True, + type: string; // Ready + }[]; + notAfter: string; // 2019-11-01T05:36:27Z + lastFailureTime?: string; + } + + getType(): string { + const { isCA, acme } = this.spec; + if (isCA) return "CA" + if (acme) return "ACME" + } + + getCommonName() { + return this.spec.commonName || "" + } + + getIssuerName() { + return this.spec.issuerRef.name; + } + + getSecretName() { + return this.spec.secretName; + } + + getIssuerDetailsUrl() { + return getDetailsUrl(issuersApi.getUrl({ + namespace: this.getNs(), + name: this.getIssuerName(), + })) + } + + getSecretDetailsUrl() { + return getDetailsUrl(secretsApi.getUrl({ + namespace: this.getNs(), + name: this.getSecretName(), + })) + } + + getConditions() { + const { conditions = [] } = this.status; + return conditions.map(condition => { + const { message, reason, lastTransitionTime, status } = condition; + return { + ...condition, + isReady: status === "True", + tooltip: `${message || reason} (${lastTransitionTime})` + } + }); + } +} + +export class Issuer extends KubeObject { + static kind = "Issuer" + + spec: { + acme?: { + email: string; + server: string; + skipTLSVerify?: boolean; + privateKeySecretRef: ISecretRef; + solvers?: { + dns01?: { + cnameStrategy: string; + acmedns?: { + host: string; + accountSecretRef: ISecretRef; + }; + akamai?: { + accessTokenSecretRef: ISecretRef; + clientSecretSecretRef: ISecretRef; + clientTokenSecretRef: ISecretRef; + serviceConsumerDomain: string; + }; + azuredns?: { + clientID: string; + clientSecretSecretRef: ISecretRef; + hostedZoneName: string; + resourceGroupName: string; + subscriptionID: string; + tenantID: string; + }; + clouddns?: { + project: string; + serviceAccountSecretRef: ISecretRef; + }; + cloudflare?: { + email: string; + apiKeySecretRef: ISecretRef; + }; + digitalocean?: { + tokenSecretRef: ISecretRef; + }; + rfc2136?: { + nameserver: string; + tsigAlgorithm: string; + tsigKeyName: string; + tsigSecretSecretRef: ISecretRef; + }; + route53?: { + accessKeyID: string; + hostedZoneID: string; + region: string; + secretAccessKeySecretRef: ISecretRef; + }; + webhook?: { + config: object; // arbitrary json + groupName: string; + solverName: string; + }; + }; + http01?: { + ingress: { + class: string; + name: string; + serviceType: string; + }; + }; + selector?: { + dnsNames: string[]; + matchLabels: { + [label: string]: string; + }; + }; + }[]; + }; + ca?: { + secretName: string; + }; + vault?: { + path: string; + server: string; + caBundle: string; // + auth: { + appRole: { + path: string; + roleId: string; + secretRef: ISecretRef; + }; + }; + }; + selfSigned?: {}; + venafi?: { + zone: string; + cloud?: { + apiTokenSecretRef: ISecretRef; + }; + tpp?: { + url: string; + caBundle: string; // + credentialsRef: { + name: string; + }; + }; + }; + } + + status: { + acme?: { + uri: string; + }; + conditions?: { + lastTransitionTime: string; // 2019-06-05T07:10:42Z, + message: string; // The ACME account was registered with the ACME server, + reason: string; // ACMEAccountRegistered, + status: string; // True, + type: string; // Ready + }[]; + } + + getType() { + const { acme, ca, selfSigned, vault, venafi } = this.spec; + if (acme) return "ACME" + if (ca) return "CA" + if (selfSigned) return "SelfSigned" + if (vault) return "Vault" + if (venafi) return "Venafi" + } + + getConditions() { + const { conditions = [] } = this.status; + return conditions.map(condition => { + const { message, reason, lastTransitionTime, status } = condition; + return { + ...condition, + isReady: status === "True", + tooltip: `${message || reason} (${lastTransitionTime})`, + } + }); + } +} + +export class ClusterIssuer extends Issuer { + static kind = "ClusterIssuer" +} + +export const certificatesApi = new KubeApi({ + kind: Certificate.kind, + apiBase: "/apis/certmanager.k8s.io/v1alpha1/certificates", + isNamespaced: true, + objectConstructor: Certificate, +}); + +export const issuersApi = new KubeApi({ + kind: Issuer.kind, + apiBase: "/apis/certmanager.k8s.io/v1alpha1/issuers", + isNamespaced: true, + objectConstructor: Issuer, +}); + +export const clusterIssuersApi = new KubeApi({ + kind: ClusterIssuer.kind, + apiBase: "/apis/certmanager.k8s.io/v1alpha1/clusterissuers", + isNamespaced: false, + objectConstructor: ClusterIssuer, +}); diff --git a/dashboard/client/api/endpoints/cluster-role-binding.api.ts b/dashboard/client/api/endpoints/cluster-role-binding.api.ts new file mode 100644 index 0000000000..391bfaf12f --- /dev/null +++ b/dashboard/client/api/endpoints/cluster-role-binding.api.ts @@ -0,0 +1,13 @@ +import { RoleBinding } from "./role-binding.api"; +import { KubeApi } from "../kube-api"; + +export class ClusterRoleBinding extends RoleBinding { + static kind = "ClusterRoleBinding" +} + +export const clusterRoleBindingApi = new KubeApi({ + kind: ClusterRoleBinding.kind, + apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterrolebindings", + isNamespaced: false, + objectConstructor: ClusterRoleBinding, +}); diff --git a/dashboard/client/api/endpoints/cluster-role.api.ts b/dashboard/client/api/endpoints/cluster-role.api.ts new file mode 100644 index 0000000000..46175fcd6e --- /dev/null +++ b/dashboard/client/api/endpoints/cluster-role.api.ts @@ -0,0 +1,15 @@ +import { autobind } from "../../utils"; +import { Role } from "./role.api"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class ClusterRole extends Role { + static kind = "ClusterRole" +} + +export const clusterRoleApi = new KubeApi({ + kind: ClusterRole.kind, + apiBase: "/apis/rbac.authorization.k8s.io/v1/clusterroles", + isNamespaced: false, + objectConstructor: ClusterRole, +}); diff --git a/dashboard/client/api/endpoints/cluster.api.ts b/dashboard/client/api/endpoints/cluster.api.ts new file mode 100644 index 0000000000..1f772d57bf --- /dev/null +++ b/dashboard/client/api/endpoints/cluster.api.ts @@ -0,0 +1,114 @@ +import { IMetrics, IMetricsReqParams, metricsApi } from "./metrics.api"; +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +export class ClusterApi extends KubeApi { + async getMetrics(nodeNames: string[], params?: IMetricsReqParams): Promise { + const nodes = nodeNames.join("|"); + const memoryUsage = ` + sum( + node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes) + ) by (kubernetes_name) + `.replace(/_bytes/g, `_bytes{kubernetes_node=~"${nodes}"}`); + + const memoryRequests = `sum(kube_pod_container_resource_requests{node=~"${nodes}", resource="memory"}) by (component)`; + const memoryLimits = `sum(kube_pod_container_resource_limits{node=~"${nodes}", resource="memory"}) by (component)`; + const memoryCapacity = `sum(kube_node_status_capacity{node=~"${nodes}", resource="memory"}) by (component)`; + const cpuUsage = `sum(rate(node_cpu_seconds_total{kubernetes_node=~"${nodes}", mode=~"user|system"}[1m]))`; + const cpuRequests = `sum(kube_pod_container_resource_requests{node=~"${nodes}", resource="cpu"}) by (component)`; + const cpuLimits = `sum(kube_pod_container_resource_limits{node=~"${nodes}", resource="cpu"}) by (component)`; + const cpuCapacity = `sum(kube_node_status_capacity{node=~"${nodes}", resource="cpu"}) by (component)`; + const podUsage = `sum(kubelet_running_pod_count{instance=~"${nodes}"})`; + const podCapacity = `sum(kube_node_status_capacity{node=~"${nodes}", resource="pods"}) by (component)`; + const fsSize = `sum(node_filesystem_size_bytes{kubernetes_node=~"${nodes}", mountpoint="/"}) by (kubernetes_node)`; + const fsUsage = `sum(node_filesystem_size_bytes{kubernetes_node=~"${nodes}", mountpoint="/"} - node_filesystem_avail_bytes{kubernetes_node=~"${nodes}", mountpoint="/"}) by (kubernetes_node)`; + + return metricsApi.getMetrics({ + memoryUsage, + memoryRequests, + memoryLimits, + memoryCapacity, + cpuUsage, + cpuRequests, + cpuLimits, + cpuCapacity, + podUsage, + podCapacity, + fsSize, + fsUsage + }, params); + } +} + +export enum ClusterStatus { + ACTIVE = "Active", + CREATING = "Creating", + REMOVING = "Removing", + ERROR = "Error" +} + +export interface IClusterMetrics { + [metric: string]: T; + memoryUsage: T; + memoryRequests: T; + memoryLimits: T; + memoryCapacity: T; + cpuUsage: T; + cpuRequests: T; + cpuLimits: T; + cpuCapacity: T; + podUsage: T; + podCapacity: T; + fsSize: T; + fsUsage: T; +} + +export class Cluster extends KubeObject { + static kind = "Cluster"; + + spec: { + clusterNetwork?: { + serviceDomain?: string; + pods?: { + cidrBlocks?: string[]; + }; + services?: { + cidrBlocks?: string[]; + }; + }; + providerSpec: { + value: { + profile: string; + }; + }; + } + status?: { + apiEndpoints: { + host: string; + port: string; + }[]; + providerStatus: { + adminUser?: string; + adminPassword?: string; + kubeconfig?: string; + processState?: string; + lensAddress?: string; + }; + errorMessage?: string; + errorReason?: string; + } + + getStatus() { + if (this.metadata.deletionTimestamp) return ClusterStatus.REMOVING; + if (!this.status || !this.status) return ClusterStatus.CREATING; + if (this.status.errorMessage) return ClusterStatus.ERROR; + return ClusterStatus.ACTIVE; + } +} + +export const clusterApi = new ClusterApi({ + kind: Cluster.kind, + apiBase: "/apis/cluster.k8s.io/v1alpha1/clusters", + isNamespaced: true, + objectConstructor: Cluster, +}); diff --git a/dashboard/client/api/endpoints/component-status.api.ts b/dashboard/client/api/endpoints/component-status.api.ts new file mode 100644 index 0000000000..92d91f92a8 --- /dev/null +++ b/dashboard/client/api/endpoints/component-status.api.ts @@ -0,0 +1,25 @@ +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +export interface IComponentStatusCondition { + type: string; + status: string; + message: string; +} + +export class ComponentStatus extends KubeObject { + static kind = "ComponentStatus" + + conditions: IComponentStatusCondition[] + + getTruthyConditions() { + return this.conditions.filter(c => c.status === "True"); + } +} + +export const componentStatusApi = new KubeApi({ + kind: ComponentStatus.kind, + apiBase: "/api/v1/componentstatuses", + isNamespaced: false, + objectConstructor: ComponentStatus, +}); diff --git a/dashboard/client/api/endpoints/config.api.ts b/dashboard/client/api/endpoints/config.api.ts new file mode 100644 index 0000000000..70ad639236 --- /dev/null +++ b/dashboard/client/api/endpoints/config.api.ts @@ -0,0 +1,9 @@ +// App configuration api +import { apiBase } from "../index"; +import { IConfig } from "../../../server/common/config"; + +export const configApi = { + getConfig() { + return apiBase.get("/config") + }, +}; diff --git a/dashboard/client/api/endpoints/configmap.api.ts b/dashboard/client/api/endpoints/configmap.api.ts new file mode 100644 index 0000000000..6637a82163 --- /dev/null +++ b/dashboard/client/api/endpoints/configmap.api.ts @@ -0,0 +1,29 @@ +import { KubeObject } from "../kube-object"; +import { KubeJsonApiData } from "../kube-json-api"; +import { autobind } from "../../utils"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class ConfigMap extends KubeObject { + static kind = "ConfigMap"; + + constructor(data: KubeJsonApiData) { + super(data); + this.data = this.data || {}; + } + + data: { + [param: string]: string; + } + + getKeys(): string[] { + return Object.keys(this.data); + } +} + +export const configMapApi = new KubeApi({ + kind: ConfigMap.kind, + apiBase: "/api/v1/configmaps", + isNamespaced: true, + objectConstructor: ConfigMap, +}); diff --git a/dashboard/client/api/endpoints/crd.api.ts b/dashboard/client/api/endpoints/crd.api.ts new file mode 100644 index 0000000000..39a4e50f8c --- /dev/null +++ b/dashboard/client/api/endpoints/crd.api.ts @@ -0,0 +1,138 @@ +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; +import { crdResourcesURL } from "../../components/+custom-resources/crd.route"; + +export class CustomResourceDefinition extends KubeObject { + static kind = "CustomResourceDefinition"; + + spec: { + group: string; + version: string; + names: { + plural: string; + singular: string; + kind: string; + listKind: string; + }; + scope: "Namespaced" | "Cluster" | string; + validation?: any; + versions: { + name: string; + served: boolean; + storage: boolean; + }[]; + conversion: { + strategy?: string; + webhook?: any; + }; + additionalPrinterColumns?: { + name: string; + type: "integer" | "number" | "string" | "boolean" | "date"; + priority: number; + description: string; + JSONPath: string; + }[]; + } + status: { + conditions: { + lastTransitionTime: string; + message: string; + reason: string; + status: string; + type: string; + }[]; + acceptedNames: { + plural: string; + singular: string; + kind: string; + shortNames: string[]; + listKind: string; + }; + storedVersions: string[]; + } + + getResourceUrl() { + return crdResourcesURL({ + params: { + group: this.getGroup(), + name: this.getPluralName(), + } + }) + } + + getResourceApiBase() { + const { version, group } = this.spec; + return `/apis/${group}/${version}/${this.getPluralName()}` + } + + getPluralName() { + return this.getNames().plural + } + + getResourceKind() { + return this.spec.names.kind + } + + getResourceTitle() { + const name = this.getPluralName(); + return name[0].toUpperCase() + name.substr(1) + } + + getGroup() { + return this.spec.group; + } + + getScope() { + return this.spec.scope; + } + + getVersion() { + return this.spec.version; + } + + isNamespaced() { + return this.getScope() === "Namespaced"; + } + + getStoredVersions() { + return this.status.storedVersions.join(", "); + } + + getNames() { + return this.spec.names; + } + + getConversion() { + return JSON.stringify(this.spec.conversion); + } + + getPrinterColumns(ignorePriority = true) { + const columns = this.spec.additionalPrinterColumns || []; + return columns + .filter(column => column.name != "Age") + .filter(column => ignorePriority ? true : !column.priority); + } + + getValidation() { + return JSON.stringify(this.spec.validation, null, 2); + } + + getConditions() { + if (!this.status.conditions) return []; + return this.status.conditions.map(condition => { + const { message, reason, lastTransitionTime, status } = condition; + return { + ...condition, + isReady: status === "True", + tooltip: `${message || reason} (${lastTransitionTime})` + } + }); + } +} + +export const crdApi = new KubeApi({ + kind: CustomResourceDefinition.kind, + apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions", + isNamespaced: false, + objectConstructor: CustomResourceDefinition, +}); diff --git a/dashboard/client/api/endpoints/cron-job.api.ts b/dashboard/client/api/endpoints/cron-job.api.ts new file mode 100644 index 0000000000..e48a8636b8 --- /dev/null +++ b/dashboard/client/api/endpoints/cron-job.api.ts @@ -0,0 +1,88 @@ +import moment from "moment"; +import { KubeObject } from "../kube-object"; +import { IPodContainer } from "./pods.api"; +import { formatDuration } from "../../utils/formatDuration"; +import { autobind } from "../../utils"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class CronJob extends KubeObject { + static kind = "CronJob" + + kind: string + apiVersion: string + metadata: { + name: string; + namespace: string; + selfLink: string; + uid: string; + resourceVersion: string; + creationTimestamp: string; + labels: { + [key: string]: string; + }; + annotations: { + [key: string]: string; + }; + } + spec: { + schedule: string; + concurrencyPolicy: string; + suspend: boolean; + jobTemplate: { + metadata: { + creationTimestamp?: string; + }; + spec: { + template: { + metadata: { + creationTimestamp?: string; + }; + spec: { + containers: IPodContainer[]; + restartPolicy: string; + terminationGracePeriodSeconds: number; + dnsPolicy: string; + hostPID: boolean; + schedulerName: string; + }; + }; + }; + }; + successfulJobsHistoryLimit: number; + failedJobsHistoryLimit: number; + } + status: { + lastScheduleTime: string; + } + + getSuspendFlag() { + return this.spec.suspend.toString() + } + + getLastScheduleTime() { + const diff = moment().diff(this.status.lastScheduleTime) + return formatDuration(diff, true) + } + + getSchedule() { + return this.spec.schedule + } + + isNeverRun() { + const schedule = this.getSchedule(); + const daysInMonth = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const stamps = schedule.split(" "); + const day = Number(stamps[stamps.length - 3]); // 1-31 + const month = Number(stamps[stamps.length - 2]); // 1-12 + if (schedule.startsWith("@")) return false; + return day > daysInMonth[month - 1]; + } +} + +export const cronJobApi = new KubeApi({ + kind: CronJob.kind, + apiBase: "/apis/batch/v1beta1/cronjobs", + isNamespaced: true, + objectConstructor: CronJob, +}); diff --git a/dashboard/client/api/endpoints/daemon-set.api.ts b/dashboard/client/api/endpoints/daemon-set.api.ts new file mode 100644 index 0000000000..eedd4a1749 --- /dev/null +++ b/dashboard/client/api/endpoints/daemon-set.api.ts @@ -0,0 +1,76 @@ +import get from "lodash/get"; +import { IPodContainer } from "./pods.api"; +import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; +import { autobind } from "../../utils"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class DaemonSet extends WorkloadKubeObject { + static kind = "DaemonSet" + + spec: { + selector: { + matchLabels: { + [name: string]: string; + }; + }; + template: { + metadata: { + creationTimestamp?: string; + labels: { + name: string; + }; + }; + spec: { + containers: IPodContainer[]; + initContainers?: IPodContainer[]; + restartPolicy: string; + terminationGracePeriodSeconds: number; + dnsPolicy: string; + hostPID: boolean; + affinity?: IAffinity; + nodeSelector?: { + [selector: string]: string; + }; + securityContext: {}; + schedulerName: string; + tolerations: { + key: string; + operator: string; + effect: string; + tolerationSeconds: number; + }[]; + }; + }; + updateStrategy: { + type: string; + rollingUpdate: { + maxUnavailable: number; + }; + }; + revisionHistoryLimit: number; + } + status: { + currentNumberScheduled: number; + numberMisscheduled: number; + desiredNumberScheduled: number; + numberReady: number; + observedGeneration: number; + updatedNumberScheduled: number; + numberAvailable: number; + numberUnavailable: number; + } + + getImages() { + const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []) + const initContainers: IPodContainer[] = get(this, "spec.template.spec.initContainers", []) + return [...containers, ...initContainers].map(container => container.image) + } +} + +export const daemonSetApi = new KubeApi({ + kind: DaemonSet.kind, + apiBase: "/apis/apps/v1/daemonsets", + isNamespaced: true, + objectConstructor: DaemonSet, +}); diff --git a/dashboard/client/api/endpoints/deployment.api.ts b/dashboard/client/api/endpoints/deployment.api.ts new file mode 100644 index 0000000000..dbc3744ee0 --- /dev/null +++ b/dashboard/client/api/endpoints/deployment.api.ts @@ -0,0 +1,171 @@ +import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; +import { autobind } from "../../utils"; +import { KubeApi } from "../kube-api"; + +export class DeploymentApi extends KubeApi { + protected getScaleApiUrl(params: { namespace: string; name: string }) { + return this.getUrl(params) + "/scale" + } + + getReplicas(params: { namespace: string; name: string }): Promise { + 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: replicas + } + } + }) + } +} + +@autobind() +export class Deployment extends WorkloadKubeObject { + static kind = "Deployment" + + spec: { + replicas: number; + selector: { matchLabels: { [app: string]: string } }; + template: { + metadata: { + creationTimestamp?: string; + labels: { [app: string]: string }; + }; + spec: { + containers: { + name: string; + image: string; + args?: string[]; + ports?: { + name: string; + containerPort: number; + protocol: string; + }[]; + env?: { + name: string; + value: string; + }[]; + resources: { + limits?: { + cpu: string; + memory: string; + }; + requests: { + cpu: string; + memory: string; + }; + }; + volumeMounts?: { + name: string; + mountPath: string; + }[]; + livenessProbe?: { + httpGet: { + path: string; + port: number; + scheme: string; + }; + initialDelaySeconds: number; + timeoutSeconds: number; + periodSeconds: number; + successThreshold: number; + failureThreshold: number; + }; + readinessProbe?: { + httpGet: { + path: string; + port: number; + scheme: string; + }; + initialDelaySeconds: number; + timeoutSeconds: number; + periodSeconds: number; + successThreshold: number; + failureThreshold: number; + }; + terminationMessagePath: string; + terminationMessagePolicy: string; + imagePullPolicy: string; + }[]; + restartPolicy: string; + terminationGracePeriodSeconds: number; + dnsPolicy: string; + affinity?: IAffinity; + nodeSelector?: { + [selector: string]: string; + }; + serviceAccountName: string; + serviceAccount: string; + securityContext: {}; + schedulerName: string; + tolerations?: { + key: string; + operator: string; + effect: string; + tolerationSeconds: number; + }[]; + volumes?: { + name: string; + configMap: { + name: string; + defaultMode: number; + optional: boolean; + }; + }[]; + }; + }; + strategy: { + type: string; + rollingUpdate: { + maxUnavailable: number; + maxSurge: number; + }; + }; + } + status: { + observedGeneration: number; + replicas: number; + updatedReplicas: number; + readyReplicas: number; + availableReplicas?: number; + unavailableReplicas?: number; + conditions: { + type: string; + status: string; + lastUpdateTime: string; + lastTransitionTime: string; + reason: string; + message: string; + }[]; + } + + getConditions(activeOnly = false) { + const { conditions } = this.status + if (!conditions) return [] + if (activeOnly) { + return conditions.filter(c => c.status === "True") + } + return conditions + } + + getConditionsText(activeOnly = true) { + return this.getConditions(activeOnly).map(({ type }) => type).join(" ") + } + + getReplicas() { + return this.spec.replicas || 0; + } +} + +export const deploymentApi = new DeploymentApi({ + kind: Deployment.kind, + apiBase: "/apis/apps/v1/deployments", + isNamespaced: true, + objectConstructor: Deployment, +}); diff --git a/dashboard/client/api/endpoints/events.api.ts b/dashboard/client/api/endpoints/events.api.ts new file mode 100644 index 0000000000..33f7b4a3ec --- /dev/null +++ b/dashboard/client/api/endpoints/events.api.ts @@ -0,0 +1,59 @@ +import moment from "moment"; +import { KubeObject } from "../kube-object"; +import { formatDuration } from "../../utils/formatDuration"; +import { autobind } from "../../utils"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class KubeEvent extends KubeObject { + static kind = "Event" + + involvedObject: { + kind: string; + namespace: string; + name: string; + uid: string; + apiVersion: string; + resourceVersion: string; + fieldPath: string; + } + reason: string + message: string + source: { + component: string; + host: string; + } + firstTimestamp: string + lastTimestamp: string + count: number + type: string + eventTime: null + reportingComponent: string + reportingInstance: string + + isWarning() { + return this.type === "Warning"; + } + + getSource() { + const { component, host } = this.source + return `${component} ${host || ""}` + } + + getFirstSeenTime() { + const diff = moment().diff(this.firstTimestamp) + return formatDuration(diff, true) + } + + getLastSeenTime() { + const diff = moment().diff(this.lastTimestamp) + return formatDuration(diff, true) + } +} + +export const eventApi = new KubeApi({ + kind: KubeEvent.kind, + apiBase: "/api/v1/events", + isNamespaced: true, + objectConstructor: KubeEvent, +}) diff --git a/dashboard/client/api/endpoints/helm-charts.api.ts b/dashboard/client/api/endpoints/helm-charts.api.ts new file mode 100644 index 0000000000..ced9d13c1c --- /dev/null +++ b/dashboard/client/api/endpoints/helm-charts.api.ts @@ -0,0 +1,129 @@ +import pathToRegExp from "path-to-regexp"; +import { apiKubeHelm } from "../index"; +import { stringify } from "querystring"; +import { autobind } from "../../utils"; + +interface IHelmChartList { + [repo: string]: { + [name: string]: HelmChart; + }; +} + +export interface IHelmChartDetails { + readme: string; + versions: HelmChart[]; +} + +const endpoint = pathToRegExp.compile(`/v2/charts/:repo?/:name?`) as (params?: { + repo?: string; + name?: string; +}) => string; + +export const helmChartsApi = { + list() { + return apiKubeHelm + .get(endpoint()) + .then(data => { + return Object + .values(data) + .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) + .map(HelmChart.create); + }); + }, + + get(repo: string, name: string, readmeVersion?: string) { + const path = endpoint({ repo, name }); + return apiKubeHelm + .get(path + "?" + stringify({ version: readmeVersion })) + .then(data => { + const versions = data.versions.map(HelmChart.create); + const readme = data.readme; + return { + readme, + versions, + } + }); + }, + + getValues(repo: string, name: string, version: string) { + return apiKubeHelm + .get(`/v2/charts/${repo}/${name}/values?` + stringify({ version })); + } +}; + +@autobind() +export class HelmChart { + constructor(data: any) { + Object.assign(this, data); + } + + static create(data: any) { + return new HelmChart(data); + } + + apiVersion: string + name: string + version: string + repo: string + kubeVersion?: string + created: string + description?: string + digest: string + keywords?: string[] + home?: string + sources?: string[] + maintainers?: { + name: string; + email: string; + url: string; + }[] + engine?: string + icon?: string + appVersion?: string + deprecated?: boolean + tillerVersion?: string + + getId() { + return this.digest; + } + + getName() { + return this.name; + } + + getFullName(splitter = "/") { + return [this.getRepository(), this.getName()].join(splitter); + } + + getDescription() { + return this.description; + } + + getIcon() { + return this.icon; + } + + getHome() { + return this.home; + } + + getMaintainers() { + return this.maintainers || []; + } + + getVersion() { + return this.version; + } + + getRepository() { + return this.repo; + } + + getAppVersion() { + return this.appVersion || ""; + } + + getKeywords() { + return this.keywords || []; + } +} diff --git a/dashboard/client/api/endpoints/helm-releases.api.ts b/dashboard/client/api/endpoints/helm-releases.api.ts new file mode 100644 index 0000000000..23ea9653c1 --- /dev/null +++ b/dashboard/client/api/endpoints/helm-releases.api.ts @@ -0,0 +1,213 @@ +import jsYaml from "js-yaml"; +import pathToRegExp from "path-to-regexp"; +import { autobind, formatDuration } from "../../utils"; +import capitalize from "lodash/capitalize"; +import { apiKubeHelm } from "../index"; +import { helmChartStore } from "../../components/+apps-helm-charts/helm-chart.store"; +import { ItemObject } from "../../item.store"; +import { KubeObject } from "../kube-object"; + +interface IReleasePayload { + name: string; + namespace: string; + version: string; + config: string; // release values + manifest: string; + info: { + deleted: string; + description: string; + first_deployed: string; + last_deployed: string; + notes: string; + status: string; + }; +} + +interface IReleaseRawDetails extends IReleasePayload { + resources: string; +} + +export interface IReleaseDetails extends IReleasePayload { + resources: KubeObject[]; +} + +export interface IReleaseCreatePayload { + name?: string; + repo: string; + chart: string; + namespace: string; + version: string; + values: string; +} + +export interface IReleaseUpdatePayload { + repo: string; + chart: string; + version: string; + values: string; +} + +export interface IReleaseUpdateDetails { + log: string; + release: IReleaseDetails; +} + +export interface IReleaseRevision { + revision: number; + updated: string; + status: string; + chart: string; + description: string; +} + +const endpoint = pathToRegExp.compile(`/v2/releases/:namespace?/:name?`) as ( + params?: { + namespace?: string; + name?: string; + } +) => string; + +export const helmReleasesApi = { + list(namespace?: string) { + return apiKubeHelm + .get(endpoint({ namespace })) + .then(releases => releases.map(HelmRelease.create)); + }, + + get(name: string, namespace: string) { + const path = endpoint({ name, namespace }); + return apiKubeHelm.get(path).then(details => { + const items: KubeObject[] = JSON.parse(details.resources).items; + const resources = items.map(item => KubeObject.create(item)); + return { + ...details, + resources + } + }); + }, + + create(payload: IReleaseCreatePayload): Promise { + const { repo, ...data } = payload; + data.chart = `${repo}/${data.chart}`; + data.values = jsYaml.safeLoad(data.values); + return apiKubeHelm.post(endpoint(), { data }); + }, + + update(name: string, namespace: string, payload: IReleaseUpdatePayload): Promise { + const { repo, ...data } = payload; + data.chart = `${repo}/${data.chart}`; + data.values = jsYaml.safeLoad(data.values); + return apiKubeHelm.put(endpoint({ name, namespace }), { data }); + }, + + async delete(name: string, namespace: string) { + const path = endpoint({ name, namespace }); + return apiKubeHelm.del(path); + }, + + getValues(name: string, namespace: string) { + const path = endpoint({ name, namespace }) + "/values"; + return apiKubeHelm.get(path); + }, + + getHistory(name: string, namespace: string): Promise { + const path = endpoint({ name, namespace }) + "/history"; + return apiKubeHelm.get(path); + }, + + rollback(name: string, namespace: string, revision: number) { + const path = endpoint({ name, namespace }) + "/rollback"; + return apiKubeHelm.put(path, { + data: { + revision: revision + } + }); + } +}; + +@autobind() +export class HelmRelease implements ItemObject { + constructor(data: any) { + Object.assign(this, data); + } + + static create(data: any) { + return new HelmRelease(data); + } + + appVersion: string + name: string + namespace: string + chart: string + status: string + updated: string + revision: number + + getId() { + return this.namespace + this.name; + } + + getName() { + return this.name; + } + + getNs() { + return this.namespace; + } + + getChart(withVersion = false) { + return withVersion ? + this.chart : + this.chart.substr(0, this.chart.lastIndexOf("-")); + } + + getRevision() { + return this.revision; + } + + getStatus() { + return capitalize(this.status); + } + + getVersion() { + return this.chart.match(/(\d+)[^-]*$/)[0]; + } + + 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; + if (humanize) { + return formatDuration(diff, compact); + } + return diff; + } + + // Helm does not store from what repository the release is installed, + // so we have to try to guess it by searching charts + async getRepo() { + const chartName = this.getChart(); + const version = this.getVersion(); + const versions = await helmChartStore.getVersions(chartName); + const chartVersion = versions.find(chartVersion => chartVersion.version === version); + return chartVersion ? chartVersion.repo : ""; + } + + getLastVersion(): string | null { + const chartName = this.getChart(); + const versions = helmChartStore.versions.get(chartName); + if (!versions) { + return null; // checking new version state + } + if (versions.length) { + return versions[0].version; // versions already sorted when loaded, the first is latest + } + return this.getVersion(); + } + + hasNewVersion() { + const lastVersion = this.getLastVersion(); + return lastVersion && lastVersion !== this.getVersion(); + } +} diff --git a/dashboard/client/api/endpoints/hpa.api.ts b/dashboard/client/api/endpoints/hpa.api.ts new file mode 100644 index 0000000000..4410bb9521 --- /dev/null +++ b/dashboard/client/api/endpoints/hpa.api.ts @@ -0,0 +1,140 @@ +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +export enum HpaMetricType { + Resource = "Resource", + Pods = "Pods", + Object = "Object", + External = "External", +} + +export type IHpaMetricData = T & { + target?: { + kind: string; + name: string; + apiVersion: string; + }; + name?: string; + metricName?: string; + currentAverageUtilization?: number; + currentAverageValue?: string; + targetAverageUtilization?: number; + targetAverageValue?: string; +} + +export interface IHpaMetric { + [kind: string]: IHpaMetricData; + + type: HpaMetricType; + resource?: IHpaMetricData<{ name: string }>; + pods?: IHpaMetricData; + external?: IHpaMetricData; + object?: IHpaMetricData<{ + describedObject: { + apiVersion: string; + kind: string; + name: string; + }; + }>; +} + +export class HorizontalPodAutoscaler extends KubeObject { + static kind = "HorizontalPodAutoscaler"; + + spec: { + scaleTargetRef: { + kind: string; + name: string; + apiVersion: string; + }; + minReplicas: number; + maxReplicas: number; + metrics: IHpaMetric[]; + } + status: { + currentReplicas: number; + desiredReplicas: number; + currentMetrics: IHpaMetric[]; + conditions: { + lastTransitionTime: string; + message: string; + reason: string; + status: string; + type: string; + }[]; + } + + getMaxPods() { + return this.spec.maxReplicas || 0; + } + + getMinPods() { + return this.spec.minReplicas || 0; + } + + getReplicas() { + return this.status.currentReplicas; + } + + getConditions() { + if (!this.status.conditions) return []; + return this.status.conditions.map(condition => { + const { message, reason, lastTransitionTime, status } = condition; + return { + ...condition, + isReady: status === "True", + tooltip: `${message || reason} (${lastTransitionTime})` + } + }); + } + + getMetrics() { + return this.spec.metrics || []; + } + + getCurrentMetrics() { + return this.status.currentMetrics || []; + } + + protected getMetricName(metric: IHpaMetric): string { + const { type, resource, pods, object, external } = metric; + switch (type) { + case HpaMetricType.Resource: + return resource.name + case HpaMetricType.Pods: + return pods.metricName; + case HpaMetricType.Object: + return object.metricName; + case HpaMetricType.External: + return external.metricName; + } + } + + // todo: refactor + getMetricValues(metric: IHpaMetric): string { + const metricType = metric.type.toLowerCase(); + const currentMetric = this.getCurrentMetrics().find(current => + metric.type == current.type && this.getMetricName(metric) == this.getMetricName(current) + ); + const current = currentMetric ? currentMetric[metricType] : null; + const target = metric[metricType]; + let currentValue = "unknown"; + let targetValue = "unknown"; + if (current) { + currentValue = current.currentAverageUtilization || current.currentAverageValue || current.currentValue; + if (current.currentAverageUtilization) currentValue += "%"; + } + if (target) { + targetValue = target.targetAverageUtilization || target.targetAverageValue || target.targetValue; + if (target.targetAverageUtilization) targetValue += "%" + } + return `${currentValue} / ${targetValue}`; + } +} + +export const hpaApi = new KubeApi({ + kind: HorizontalPodAutoscaler.kind, + apiBase: "/apis/autoscaling/v2beta1/horizontalpodautoscalers", + isNamespaced: true, + objectConstructor: HorizontalPodAutoscaler, +}); diff --git a/dashboard/client/api/endpoints/index.ts b/dashboard/client/api/endpoints/index.ts new file mode 100644 index 0000000000..94059774f9 --- /dev/null +++ b/dashboard/client/api/endpoints/index.ts @@ -0,0 +1,32 @@ +// Local express.js & kontena endpoints +export * from "./config.api" +export * from "./cluster.api" +export * from "./kubeconfig.api" + +// Kubernetes endpoints +// Docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/ +export * from "./namespaces.api" +export * from "./cluster-role.api" +export * from "./cluster-role-binding.api" +export * from "./role.api" +export * from "./role-binding.api" +export * from "./secret.api" +export * from "./service-accounts.api" +export * from "./nodes.api" +export * from "./pods.api" +export * from "./deployment.api" +export * from "./daemon-set.api" +export * from "./stateful-set.api" +export * from "./replica-set.api" +export * from "./job.api" +export * from "./cron-job.api" +export * from "./configmap.api" +export * from "./ingress.api" +export * from "./network-policy.api" +export * from "./persistent-volume-claims.api" +export * from "./persistent-volume.api" +export * from "./service.api" +export * from "./storage-class.api" +export * from "./pod-metrics.api" +export * from "./podsecuritypolicy.api" +export * from "./selfsubjectrulesreviews.api" diff --git a/dashboard/client/api/endpoints/ingress.api.ts b/dashboard/client/api/endpoints/ingress.api.ts new file mode 100644 index 0000000000..4105f484a9 --- /dev/null +++ b/dashboard/client/api/endpoints/ingress.api.ts @@ -0,0 +1,118 @@ +import { KubeObject } from "../kube-object"; +import { autobind } from "../../utils"; +import { IMetrics, metricsApi } from "./metrics.api"; +import { KubeApi } from "../kube-api"; + +export class IngressApi extends KubeApi { + getMetrics(ingress: string, namespace: string): Promise { + const bytesSent = (statuses: string) => + `sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[1m])) by (ingress)`; + const bytesSentSuccess = bytesSent("^2\\\\d*"); // Requests with status 2** + const bytesSentFailure = bytesSent("^5\\\\d*"); // Requests with status 5** + const requestDurationSeconds = `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${ingress}"}[1m])) by (ingress)`; + const responseDurationSeconds = `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${ingress}"}[1m])) by (ingress)`; + return metricsApi.getMetrics({ + bytesSentSuccess, + bytesSentFailure, + requestDurationSeconds, + responseDurationSeconds + }, { + namespace, + }); + } +} + +export interface IIngressMetrics { + [metric: string]: T; + bytesSentSuccess: T; + bytesSentFailure: T; + requestDurationSeconds: T; + responseDurationSeconds: T; +} + +@autobind() +export class Ingress extends KubeObject { + static kind = "Ingress" + + spec: { + tls: { + secretName: string; + }[]; + rules?: { + host?: string; + http: { + paths: { + path?: string; + backend: { + serviceName: string; + servicePort: number; + }; + }[]; + }; + }[]; + backend?: { + serviceName: string; + servicePort: number; + }; + } + status: { + loadBalancer: { + ingress: any[]; + }; + } + + getRoutes() { + const { spec: { tls, rules } } = this + if (!rules) return [] + + let protocol = "http" + const routes: string[] = [] + if (tls && tls.length > 0) { + protocol += "s" + } + rules.map(rule => { + const host = rule.host ? rule.host : "*" + if (rule.http && rule.http.paths) { + rule.http.paths.forEach(path => { + routes.push(protocol + "://" + host + (path.path || "/") + " ⇢ " + path.backend.serviceName + ":" + path.backend.servicePort) + }) + } + }) + + return routes; + } + + getHosts() { + const { spec: { rules } } = this + if (!rules) return [] + return rules.filter(rule => rule.host).map(rule => rule.host) + } + + getPorts() { + const ports: number[] = [] + const { spec: { tls, rules, backend } } = this + const httpPort = 80 + const tlsPort = 443 + if (rules && rules.length > 0) { + if (rules.some(rule => rule.hasOwnProperty("http"))) { + ports.push(httpPort) + } + } + else { + if (backend && backend.servicePort) { + ports.push(backend.servicePort) + } + } + if (tls && tls.length > 0) { + ports.push(tlsPort) + } + return ports.join(", ") + } +} + +export const ingressApi = new IngressApi({ + kind: Ingress.kind, + apiBase: "/apis/extensions/v1beta1/ingresses", + isNamespaced: true, + objectConstructor: Ingress, +}); diff --git a/dashboard/client/api/endpoints/job.api.ts b/dashboard/client/api/endpoints/job.api.ts new file mode 100644 index 0000000000..dce9d85c8d --- /dev/null +++ b/dashboard/client/api/endpoints/job.api.ts @@ -0,0 +1,98 @@ +import get from "lodash/get"; +import { autobind } from "../../utils"; +import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; +import { IPodContainer } from "./pods.api"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class Job extends WorkloadKubeObject { + static kind = "Job" + + spec: { + parallelism?: number; + completions?: number; + backoffLimit?: number; + selector: { + matchLabels: { + [name: string]: string; + }; + }; + template: { + metadata: { + creationTimestamp?: string; + labels: { + name: string; + }; + }; + spec: { + containers: IPodContainer[]; + restartPolicy: string; + terminationGracePeriodSeconds: number; + dnsPolicy: string; + hostPID: boolean; + affinity?: IAffinity; + nodeSelector?: { + [selector: string]: string; + }; + tolerations: { + key: string; + operator: string; + effect: string; + tolerationSeconds: number; + }[]; + schedulerName: string; + }; + }; + containers?: IPodContainer[]; + restartPolicy?: string; + terminationGracePeriodSeconds?: number; + dnsPolicy?: string; + serviceAccountName?: string; + serviceAccount?: string; + schedulerName?: string; + } + status: { + conditions: { + type: string; + status: string; + lastProbeTime: string; + lastTransitionTime: string; + message?: string; + }[]; + startTime: string; + completionTime: string; + succeeded: number; + } + + getDesiredCompletions() { + return this.spec.completions || 0; + } + + getCompletions() { + return this.status.succeeded || 0; + } + + getParallelism() { + return this.spec.parallelism; + } + + getCondition() { + // Type of Job condition could be only Complete or Failed + // https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.14/#jobcondition-v1-batch + const { conditions } = this.status; + if (!conditions) return; + return conditions.find(({ status }) => status === "True"); + } + + getImages() { + const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []) + return [...containers].map(container => container.image) + } +} + +export const jobApi = new KubeApi({ + kind: Job.kind, + apiBase: "/apis/batch/v1/jobs", + isNamespaced: true, + objectConstructor: Job, +}); diff --git a/dashboard/client/api/endpoints/kubeconfig.api.ts b/dashboard/client/api/endpoints/kubeconfig.api.ts new file mode 100644 index 0000000000..c6476badf4 --- /dev/null +++ b/dashboard/client/api/endpoints/kubeconfig.api.ts @@ -0,0 +1,12 @@ +// Kubeconfig api +import { apiBase } from "../index"; + +export const kubeConfigApi = { + getUserConfig() { + return apiBase.get("/kubeconfig/user"); + }, + + getServiceAccountConfig(account: string, namespace: string) { + return apiBase.get(`/kubeconfig/service-account/${namespace}/${account}`); + }, +}; diff --git a/dashboard/client/api/endpoints/metrics.api.ts b/dashboard/client/api/endpoints/metrics.api.ts new file mode 100644 index 0000000000..ed6260c225 --- /dev/null +++ b/dashboard/client/api/endpoints/metrics.api.ts @@ -0,0 +1,112 @@ +// Metrics api + +import moment from "moment"; +import { apiBase } from "../index"; +import { IMetricsQuery } from "../../../server/common/metrics"; + +export interface IMetrics { + status: string; + data: { + resultType: string; + result: IMetricsResult[]; + }; +} + +export interface IMetricsResult { + metric: { + [name: string]: string; + instance: string; + node?: string; + pod?: string; + kubernetes?: string; + kubernetes_node?: string; + kubernetes_namespace?: string; + }; + values: [number, string][]; +} + +export interface IMetricsReqParams { + start?: number | string; // timestamp in seconds or valid date-string + end?: number | string; + step?: number; // step in seconds (default: 60s = each point 1m) + range?: number; // time-range in seconds for data aggregation (default: 3600s = last 1h) + namespace?: string; // rbac-proxy validation param +} + +export const metricsApi = { + async getMetrics(query: T, reqParams: IMetricsReqParams = {}): Promise { + const { range = 3600, step = 60, namespace } = reqParams; + let { start, end } = reqParams; + + if (!start && !end) { + const timeNow = Date.now() / 1000; + const now = moment.unix(timeNow).startOf('minute').unix(); // round date to minutes + start = now - range; + end = now; + } + + return apiBase.post("/metrics", { + data: query, + query: { + start, end, step, + "kubernetes_namespace": namespace, + } + }); + }, +}; + +export function normalizeMetrics(metrics: IMetrics, frames = 60): IMetrics { + const { result } = metrics.data; + if (result.length) { + if (frames > 0) { + // fill the gaps + result.forEach(res => { + if (!res.values || !res.values.length) return; + while (res.values.length < frames) { + const timestamp = moment.unix(res.values[0][0]).subtract(1, "minute").unix(); + res.values.unshift([timestamp, "0"]) + } + }); + } + } else { + // always return at least empty values array + result.push({ + metric: {}, + values: [] + } as IMetricsResult); + } + + return metrics; +} + +export function isMetricsEmpty(metrics: { [key: string]: IMetrics }) { + return Object.values(metrics).every(metric => !metric.data.result.length); +} + +export function getItemMetrics(metrics: { [key: string]: IMetrics }, itemName: string) { + if (!metrics) return; + const itemMetrics = { ...metrics }; + for (const metric in metrics) { + const results = metrics[metric].data.result; + const result = results.find(res => Object.values(res.metric)[0] == itemName); + itemMetrics[metric].data.result = result ? [result] : []; + } + return itemMetrics; +} + +export function getMetricLastPoints(metrics: { [key: string]: IMetrics }) { + const result: Partial<{[metric: string]: number}> = {}; + + Object.keys(metrics).forEach(metricName => { + try { + const metric = metrics[metricName]; + if (metric.data.result.length) { + result[metricName] = +metric.data.result[0].values.slice(-1)[0][1]; + } + } catch (e) { + } + return result; + }, {}); + + return result; +} diff --git a/dashboard/client/api/endpoints/namespaces.api.ts b/dashboard/client/api/endpoints/namespaces.api.ts new file mode 100644 index 0000000000..96e14b4235 --- /dev/null +++ b/dashboard/client/api/endpoints/namespaces.api.ts @@ -0,0 +1,28 @@ +import { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; +import { autobind } from "../../utils"; + +export enum NamespaceStatus { + ACTIVE = "Active", + TERMINATING = "Terminating", +} + +@autobind() +export class Namespace extends KubeObject { + static kind = "Namespace"; + + status?: { + phase: string; + } + + getStatus() { + return this.status ? this.status.phase : "-"; + } +} + +export const namespacesApi = new KubeApi({ + kind: Namespace.kind, + apiBase: "/api/v1/namespaces", + isNamespaced: false, + objectConstructor: Namespace, +}); diff --git a/dashboard/client/api/endpoints/network-policy.api.ts b/dashboard/client/api/endpoints/network-policy.api.ts new file mode 100644 index 0000000000..57c6d1fae4 --- /dev/null +++ b/dashboard/client/api/endpoints/network-policy.api.ts @@ -0,0 +1,72 @@ +import { KubeObject } from "../kube-object"; +import { autobind } from "../../utils"; +import { KubeApi } from "../kube-api"; + +export interface IPolicyIpBlock { + cidr: string; + except?: string[]; +} + +export interface IPolicySelector { + matchLabels: { + [label: string]: string; + }; +} + +export interface IPolicyIngress { + from: { + ipBlock?: IPolicyIpBlock; + namespaceSelector?: IPolicySelector; + podSelector?: IPolicySelector; + }[]; + ports: { + protocol: string; + port: number; + }[]; +} + +export interface IPolicyEgress { + to: { + ipBlock: IPolicyIpBlock; + }[]; + ports: { + protocol: string; + port: number; + }[]; +} + +@autobind() +export class NetworkPolicy extends KubeObject { + static kind = "NetworkPolicy" + + spec: { + podSelector: { + matchLabels: { + [label: string]: string; + role: string; + }; + }; + policyTypes: string[]; + ingress: IPolicyIngress[]; + egress: IPolicyEgress[]; + } + + getMatchLabels(): string[] { + if (!this.spec.podSelector || !this.spec.podSelector.matchLabels) return []; + return Object + .entries(this.spec.podSelector.matchLabels) + .map(data => data.join(":")) + } + + getTypes(): string[] { + if (!this.spec.policyTypes) return []; + return this.spec.policyTypes; + } +} + +export const networkPolicyApi = new KubeApi({ + kind: NetworkPolicy.kind, + apiBase: "/apis/networking.k8s.io/v1/networkpolicies", + isNamespaced: true, + objectConstructor: NetworkPolicy, +}); diff --git a/dashboard/client/api/endpoints/nodes.api.ts b/dashboard/client/api/endpoints/nodes.api.ts new file mode 100644 index 0000000000..877b2c7ad4 --- /dev/null +++ b/dashboard/client/api/endpoints/nodes.api.ts @@ -0,0 +1,158 @@ +import { KubeObject } from "../kube-object"; +import { autobind, cpuUnitsToNumber, unitsToBytes } from "../../utils"; +import { IMetrics, metricsApi } from "./metrics.api"; +import { KubeApi } from "../kube-api"; + +export class NodesApi extends KubeApi { + getMetrics(): Promise { + const memoryUsage = ` + sum ( + node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes) + ) by (kubernetes_node) + `; + const memoryCapacity = `sum(kube_node_status_capacity{resource="memory"}) by (node)`; + const cpuUsage = `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[1m])) by(kubernetes_node)`; + const cpuCapacity = `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; + const fsSize = `sum(node_filesystem_size_bytes{mountpoint="/"}) by (kubernetes_node)`; + const fsUsage = `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (kubernetes_node)`; + + return metricsApi.getMetrics({ + memoryUsage, + memoryCapacity, + cpuUsage, + cpuCapacity, + fsSize, + fsUsage + }); + } +} + +export interface INodeMetrics { + [metric: string]: T; + memoryUsage: T; + memoryCapacity: T; + cpuUsage: T; + cpuCapacity: T; + fsUsage: T; + fsSize: T; +} + +@autobind() +export class Node extends KubeObject { + static kind = "Node" + + spec: { + podCIDR: string; + externalID: string; + taints?: { + key: string; + value: string; + effect: string; + }[]; + unschedulable?: boolean; + } + status: { + capacity: { + cpu: string; + memory: string; + pods: string; + }; + allocatable: { + cpu: string; + memory: string; + pods: string; + }; + conditions: { + type: string; + status?: string; + lastHeartbeatTime?: string; + lastTransitionTime?: string; + reason?: string; + message?: string; + }[]; + addresses: { + type: string; + address: string; + }[]; + nodeInfo: { + machineID: string; + systemUUID: string; + bootID: string; + kernelVersion: string; + osImage: string; + containerRuntimeVersion: string; + kubeletVersion: string; + kubeProxyVersion: string; + operatingSystem: string; + architecture: string; + }; + images: { + names: string[]; + sizeBytes: number; + }[]; + } + + getNodeConditionText() { + const { conditions } = this.status + if (!conditions) return "" + return conditions.reduce((types, current) => { + if (current.status !== "True") return "" + return types += ` ${current.type}` + }, "") + } + + getTaints() { + return this.spec.taints || []; + } + + getRoleLabels() { + const roleLabels = Object.keys(this.metadata.labels).filter(key => + key.includes("node-role.kubernetes.io") + ).map(key => key.match(/([^/]+$)/)[0]) // all after last slash + return roleLabels.join(", ") + } + + getCpuCapacity() { + if (!this.status.capacity || !this.status.capacity.cpu) return 0 + return cpuUnitsToNumber(this.status.capacity.cpu) + } + + getMemoryCapacity() { + if (!this.status.capacity || !this.status.capacity.memory) return 0 + return unitsToBytes(this.status.capacity.memory) + } + + getConditions() { + const conditions = this.status.conditions || []; + if (this.isUnschedulable()) { + return [{ type: "SchedulingDisabled", status: "True" }, ...conditions]; + } + return conditions; + } + + getActiveConditions() { + return this.getConditions().filter(c => c.status === "True"); + } + + getWarningConditions() { + const goodConditions = ["Ready", "HostUpgrades", "SchedulingDisabled"]; + return this.getActiveConditions().filter(condition => { + return !goodConditions.includes(condition.type); + }); + } + + getKubeletVersion() { + return this.status.nodeInfo.kubeletVersion; + } + + isUnschedulable() { + return this.spec.unschedulable + } +} + +export const nodesApi = new NodesApi({ + kind: Node.kind, + apiBase: "/api/v1/nodes", + isNamespaced: false, + objectConstructor: Node, +}); diff --git a/dashboard/client/api/endpoints/persistent-volume-claims.api.ts b/dashboard/client/api/endpoints/persistent-volume-claims.api.ts new file mode 100644 index 0000000000..3f6dafd6b8 --- /dev/null +++ b/dashboard/client/api/endpoints/persistent-volume-claims.api.ts @@ -0,0 +1,91 @@ +import { KubeObject } from "../kube-object"; +import { autobind } from "../../utils"; +import { IMetrics, metricsApi } from "./metrics.api"; +import { Pod } from "./pods.api"; +import { KubeApi } from "../kube-api"; + +export class PersistentVolumeClaimsApi extends KubeApi { + getMetrics(pvcName: string, namespace: string): Promise { + const diskUsage = `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${pvcName}"}) by (persistentvolumeclaim, namespace)`; + const diskCapacity = `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${pvcName}"}) by (persistentvolumeclaim, namespace)`; + + return metricsApi.getMetrics({ + diskUsage, + diskCapacity + }, { + namespace + }); + } +} + +export interface IPvcMetrics { + [key: string]: T; + diskUsage: T; + diskCapacity: T; +} + +@autobind() +export class PersistentVolumeClaim extends KubeObject { + static kind = "PersistentVolumeClaim" + + spec: { + accessModes: string[]; + storageClassName: string; + selector: { + matchLabels: { + release: string; + }; + matchExpressions: { + key: string; // environment, + operator: string; // In, + values: string[]; // [dev] + }[]; + }; + resources: { + requests: { + storage: string; // 8Gi + }; + }; + } + status: { + phase: string; // Pending + } + + getPods(allPods: Pod[]): Pod[] { + const pods = allPods.filter(pod => pod.getNs() === this.getNs()) + return pods.filter(pod => { + return pod.getVolumes().filter(volume => + volume.persistentVolumeClaim && + volume.persistentVolumeClaim.claimName === this.getName() + ).length > 0 + }) + } + + getStorage(): string { + if (!this.spec.resources || !this.spec.resources.requests) return "-"; + return this.spec.resources.requests.storage; + } + + getMatchLabels(): string[] { + if (!this.spec.selector || !this.spec.selector.matchLabels) return []; + return Object.entries(this.spec.selector.matchLabels) + .map(([name, val]) => `${name}:${val}`); + } + + getMatchExpressions() { + if (!this.spec.selector || !this.spec.selector.matchExpressions) return []; + return this.spec.selector.matchExpressions; + } + + getStatus(): string { + if (this.status) return this.status.phase; + return "-" + } +} + +export const pvcApi = new PersistentVolumeClaimsApi({ + kind: PersistentVolumeClaim.kind, + apiBase: "/api/v1/persistentvolumeclaims", + isNamespaced: true, + objectConstructor: PersistentVolumeClaim, +}); diff --git a/dashboard/client/api/endpoints/persistent-volume.api.ts b/dashboard/client/api/endpoints/persistent-volume.api.ts new file mode 100644 index 0000000000..317ed1a726 --- /dev/null +++ b/dashboard/client/api/endpoints/persistent-volume.api.ts @@ -0,0 +1,71 @@ +import { KubeObject } from "../kube-object"; +import { unitsToBytes } from "../../utils/convertMemory"; +import { autobind } from "../../utils"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class PersistentVolume extends KubeObject { + static kind = "PersistentVolume" + + spec: { + capacity: { + storage: string; // 8Gi + }; + flexVolume: { + driver: string; // ceph.rook.io/rook-ceph-system, + options: { + clusterNamespace: string; // rook-ceph, + image: string; // pvc-c5d7c485-9f1b-11e8-b0ea-9600000e54fb, + pool: string; // replicapool, + storageClass: string; // rook-ceph-block + }; + }; + mountOptions?: string[]; + accessModes: string[]; // [ReadWriteOnce] + claimRef: { + kind: string; // PersistentVolumeClaim, + namespace: string; // storage, + name: string; // nfs-provisioner, + uid: string; // c5d7c485-9f1b-11e8-b0ea-9600000e54fb, + apiVersion: string; // v1, + resourceVersion: string; // 292180 + }; + persistentVolumeReclaimPolicy: string; // Delete, + storageClassName: string; // rook-ceph-block + nfs?: { + path: string; + server: string; + }; + } + + status: { + phase: string; + reason?: string; + } + + getCapacity(inBytes = false) { + const capacity = this.spec.capacity; + if (capacity) { + if (inBytes) return unitsToBytes(capacity.storage) + return capacity.storage; + } + return 0; + } + + getStatus() { + if (!this.status) return; + return this.status.phase || "-"; + } + + getClaimRefName() { + const { claimRef } = this.spec; + return claimRef ? claimRef.name : ""; + } +} + +export const persistentVolumeApi = new KubeApi({ + kind: PersistentVolume.kind, + apiBase: "/api/v1/persistentvolumes", + isNamespaced: false, + objectConstructor: PersistentVolume, +}); diff --git a/dashboard/client/api/endpoints/pod-metrics.api.ts b/dashboard/client/api/endpoints/pod-metrics.api.ts new file mode 100644 index 0000000000..b24cb50527 --- /dev/null +++ b/dashboard/client/api/endpoints/pod-metrics.api.ts @@ -0,0 +1,21 @@ +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +export class PodMetrics extends KubeObject { + timestamp: string + window: string + containers: { + name: string; + usage: { + cpu: string; + memory: string; + }; + }[] +} + +export const podMetricsApi = new KubeApi({ + kind: PodMetrics.kind, + apiBase: "/apis/metrics.k8s.io/v1beta1/pods", + isNamespaced: true, + objectConstructor: PodMetrics, +}); diff --git a/dashboard/client/api/endpoints/pods.api.ts b/dashboard/client/api/endpoints/pods.api.ts new file mode 100644 index 0000000000..ebba1657c5 --- /dev/null +++ b/dashboard/client/api/endpoints/pods.api.ts @@ -0,0 +1,411 @@ +import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; +import { autobind } from "../../utils"; +import { IMetrics, metricsApi } from "./metrics.api"; +import { KubeApi } from "../kube-api"; + +export class PodsApi extends KubeApi { + async getLogs(params: { namespace: string; name: string }, query?: IPodLogsQuery): Promise { + const path = this.getUrl(params) + "/log"; + return this.request.get(path, { query }); + } + + getMetrics(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise { + const podSelector = pods.map(pod => pod.getName()).join("|"); + const cpuUsage = `sum(rate(container_cpu_usage_seconds_total{container_name!="POD",container_name!="",pod_name=~"${podSelector}",namespace="${namespace}"}[1m])) by (${selector})`; + const cpuRequests = `sum(kube_pod_container_resource_requests{pod=~"${podSelector}",resource="cpu",namespace="${namespace}"}) by (${selector})`; + const cpuLimits = `sum(kube_pod_container_resource_limits{pod=~"${podSelector}",resource="cpu",namespace="${namespace}"}) by (${selector})`; + const memoryUsage = `sum(container_memory_working_set_bytes{container_name!="POD",container_name!="",pod_name=~"${podSelector}",namespace="${namespace}"}) by (${selector})`; + const memoryRequests = `sum(kube_pod_container_resource_requests{pod=~"${podSelector}",resource="memory",namespace="${namespace}"}) by (${selector})`; + const memoryLimits = `sum(kube_pod_container_resource_limits{pod=~"${podSelector}",resource="memory",namespace="${namespace}"}) by (${selector})`; + const fsUsage = `sum(container_fs_usage_bytes{container_name!="POD",container_name!="",pod_name=~"${podSelector}",namespace="${namespace}"}) by (${selector})`; + const networkReceive = `sum(rate(container_network_receive_bytes_total{pod_name=~"${podSelector}",namespace="${namespace}"}[1m])) by (${selector})`; + const networkTransit = `sum(rate(container_network_transmit_bytes_total{pod_name=~"${podSelector}",namespace="${namespace}"}[1m])) by (${selector})`; + + return metricsApi.getMetrics({ + cpuUsage, + cpuRequests, + cpuLimits, + memoryUsage, + memoryRequests, + memoryLimits, + fsUsage, + networkReceive, + networkTransit, + }, { + namespace, + }); + } +} + +export interface IPodMetrics { + [metric: string]: T; + cpuUsage: T; + cpuRequests: T; + cpuLimits: T; + memoryUsage: T; + memoryRequests: T; + memoryLimits: T; + fsUsage: T; + networkReceive: T; + networkTransit: T; +} + +export interface IPodLogsQuery { + container?: string; + tailLines?: number; + timestamps?: boolean; + sinceTime?: string; // Date.toISOString()-format +} + +export enum PodStatus { + TERMINATED = "Terminated", + FAILED = "Failed", + PENDING = "Pending", + RUNNING = "Running", + SUCCEEDED = "Succeeded", + EVICTED = "Evicted" +} + +export interface IPodContainer { + name: string; + image: string; + command?: string[]; + args?: string[]; + ports: { + name?: string; + containerPort: number; + protocol: string; + }[]; + resources?: { + limits: { + cpu: string; + memory: string; + }; + requests: { + cpu: string; + memory: string; + }; + }; + env?: { + name: string; + value?: string; + valueFrom?: { + fieldRef?: { + apiVersion: string; + fieldPath: string; + }; + secretKeyRef?: { + key: string; + name: string; + }; + configMapKeyRef?: { + key: string; + name: string; + }; + }; + }[]; + envFrom?: { + configMapRef?: { + name: string; + }; + }[]; + volumeMounts?: { + name: string; + readOnly: boolean; + mountPath: string; + }[]; + livenessProbe?: IContainerProbe; + readinessProbe?: IContainerProbe; + imagePullPolicy: string; +} + +interface IContainerProbe { + httpGet?: { + path?: string; + port: number; + scheme: string; + host?: string; + }; + exec?: { + command: string[]; + }; + tcpSocket?: { + port: number; + }; + initialDelaySeconds?: number; + timeoutSeconds?: number; + periodSeconds?: number; + successThreshold?: number; + failureThreshold?: number; +} + +export interface IPodContainerStatus { + name: string; + state: { + [index: string]: object; + running?: { + startedAt: string; + }; + waiting?: { + reason: string; + message: string; + }; + terminated?: { + startedAt: string; + finishedAt: string; + exitCode: number; + reason: string; + }; + }; + lastState: {}; + ready: boolean; + restartCount: number; + image: string; + imageID: string; + containerID: string; +} + +@autobind() +export class Pod extends WorkloadKubeObject { + static kind = "Pod" + + spec: { + volumes?: { + name: string; + persistentVolumeClaim: { + claimName: string; + }; + secret: { + secretName: string; + defaultMode: number; + }; + }[]; + initContainers: IPodContainer[]; + containers: IPodContainer[]; + restartPolicy: string; + terminationGracePeriodSeconds: number; + dnsPolicy: string; + serviceAccountName: string; + serviceAccount: string; + priority: number; + priorityClassName: string; + nodeName: string; + nodeSelector?: { + [selector: string]: string; + }; + securityContext: {}; + schedulerName: string; + tolerations: { + key: string; + operator: string; + effect: string; + tolerationSeconds: number; + }[]; + affinity: IAffinity; + } + status: { + phase: string; + conditions: { + type: string; + status: string; + lastProbeTime: number; + lastTransitionTime: string; + }[]; + hostIP: string; + podIP: string; + startTime: string; + initContainerStatuses?: IPodContainerStatus[]; + containerStatuses?: IPodContainerStatus[]; + qosClass: string; + reason?: string; + } + + getInitContainers() { + return this.spec.initContainers || []; + } + + getContainers() { + return this.spec.containers || []; + } + + getAllContainers() { + return this.getContainers().concat(this.getInitContainers()); + } + + getRunningContainers() { + const statuses = this.getContainerStatuses() + return this.getAllContainers().filter(container => { + return statuses.find(status => status.name === container.name && !!status.state["running"]) + } + ) + } + + getContainerStatuses(includeInitContainers = true) { + const statuses: IPodContainerStatus[] = []; + const { containerStatuses, initContainerStatuses } = this.status; + if (containerStatuses) { + statuses.push(...containerStatuses); + } + if (includeInitContainers && initContainerStatuses) { + statuses.push(...initContainerStatuses); + } + return statuses; + } + + getRestartsCount(): number { + const { containerStatuses } = this.status; + if (!containerStatuses) return 0; + return containerStatuses.reduce((count, item) => count + item.restartCount, 0); + } + + getQosClass() { + return this.status.qosClass || ""; + } + + getReason() { + return this.status.reason || ""; + } + + getPriorityClassName() { + return this.spec.priorityClassName || ""; + } + + // Returns one of 5 statuses: Running, Succeeded, Pending, Failed, Evicted + getStatus() { + const phase = this.getStatusPhase(); + const reason = this.getReason(); + const goodConditions = ["Initialized", "Ready"].every(condition => + !!this.getConditions().find(item => item.type === condition && item.status === "True") + ); + if (reason === PodStatus.EVICTED) { + return PodStatus.EVICTED; + } + if (phase === PodStatus.FAILED) { + return PodStatus.FAILED; + } + if (phase === PodStatus.SUCCEEDED) { + return PodStatus.SUCCEEDED; + } + if (phase === PodStatus.RUNNING && goodConditions) { + return PodStatus.RUNNING; + } + return PodStatus.PENDING; + } + + // Returns pod phase or container error if occured + getStatusMessage() { + let message = ""; + const statuses = this.getContainerStatuses(false); // not including initContainers + if (statuses.length) { + statuses.forEach(status => { + const { state } = status; + if (state.waiting) { + const { reason } = state.waiting; + message = reason ? reason : "Waiting"; + } + if (state.terminated) { + const { reason } = state.terminated; + message = reason ? reason : "Terminated"; + } + }) + } + if (this.getReason() === PodStatus.EVICTED) return "Evicted"; + if (message) return message; + return this.getStatusPhase(); + } + + getStatusPhase() { + return this.status.phase; + } + + getConditions() { + return this.status.conditions || []; + } + + getVolumes() { + return this.spec.volumes || []; + } + + getSecrets(): string[] { + return this.getVolumes() + .filter(vol => vol.secret) + .map(vol => vol.secret.secretName); + } + + getNodeSelectors(): string[] { + const { nodeSelector } = this.spec + if (!nodeSelector) return [] + return Object.entries(nodeSelector).map(values => values.join(": ")) + } + + getTolerations() { + return this.spec.tolerations || [] + } + + getAffinity(): IAffinity { + return this.spec.affinity + } + + hasIssues() { + const notReady = !!this.getConditions().find(condition => { + return condition.type == "Ready" && condition.status !== "True" + }); + const crashLoop = !!this.getContainerStatuses().find(condition => { + const waiting = condition.state.waiting + return (waiting && waiting.reason == "CrashLoopBackOff") + }) + return ( + notReady || + crashLoop || + this.getStatusPhase() !== "Running" + ) + } + + getLivenessProbe(container: IPodContainer) { + return this.getProbe(container.livenessProbe); + } + + getReadinessProbe(container: IPodContainer) { + return this.getProbe(container.readinessProbe); + } + + getProbe(probeData: IContainerProbe) { + if (!probeData) return []; + const { + httpGet, exec, tcpSocket, initialDelaySeconds, timeoutSeconds, + periodSeconds, successThreshold, failureThreshold + } = probeData; + const probe = []; + // HTTP Request + if (httpGet) { + const { path, port, host, scheme } = httpGet; + probe.push( + "http-get", + `${scheme.toLowerCase()}://${host || ""}:${port || ""}${path || ""}`, + ); + } + // Command + if (exec && exec.command) { + probe.push(`exec [${exec.command.join(" ")}]`); + } + // TCP Probe + if (tcpSocket && tcpSocket.port) { + probe.push(`tcp-socket :${tcpSocket.port}`); + } + probe.push( + `delay=${initialDelaySeconds || "0"}s`, + `timeout=${timeoutSeconds || "0"}s`, + `period=${periodSeconds || "0"}s`, + `#success=${successThreshold || "0"}`, + `#failure=${failureThreshold || "0"}`, + ); + return probe; + } +} + +export const podsApi = new PodsApi({ + kind: Pod.kind, + apiBase: "/api/v1/pods", + isNamespaced: true, + objectConstructor: Pod, +}); diff --git a/dashboard/client/api/endpoints/podsecuritypolicy.api.ts b/dashboard/client/api/endpoints/podsecuritypolicy.api.ts new file mode 100644 index 0000000000..4f1e807ba5 --- /dev/null +++ b/dashboard/client/api/endpoints/podsecuritypolicy.api.ts @@ -0,0 +1,94 @@ +import { autobind } from "../../utils"; +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class PodSecurityPolicy extends KubeObject { + static kind = "PodSecurityPolicy" + + spec: { + allowPrivilegeEscalation?: boolean; + allowedCSIDrivers?: { + name: string; + }[]; + allowedCapabilities: string[]; + allowedFlexVolumes?: { + driver: string; + }[]; + allowedHostPaths?: { + pathPrefix: string; + readOnly: boolean; + }[]; + allowedProcMountTypes?: string[]; + allowedUnsafeSysctls?: string[]; + defaultAddCapabilities?: string[]; + defaultAllowPrivilegeEscalation?: boolean; + forbiddenSysctls?: string[]; + fsGroup?: { + rule: string; + ranges: { max: number; min: number }[]; + }; + hostIPC?: boolean; + hostNetwork?: boolean; + hostPID?: boolean; + hostPorts?: { + max: number; + min: number; + }[]; + privileged?: boolean; + readOnlyRootFilesystem?: boolean; + requiredDropCapabilities?: string[]; + runAsGroup?: { + ranges: { max: number; min: number }[]; + rule: string; + }; + runAsUser?: { + rule: string; + ranges: { max: number; min: number }[]; + }; + runtimeClass?: { + allowedRuntimeClassNames: string[]; + defaultRuntimeClassName: string; + }; + seLinux?: { + rule: string; + seLinuxOptions: { + level: string; + role: string; + type: string; + user: string; + }; + }; + supplementalGroups?: { + rule: string; + ranges: { max: number; min: number }[]; + }; + volumes?: string[]; + } + + isPrivileged() { + return !!this.spec.privileged; + } + + getVolumes() { + return this.spec.volumes || []; + } + + getRules() { + const { fsGroup, runAsGroup, runAsUser, supplementalGroups, seLinux } = this.spec; + return { + fsGroup: fsGroup ? fsGroup.rule : "", + runAsGroup: runAsGroup ? runAsGroup.rule : "", + runAsUser: runAsUser ? runAsUser.rule : "", + supplementalGroups: supplementalGroups ? supplementalGroups.rule : "", + seLinux: seLinux ? seLinux.rule : "", + }; + } +} + +export const pspApi = new KubeApi({ + kind: PodSecurityPolicy.kind, + apiBase: "/apis/policy/v1beta1/podsecuritypolicies", + isNamespaced: false, + objectConstructor: PodSecurityPolicy, +}); diff --git a/dashboard/client/api/endpoints/replica-set.api.ts b/dashboard/client/api/endpoints/replica-set.api.ts new file mode 100644 index 0000000000..d8ac690ab6 --- /dev/null +++ b/dashboard/client/api/endpoints/replica-set.api.ts @@ -0,0 +1,58 @@ +import get from "lodash/get"; +import { autobind } from "../../utils"; +import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; +import { IPodContainer } from "./pods.api"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class ReplicaSet extends WorkloadKubeObject { + static kind = "ReplicaSet" + + spec: { + replicas?: number; + selector?: { + matchLabels: { + [key: string]: string; + }; + }; + containers?: IPodContainer[]; + template?: { + spec?: { + affinity?: IAffinity; + nodeSelector?: { + [selector: string]: string; + }; + tolerations: { + key: string; + operator: string; + effect: string; + tolerationSeconds: number; + }[]; + containers: IPodContainer[]; + }; + }; + restartPolicy?: string; + terminationGracePeriodSeconds?: number; + dnsPolicy?: string; + schedulerName?: string; + } + status: { + replicas: number; + fullyLabeledReplicas: number; + readyReplicas: number; + availableReplicas: number; + observedGeneration: number; + } + + getImages() { + const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []) + return [...containers].map(container => container.image) + } +} + +export const replicaSetApi = new KubeApi({ + kind: ReplicaSet.kind, + apiBase: "/apis/apps/v1/replicasets", + isNamespaced: true, + objectConstructor: ReplicaSet, +}); diff --git a/dashboard/client/api/endpoints/resource-applier.api.ts b/dashboard/client/api/endpoints/resource-applier.api.ts new file mode 100644 index 0000000000..ffa2852b5e --- /dev/null +++ b/dashboard/client/api/endpoints/resource-applier.api.ts @@ -0,0 +1,26 @@ +import jsYaml from "js-yaml" +import { KubeObject } from "../kube-object"; +import { KubeJsonApiData } from "../kube-json-api"; +import { apiKubeResourceApplier } from "../index"; +import { apiManager } from "../api-manager"; + +export const resourceApplierApi = { + annotations: [ + "kubectl.kubernetes.io/last-applied-configuration" + ], + + async update(resource: object | string): Promise { + if (typeof resource === "string") { + resource = jsYaml.safeLoad(resource); + } + return apiKubeResourceApplier + .post("/stack", { data: resource }) + .then(data => { + const items = data.map(obj => { + const api = apiManager.getApi(obj.metadata.selfLink); + return new api.objectConstructor(obj); + }); + return items.length === 1 ? items[0] : items; + }); + } +}; diff --git a/dashboard/client/api/endpoints/resource-quota.api.ts b/dashboard/client/api/endpoints/resource-quota.api.ts new file mode 100644 index 0000000000..337834f5b0 --- /dev/null +++ b/dashboard/client/api/endpoints/resource-quota.api.ts @@ -0,0 +1,68 @@ +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; +import { KubeJsonApiData } from "../kube-json-api"; + +export interface IResourceQuotaValues { + [quota: string]: string; + + // Compute Resource Quota + "limits.cpu"?: string; + "limits.memory"?: string; + "requests.cpu"?: string; + "requests.memory"?: string; + + // Storage Resource Quota + "requests.storage"?: string; + "persistentvolumeclaims"?: string; + + // Object Count Quota + "count/pods"?: string; + "count/persistentvolumeclaims"?: string; + "count/services"?: string; + "count/secrets"?: string; + "count/configmaps"?: string; + "count/replicationcontrollers"?: string; + "count/deployments.apps"?: string; + "count/replicasets.apps"?: string; + "count/statefulsets.apps"?: string; + "count/jobs.batch"?: string; + "count/cronjobs.batch"?: string; + "count/deployments.extensions"?: string; +} + +export class ResourceQuota extends KubeObject { + static kind = "ResourceQuota" + + constructor(data: KubeJsonApiData) { + super(data); + this.spec = this.spec || {} as any + } + + spec: { + hard: IResourceQuotaValues; + scopeSelector?: { + matchExpressions: { + operator: string; + scopeName: string; + values: string[]; + }[]; + }; + } + + status: { + hard: IResourceQuotaValues; + used: IResourceQuotaValues; + } + + getScopeSelector() { + const { matchExpressions = [] } = this.spec.scopeSelector || {}; + return matchExpressions; + } +} + +export const resourceQuotaApi = new KubeApi({ + kind: ResourceQuota.kind, + apiBase: "/api/v1/resourcequotas", + isNamespaced: true, + objectConstructor: ResourceQuota, +}); diff --git a/dashboard/client/api/endpoints/role-binding.api.ts b/dashboard/client/api/endpoints/role-binding.api.ts new file mode 100644 index 0000000000..438c07f9db --- /dev/null +++ b/dashboard/client/api/endpoints/role-binding.api.ts @@ -0,0 +1,37 @@ +import { autobind } from "../../utils"; +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +export interface IRoleBindingSubject { + kind: string; + name: string; + namespace?: string; + apiGroup?: string; +} + +@autobind() +export class RoleBinding extends KubeObject { + static kind = "RoleBinding" + + subjects?: IRoleBindingSubject[] + roleRef: { + kind: string; + name: string; + apiGroup?: string; + } + + getSubjects() { + return this.subjects || []; + } + + getSubjectNames(): string { + return this.getSubjects().map(subject => subject.name).join(", ") + } +} + +export const roleBindingApi = new KubeApi({ + kind: RoleBinding.kind, + apiBase: "/apis/rbac.authorization.k8s.io/v1/rolebindings", + isNamespaced: true, + objectConstructor: RoleBinding, +}); diff --git a/dashboard/client/api/endpoints/role.api.ts b/dashboard/client/api/endpoints/role.api.ts new file mode 100644 index 0000000000..e59f9cc273 --- /dev/null +++ b/dashboard/client/api/endpoints/role.api.ts @@ -0,0 +1,24 @@ +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +export class Role extends KubeObject { + static kind = "Role" + + rules: { + verbs: string[]; + apiGroups: string[]; + resources: string[]; + resourceNames?: string[]; + }[] + + getRules() { + return this.rules || []; + } +} + +export const roleApi = new KubeApi({ + kind: Role.kind, + apiBase: "/apis/rbac.authorization.k8s.io/v1/roles", + isNamespaced: true, + objectConstructor: Role, +}); diff --git a/dashboard/client/api/endpoints/secret.api.ts b/dashboard/client/api/endpoints/secret.api.ts new file mode 100644 index 0000000000..884552c14e --- /dev/null +++ b/dashboard/client/api/endpoints/secret.api.ts @@ -0,0 +1,51 @@ +import { KubeObject } from "../kube-object"; +import { KubeJsonApiData } from "../kube-json-api"; +import { autobind } from "../../utils"; +import { KubeApi } from "../kube-api"; + +export enum SecretType { + Opaque = "Opaque", + ServiceAccountToken = "kubernetes.io/service-account-token", + Dockercfg = "kubernetes.io/dockercfg", + DockerConfigJson = "kubernetes.io/dockerconfigjson", + BasicAuth = "kubernetes.io/basic-auth", + SSHAuth = "kubernetes.io/ssh-auth", + TLS = "kubernetes.io/tls", + BootstrapToken = "bootstrap.kubernetes.io/token", +} + +export interface ISecretRef { + key?: string; + name: string; +} + +@autobind() +export class Secret extends KubeObject { + static kind = "Secret" + + type: SecretType; + data: { + [prop: string]: string; + token?: string; + } + + constructor(data: KubeJsonApiData) { + super(data); + this.data = this.data || {}; + } + + getKeys(): string[] { + return Object.keys(this.data); + } + + getToken() { + return this.data.token; + } +} + +export const secretsApi = new KubeApi({ + kind: Secret.kind, + apiBase: "/api/v1/secrets", + isNamespaced: true, + objectConstructor: Secret, +}); diff --git a/dashboard/client/api/endpoints/selfsubjectrulesreviews.api.ts b/dashboard/client/api/endpoints/selfsubjectrulesreviews.api.ts new file mode 100644 index 0000000000..755dcfa54b --- /dev/null +++ b/dashboard/client/api/endpoints/selfsubjectrulesreviews.api.ts @@ -0,0 +1,68 @@ +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +export class SelfSubjectRulesReviewApi extends KubeApi { + create({ namespace = "default" }): Promise { + return super.create({}, { + spec: { + namespace + }, + } + ); + } +} + +export interface ISelfSubjectReviewRule { + verbs: string[]; + apiGroups?: string[]; + resources?: string[]; + resourceNames?: string[]; + nonResourceURLs?: string[]; +} + +export class SelfSubjectRulesReview extends KubeObject { + static kind = "SelfSubjectRulesReview" + + spec: { + // fixme: add more types from api docs + namespace?: string; + } + + status: { + resourceRules: ISelfSubjectReviewRule[]; + nonResourceRules: ISelfSubjectReviewRule[]; + incomplete: boolean; + } + + getResourceRules() { + const rules = this.status && this.status.resourceRules || []; + return rules.map(rule => this.normalize(rule)); + } + + getNonResourceRules() { + const rules = this.status && this.status.nonResourceRules || []; + return rules.map(rule => this.normalize(rule)); + } + + protected normalize(rule: ISelfSubjectReviewRule): ISelfSubjectReviewRule { + const { apiGroups = [], resourceNames = [], verbs = [], nonResourceURLs = [], resources = [] } = rule; + return { + apiGroups, + nonResourceURLs, + resourceNames, + verbs, + resources: resources.map((resource, index) => { + const apiGroup = apiGroups.length >= index + 1 ? apiGroups[index] : apiGroups.slice(-1)[0]; + const separator = apiGroup == "" ? "" : "."; + return resource + separator + apiGroup; + }) + } + } +} + +export const selfSubjectRulesReviewApi = new SelfSubjectRulesReviewApi({ + kind: SelfSubjectRulesReview.kind, + apiBase: "/apis/authorization.k8s.io/v1/selfsubjectrulesreviews", + isNamespaced: false, + objectConstructor: SelfSubjectRulesReview, +}); diff --git a/dashboard/client/api/endpoints/service-accounts.api.ts b/dashboard/client/api/endpoints/service-accounts.api.ts new file mode 100644 index 0000000000..94c3a13673 --- /dev/null +++ b/dashboard/client/api/endpoints/service-accounts.api.ts @@ -0,0 +1,30 @@ +import { autobind } from "../../utils"; +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class ServiceAccount extends KubeObject { + static kind = "ServiceAccount"; + + secrets?: { + name: string; + }[] + imagePullSecrets?: { + name: string; + }[] + + getSecrets() { + return this.secrets || []; + } + + getImagePullSecrets() { + return this.imagePullSecrets || []; + } +} + +export const serviceAccountsApi = new KubeApi({ + kind: ServiceAccount.kind, + apiBase: "/api/v1/serviceaccounts", + isNamespaced: true, + objectConstructor: ServiceAccount, +}); diff --git a/dashboard/client/api/endpoints/service.api.ts b/dashboard/client/api/endpoints/service.api.ts new file mode 100644 index 0000000000..a4b088dff7 --- /dev/null +++ b/dashboard/client/api/endpoints/service.api.ts @@ -0,0 +1,75 @@ +import { autobind } from "../../utils"; +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class Service extends KubeObject { + static kind = "Service" + + spec: { + type: string; + clusterIP: string; + externalTrafficPolicy?: string; + loadBalancerIP?: string; + sessionAffinity: string; + selector: { [key: string]: string }; + ports: { name?: string; protocol: string; port: number; targetPort: number }[]; + externalIPs?: string[]; // https://kubernetes.io/docs/concepts/services-networking/service/#external-ips + } + + status: { + loadBalancer?: { + ingress?: { + ip?: string; + hostname?: string; + }[]; + }; + } + + getClusterIp() { + return this.spec.clusterIP; + } + + getExternalIps() { + const lb = this.getLoadBalancer(); + if (lb && lb.ingress) { + return lb.ingress.map(val => val.ip || val.hostname) + } + return this.spec.externalIPs || []; + } + + getType() { + return this.spec.type || "-"; + } + + getSelector(): string[] { + if (!this.spec.selector) return []; + return Object.entries(this.spec.selector).map(val => val.join("=")); + } + + getPorts(): string[] { + const ports = this.spec.ports || []; + return ports.map(({ port, protocol, targetPort }) => { + return `${port}${port === targetPort ? "" : ":" + targetPort}/${protocol}` + }) + } + + getLoadBalancer() { + return this.status.loadBalancer; + } + + isActive() { + return this.getType() !== "LoadBalancer" || this.getExternalIps().length > 0; + } + + getStatus() { + return this.isActive() ? "Active" : "Pending"; + } +} + +export const serviceApi = new KubeApi({ + kind: Service.kind, + apiBase: "/api/v1/services", + isNamespaced: true, + objectConstructor: Service, +}); diff --git a/dashboard/client/api/endpoints/stateful-set.api.ts b/dashboard/client/api/endpoints/stateful-set.api.ts new file mode 100644 index 0000000000..01509aa787 --- /dev/null +++ b/dashboard/client/api/endpoints/stateful-set.api.ts @@ -0,0 +1,84 @@ +import get from "lodash/get"; +import { IPodContainer } from "./pods.api"; +import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; +import { autobind } from "../../utils"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class StatefulSet extends WorkloadKubeObject { + static kind = "StatefulSet" + + spec: { + serviceName: string; + replicas: number; + selector: { + matchLabels: { + [key: string]: string; + }; + }; + template: { + metadata: { + labels: { + app: string; + }; + }; + spec: { + containers: { + name: string; + image: string; + ports: { + containerPort: number; + name: string; + }[]; + volumeMounts: { + name: string; + mountPath: string; + }[]; + }[]; + affinity?: IAffinity; + nodeSelector?: { + [selector: string]: string; + }; + tolerations: { + key: string; + operator: string; + effect: string; + tolerationSeconds: number; + }[]; + }; + }; + volumeClaimTemplates: { + metadata: { + name: string; + }; + spec: { + accessModes: string[]; + resources: { + requests: { + storage: string; + }; + }; + }; + }[]; + } + status: { + observedGeneration: number; + replicas: number; + currentReplicas: number; + currentRevision: string; + updateRevision: string; + collisionCount: number; + } + + getImages() { + const containers: IPodContainer[] = get(this, "spec.template.spec.containers", []) + return [...containers].map(container => container.image) + } +} + +export const statefulSetApi = new KubeApi({ + kind: StatefulSet.kind, + apiBase: "/apis/apps/v1/statefulsets", + isNamespaced: true, + objectConstructor: StatefulSet, +}); diff --git a/dashboard/client/api/endpoints/storage-class.api.ts b/dashboard/client/api/endpoints/storage-class.api.ts new file mode 100644 index 0000000000..c4b0f02dbe --- /dev/null +++ b/dashboard/client/api/endpoints/storage-class.api.ts @@ -0,0 +1,39 @@ +import { autobind } from "../../utils"; +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; + +@autobind() +export class StorageClass extends KubeObject { + static kind = "StorageClass" + + provisioner: string; // e.g. "storage.k8s.io/v1" + mountOptions?: string[]; + volumeBindingMode: string; + reclaimPolicy: string; + parameters: { + [param: string]: string; // every provisioner has own set of these parameters + } + + isDefault() { + const annotations = this.metadata.annotations || {}; + return ( + annotations["storageclass.kubernetes.io/is-default-class"] === "true" || + annotations["storageclass.beta.kubernetes.io/is-default-class"] === "true" + ) + } + + getVolumeBindingMode() { + return this.volumeBindingMode || "-" + } + + getReclaimPolicy() { + return this.reclaimPolicy || "-" + } +} + +export const storageClassApi = new KubeApi({ + kind: StorageClass.kind, + apiBase: "/apis/storage.k8s.io/v1/storageclasses", + isNamespaced: false, + objectConstructor: StorageClass, +}); diff --git a/dashboard/client/api/index.ts b/dashboard/client/api/index.ts new file mode 100644 index 0000000000..edceb5408d --- /dev/null +++ b/dashboard/client/api/index.ts @@ -0,0 +1,43 @@ +import { JsonApi, JsonApiErrorParsed } from "./json-api"; +import { KubeJsonApi } from "./kube-json-api"; +import { Notifications } from "../components/notifications"; +import { clientVars } from "../../server/config"; + +//-- JSON HTTP APIS + +export const apiBase = new JsonApi({ + debug: !clientVars.IS_PRODUCTION, + apiPrefix: clientVars.API_PREFIX.BASE, +}); +export const apiKube = new KubeJsonApi({ + debug: !clientVars.IS_PRODUCTION, + apiPrefix: clientVars.API_PREFIX.KUBE_BASE, +}); +export const apiKubeUsers = new KubeJsonApi({ + debug: !clientVars.IS_PRODUCTION, + apiPrefix: clientVars.API_PREFIX.KUBE_USERS, +}); +export const apiKubeHelm = new KubeJsonApi({ + debug: !clientVars.IS_PRODUCTION, + apiPrefix: clientVars.API_PREFIX.KUBE_HELM, +}); +export const apiKubeResourceApplier = new KubeJsonApi({ + debug: !clientVars.IS_PRODUCTION, + apiPrefix: clientVars.API_PREFIX.KUBE_RESOURCE_APPLIER, +}); + +// Common handler for HTTP api errors +function onApiError(error: JsonApiErrorParsed, res: Response) { + switch (res.status) { + case 403: + error.isUsedForNotification = true; + Notifications.error(error); + break; + } +} + +apiBase.onError.addListener(onApiError); +apiKube.onError.addListener(onApiError); +apiKubeUsers.onError.addListener(onApiError); +apiKubeHelm.onError.addListener(onApiError); +apiKubeResourceApplier.onError.addListener(onApiError); diff --git a/dashboard/client/api/json-api.ts b/dashboard/client/api/json-api.ts new file mode 100644 index 0000000000..abf278497a --- /dev/null +++ b/dashboard/client/api/json-api.ts @@ -0,0 +1,154 @@ +// Base http-service / json-api class + +import { stringify } from "querystring"; +import { EventEmitter } from "../utils/eventEmitter"; +import { cancelableFetch } from "../utils/cancelableFetch"; + +export interface JsonApiData { +} + +export interface JsonApiError { + code?: number; + message?: string; + errors?: { id: string; title: string; status?: number }[]; +} + +export interface JsonApiParams { + query?: { [param: string]: string | number | any }; + data?: D; // request body +} + +export interface JsonApiLog { + method: string; + reqUrl: string; + reqInit: RequestInit; + data?: any; + error?: any; +} + +export interface JsonApiConfig { + apiPrefix: string; + debug?: boolean; +} + +export class JsonApi { + static reqInitDefault: RequestInit = { + headers: { + 'content-type': 'application/json' + } + }; + + static configDefault: Partial = { + debug: false + }; + + constructor(protected config: JsonApiConfig, protected reqInit?: RequestInit) { + this.config = Object.assign({}, JsonApi.configDefault, config); + this.reqInit = Object.assign({}, JsonApi.reqInitDefault, reqInit); + this.parseResponse = this.parseResponse.bind(this); + } + + public onData = new EventEmitter<[D, Response]>(); + public onError = new EventEmitter<[JsonApiErrorParsed, Response]>(); + + get(path: string, params?: P, reqInit: RequestInit = {}) { + return this.request(path, params, { ...reqInit, method: "get" }); + } + + post(path: string, params?: P, reqInit: RequestInit = {}) { + return this.request(path, params, { ...reqInit, method: "post" }); + } + + put(path: string, params?: P, reqInit: RequestInit = {}) { + return this.request(path, params, { ...reqInit, method: "put" }); + } + + patch(path: string, params?: P, reqInit: RequestInit = {}) { + return this.request(path, params, { ...reqInit, method: "patch" }); + } + + del(path: string, params?: P, reqInit: RequestInit = {}) { + return this.request(path, params, { ...reqInit, method: "delete" }); + } + + protected request(path: string, params?: P, init: RequestInit = {}) { + let reqUrl = this.config.apiPrefix + path; + const reqInit: RequestInit = { ...this.reqInit, ...init }; + const { data, query } = params || {} as P; + if (data && !reqInit.body) { + reqInit.body = JSON.stringify(data); + } + if (query) { + const queryString = stringify(query); + reqUrl += (reqUrl.includes("?") ? "&" : "?") + queryString; + } + const infoLog: JsonApiLog = { + method: reqInit.method.toUpperCase(), + reqUrl: reqUrl, + reqInit: reqInit, + }; + return cancelableFetch(reqUrl, reqInit).then(res => { + return this.parseResponse(res, infoLog); + }); + } + + protected parseResponse(res: Response, log: JsonApiLog): Promise { + const { status } = res; + return res.text().then(text => { + let data; + try { + data = text ? JSON.parse(text) : ""; // DELETE-requests might not have response-body + } catch (e) { + data = text; + } + if (status >= 200 && status < 300) { + this.onData.emit(data, res); + this.writeLog({ ...log, data }); + return data; + } + else { + const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + this.onError.emit(error, res); + this.writeLog({ ...log, error }) + throw error; + } + }) + } + + protected parseError(error: JsonApiError | string, res: Response): string[] { + if (typeof error === "string") { + return [error] + } + else if (Array.isArray(error.errors)) { + return error.errors.map(error => error.title) + } + else if (error.message) { + return [error.message] + } + return [res.statusText || "Error!"] + } + + protected writeLog(log: JsonApiLog) { + if (!this.config.debug) return; + const { method, reqUrl, ...params } = log; + let textStyle = 'font-weight: bold;'; + if (params.data) textStyle += 'background: green; color: white;'; + if (params.error) textStyle += 'background: red; color: white;'; + console.log(`%c${method} ${reqUrl}`, textStyle, params); + } +} + +export class JsonApiErrorParsed { + isUsedForNotification = false; + + constructor(private error: JsonApiError | DOMException, private messages: string[]) { + } + + get isAborted() { + return this.error.code === DOMException.ABORT_ERR; + } + + toString() { + return this.messages.join("\n"); + } +} diff --git a/dashboard/client/api/kube-api.ts b/dashboard/client/api/kube-api.ts new file mode 100644 index 0000000000..9ce8d2be94 --- /dev/null +++ b/dashboard/client/api/kube-api.ts @@ -0,0 +1,233 @@ +// Base class for building all kubernetes apis + +import merge from "lodash/merge" +import { stringify } from "querystring"; +import { IKubeObjectConstructor, KubeObject } from "./kube-object"; +import { IKubeObjectRef, KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; +import { apiKube } from "./index"; +import { kubeWatchApi } from "./kube-watch-api"; +import { apiManager } from "./api-manager"; + +export interface IKubeApiOptions { + kind: string; // resource type within api-group, e.g. "Namespace" + apiBase: string; // base api-path for listing all resources, e.g. "/api/v1/pods" + isNamespaced: boolean; + objectConstructor?: IKubeObjectConstructor; + request?: KubeJsonApi; +} + +export interface IKubeApiQueryParams { + watch?: boolean | number; + resourceVersion?: string; + timeoutSeconds?: number; + limit?: number; // doesn't work with ?watch + continue?: string; // might be used with ?limit from second request +} + +export interface IKubeApiLinkRef { + apiPrefix?: string; + apiVersion: string; + resource: string; + name: string; + namespace?: string; +} + +export class KubeApi { + static matcher = /(\/apis?.*?)\/(?:(.*?)\/)?(v.*?)(?:\/namespaces\/(.+?))?\/([^\/]+)(?:\/([^\/?]+))?.*$/ + + static parseApi(apiPath = "") { + apiPath = new URL(apiPath, location.origin).pathname; + const [, apiPrefix, apiGroup = "", apiVersion, namespace, resource, name] = apiPath.match(KubeApi.matcher) || []; + const apiVersionWithGroup = [apiGroup, apiVersion].filter(v => v).join("/"); + const apiBase = [apiPrefix, apiGroup, apiVersion, resource].filter(v => v).join("/"); + return { + apiBase, + apiPrefix, apiGroup, + apiVersion, apiVersionWithGroup, + namespace, resource, name, + } + } + + static createLink(ref: IKubeApiLinkRef): string { + const { apiPrefix = "/apis", resource, apiVersion, name } = ref; + let { namespace } = ref; + if (namespace) { + namespace = `namespaces/${namespace}` + } + return [apiPrefix, apiVersion, namespace, resource, name] + .filter(v => !!v) + .join("/") + } + + static watchAll(...apis: KubeApi[]) { + const disposers = apis.map(api => api.watch()); + return () => disposers.forEach(unwatch => unwatch()); + } + + readonly kind: string + readonly apiBase: string + readonly apiPrefix: string + readonly apiGroup: string + readonly apiVersion: string + readonly apiVersionWithGroup: string + readonly apiResource: string + readonly isNamespaced: boolean + + public objectConstructor: IKubeObjectConstructor; + protected request: KubeJsonApi; + protected resourceVersions = new Map(); + + constructor(protected options: IKubeApiOptions) { + const { + kind, + isNamespaced = false, + objectConstructor = KubeObject as IKubeObjectConstructor, + request = apiKube + } = options || {}; + const { apiBase, apiPrefix, apiGroup, apiVersion, apiVersionWithGroup, resource } = KubeApi.parseApi(options.apiBase); + + this.kind = kind; + this.isNamespaced = isNamespaced; + this.apiBase = apiBase; + this.apiPrefix = apiPrefix; + this.apiGroup = apiGroup; + this.apiVersion = apiVersion; + this.apiVersionWithGroup = apiVersionWithGroup; + this.apiResource = resource; + this.request = request; + this.objectConstructor = objectConstructor; + + this.parseResponse = this.parseResponse.bind(this); + apiManager.registerApi(apiBase, this); + } + + setResourceVersion(namespace = "", newVersion: string) { + this.resourceVersions.set(namespace, newVersion); + } + + getResourceVersion(namespace = "") { + return this.resourceVersions.get(namespace); + } + + async refreshResourceVersion(params?: { namespace: string }) { + return this.list(params, { limit: 1 }); + } + + getUrl({ name = "", namespace = "" } = {}, query?: Partial) { + const { apiPrefix, apiVersionWithGroup, apiResource } = this; + const resourcePath = KubeApi.createLink({ + apiPrefix: apiPrefix, + apiVersion: apiVersionWithGroup, + resource: apiResource, + namespace: this.isNamespaced ? namespace : undefined, + name: name, + }); + return resourcePath + (query ? `?` + stringify(query) : ""); + } + + protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { + const KubeObjectConstructor = this.objectConstructor; + if (KubeObject.isJsonApiData(data)) { + return new KubeObjectConstructor(data); + } + // process items list response + else if (KubeObject.isJsonApiDataList(data)) { + const { apiVersion, items, metadata } = data; + this.setResourceVersion(namespace, metadata.resourceVersion); + this.setResourceVersion("", metadata.resourceVersion); + return items.map(item => new KubeObjectConstructor({ + kind: this.kind, + apiVersion: apiVersion, + ...item, + })) + } + // custom apis might return array for list response, e.g. users, groups, etc. + else if (Array.isArray(data)) { + return data.map(data => new KubeObjectConstructor(data)); + } + return data; + } + + async list({ namespace = "" } = {}, query?: IKubeApiQueryParams): Promise { + return this.request + .get(this.getUrl({ namespace }), { query }) + .then(data => this.parseResponse(data, namespace)); + } + + async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { + return this.request + .get(this.getUrl({ namespace, name }), { query }) + .then(this.parseResponse); + } + + async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + const apiUrl = this.getUrl({ namespace }); + return this.request.post(apiUrl, { + data: merge({ + kind: this.kind, + apiVersion: this.apiVersionWithGroup, + metadata: { + name, + namespace + } + }, data) + }).then(this.parseResponse); + } + + async update({ name = "", namespace = "default" } = {}, data?: Partial): Promise { + const apiUrl = this.getUrl({ namespace, name }); + return this.request + .put(apiUrl, { data }) + .then(this.parseResponse) + } + + async delete({ name = "", namespace = "default" }) { + const apiUrl = this.getUrl({ namespace, name }); + return this.request.del(apiUrl) + } + + getWatchUrl(namespace = "", query: IKubeApiQueryParams = {}) { + return this.getUrl({ namespace }, { + watch: 1, + resourceVersion: this.getResourceVersion(namespace), + ...query, + }) + } + + watch(): () => void { + return kubeWatchApi.subscribe(this); + } +} + +export function lookupApiLink(ref: IKubeObjectRef, parentObject: KubeObject): string { + const { + kind, apiVersion, name, + namespace = parentObject.getNs() + } = ref; + + // search in registered apis by 'kind' & 'apiVersion' + const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == apiVersion) + if (api) { + return api.getUrl({ namespace, name }) + } + + // lookup api by generated resource link + const apiPrefixes = ["/apis", "/api"]; + const resource = kind.toLowerCase() + kind.endsWith("s") ? "es" : "s"; + for (const apiPrefix of apiPrefixes) { + const apiLink = KubeApi.createLink({ apiPrefix, apiVersion, name, namespace, resource }); + if (apiManager.getApi(apiLink)) { + return apiLink; + } + } + + // resolve by kind only (hpa's might use refs to older versions of resources for example) + const apiByKind = apiManager.getApi(api => api.kind === kind); + if (apiByKind) { + return apiByKind.getUrl({ name, namespace }) + } + + // otherwise generate link with default prefix + // resource still might exists in k8s, but api is not registered in the app + return KubeApi.createLink({ apiVersion, name, namespace, resource }) +} diff --git a/dashboard/client/api/kube-json-api.ts b/dashboard/client/api/kube-json-api.ts new file mode 100644 index 0000000000..ad7272cd90 --- /dev/null +++ b/dashboard/client/api/kube-json-api.ts @@ -0,0 +1,68 @@ +import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; + +export interface KubeJsonApiDataList { + kind: string; + apiVersion: string; + items: T[]; + metadata: { + resourceVersion: string; + selfLink: string; + }; +} + +export interface KubeJsonApiData extends JsonApiData { + kind: string; + apiVersion: string; + metadata: { + uid: string; + name: string; + namespace?: string; + creationTimestamp?: string; + resourceVersion: string; + continue?: string; + finalizers?: string[]; + selfLink: string; + labels?: { + [label: string]: string; + }; + annotations?: { + [annotation: string]: string; + }; + }; +} + +export interface IKubeObjectRef { + kind: string; + apiVersion: string; + name: string; + namespace?: string; +} + +export interface KubeJsonApiError extends JsonApiError { + code: number; + status: string; + message?: string; + reason: string; + details: { + name: string; + kind: string; + }; +} + +export interface IKubeJsonApiQuery { + watch?: any; + resourceVersion?: string; + timeoutSeconds?: number; + limit?: number; // doesn't work with ?watch + continue?: string; // might be used with ?limit from second request +} + +export class KubeJsonApi extends JsonApi { + protected parseError(error: KubeJsonApiError | any, res: Response): string[] { + const { status, reason, message } = error; + if (status && reason) { + return [message || `${status}: ${reason}`]; + } + return super.parseError(error, res); + } +} diff --git a/dashboard/client/api/kube-object.ts b/dashboard/client/api/kube-object.ts new file mode 100644 index 0000000000..2d7a7bf72a --- /dev/null +++ b/dashboard/client/api/kube-object.ts @@ -0,0 +1,142 @@ +// Base class for all kubernetes objects + +import moment from "moment"; +import { KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; +import { autobind, formatDuration } from "../utils"; +import { ItemObject } from "../item.store"; +import { apiKube } from "./index"; +import { resourceApplierApi } from "./endpoints/resource-applier.api"; + +export type IKubeObjectConstructor = (new (data: KubeJsonApiData | any) => T) & { + kind?: string; +}; + +export interface IKubeObjectMetadata { + uid: string; + name: string; + namespace?: string; + creationTimestamp: string; + resourceVersion: string; + selfLink: string; + deletionTimestamp?: string; + finalizers?: string[]; + continue?: string; // provided when used "?limit=" query param to fetch objects list + labels?: { + [label: string]: string; + }; + annotations?: { + [annotation: string]: string; + }; +} + +export type IKubeMetaField = keyof KubeObject["metadata"]; + +@autobind() +export class KubeObject implements ItemObject { + static readonly kind: string; + + static create(data: any) { + return new KubeObject(data); + } + + static isNonSystem(item: KubeJsonApiData | KubeObject) { + return !item.metadata.name.startsWith("system:"); + } + + static isJsonApiData(object: any): object is KubeJsonApiData { + return !object.items && object.metadata; + } + + static isJsonApiDataList(object: any): object is KubeJsonApiDataList { + return object.items && object.metadata; + } + + static stringifyLabels(labels: { [name: string]: string }): string[] { + if (!labels) return []; + return Object.entries(labels).map(([name, value]) => `${name}=${value}`) + } + + constructor(data: KubeJsonApiData) { + Object.assign(this, data); + } + + apiVersion: string + kind: string + metadata: IKubeObjectMetadata; + + get selfLink() { + return this.metadata.selfLink + } + + getId() { + return this.metadata.uid; + } + + getResourceVersion() { + return this.metadata.resourceVersion; + } + + getName() { + return this.metadata.name; + } + + getNs() { + // avoid "null" serialization via JSON.stringify when post data + return this.metadata.namespace || undefined; + } + + // todo: refactor with named arguments + getAge(humanize = true, compact = true, fromNow = false) { + if (fromNow) { + return moment(this.metadata.creationTimestamp).fromNow(); + } + const diff = new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime(); + if (humanize) { + return formatDuration(diff, compact); + } + return diff; + } + + getFinalizers(): string[] { + return this.metadata.finalizers || []; + } + + getLabels(): string[] { + return KubeObject.stringifyLabels(this.metadata.labels); + } + + getAnnotations(): string[] { + const labels = KubeObject.stringifyLabels(this.metadata.annotations); + return labels.filter(label => { + const skip = resourceApplierApi.annotations.some(key => label.startsWith(key)); + return !skip; + }) + } + + getSearchFields() { + const { getName, getId, getNs, getAnnotations, getLabels } = this + return [ + getName(), + getNs(), + getId(), + ...getLabels(), + ...getAnnotations(), + ] + } + + toPlainObject(): object { + return JSON.parse(JSON.stringify(this)); + } + + // use unified resource-applier api for updating all k8s objects + async update(data: Partial) { + return resourceApplierApi.update({ + ...this.toPlainObject(), + ...data, + }); + } + + delete() { + return apiKube.del(this.selfLink); + } +} \ No newline at end of file diff --git a/dashboard/client/api/kube-watch-api.ts b/dashboard/client/api/kube-watch-api.ts new file mode 100644 index 0000000000..b6abe9fb18 --- /dev/null +++ b/dashboard/client/api/kube-watch-api.ts @@ -0,0 +1,151 @@ +// Kubernetes watch-api consumer + +import { computed, observable, reaction } from "mobx"; +import { stringify } from "querystring" +import { autobind, EventEmitter, interval } from "../utils"; +import { KubeJsonApiData } from "./kube-json-api"; +import { IKubeWatchEvent, IKubeWatchRouteEvent, IKubeWatchRouteQuery } from "../../server/common/kubewatch"; +import { KubeObjectStore } from "../kube-object.store"; +import { KubeApi } from "./kube-api"; +import { configStore } from "../config.store"; +import { apiManager } from "./api-manager"; + +export { + IKubeWatchEvent +} + +@autobind() +export class KubeWatchApi { + protected evtSource: EventSource; + protected onData = new EventEmitter<[IKubeWatchEvent]>(); + protected apiUrl = configStore.apiPrefix.BASE + "/watch"; + protected subscribers = observable.map(); + protected reconnectInterval = interval(60 * 5, this.reconnect); // background reconnect every 5min + protected reconnectTimeoutMs = 5000; + protected maxReconnectsOnError = 10; + protected reconnectAttempts = this.maxReconnectsOnError; + + constructor() { + reaction(() => this.activeApis, () => this.connect(), { + fireImmediately: true, + delay: 500, + }); + } + + @computed get activeApis() { + return Array.from(this.subscribers.keys()); + } + + getSubscribersCount(api: KubeApi) { + return this.subscribers.get(api) || 0; + } + + subscribe(...apis: KubeApi[]) { + apis.forEach(api => { + this.subscribers.set(api, this.getSubscribersCount(api) + 1); + }); + return () => apis.forEach(api => { + const count = this.getSubscribersCount(api) - 1; + if (count <= 0) this.subscribers.delete(api); + else this.subscribers.set(api, count); + }); + } + + protected getQuery(): Partial { + const { isClusterAdmin, allowedNamespaces } = configStore; + return { + api: this.activeApis.map(api => { + if (isClusterAdmin) return api.getWatchUrl(); + return allowedNamespaces.map(namespace => api.getWatchUrl(namespace)) + }).flat() + } + } + + // todo: maybe switch to websocket to avoid often reconnects + @autobind() + protected connect() { + if (this.evtSource) this.disconnect(); // close previous connection + if (!this.activeApis.length) { + return; + } + const query = this.getQuery(); + const apiUrl = this.apiUrl + "?" + stringify(query); + this.evtSource = new EventSource(apiUrl); + this.evtSource.onmessage = this.onMessage; + this.evtSource.onerror = this.onError; + this.writeLog("CONNECTING", query.api); + } + + reconnect() { + if (!this.evtSource || this.evtSource.readyState !== EventSource.OPEN) { + this.reconnectAttempts = this.maxReconnectsOnError; + this.connect(); + } + } + + protected disconnect() { + if (!this.evtSource) return; + this.evtSource.close(); + this.evtSource.onmessage = null; + this.evtSource = null; + } + + protected onMessage(evt: MessageEvent) { + if (!evt.data) return; + const data = JSON.parse(evt.data); + if ((data as IKubeWatchEvent).object) { + this.onData.emit(data); + } + else { + this.onRouteEvent(data); + } + } + + protected async onRouteEvent({ type, url }: IKubeWatchRouteEvent) { + if (type === "STREAM_END") { + this.disconnect(); + const { apiBase, namespace } = KubeApi.parseApi(url); + const api = apiManager.getApi(apiBase); + if (api) { + await api.refreshResourceVersion({ namespace }); + this.reconnect(); + } + } + } + + protected onError(evt: MessageEvent) { + const { reconnectAttempts: attemptsRemain, reconnectTimeoutMs } = this; + if (evt.eventPhase === EventSource.CLOSED) { + if (attemptsRemain > 0) { + this.reconnectAttempts--; + setTimeout(() => this.connect(), reconnectTimeoutMs); + } + } + } + + protected writeLog(...data: any[]) { + if (configStore.isDevelopment) { + console.log('%cKUBE-WATCH-API:', `font-weight: bold`, ...data); + } + } + + addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) { + const listener = (evt: IKubeWatchEvent) => { + const { selfLink, namespace, resourceVersion } = evt.object.metadata; + const api = apiManager.getApi(selfLink); + api.setResourceVersion(namespace, resourceVersion); + api.setResourceVersion("", resourceVersion); + if (store == apiManager.getStore(api)) { + callback(evt); + } + }; + this.onData.addListener(listener); + return () => this.onData.removeListener(listener); + } + + reset() { + this.subscribers.clear(); + } +} + +export const kubeWatchApi = new KubeWatchApi(); diff --git a/dashboard/client/api/terminal-api.ts b/dashboard/client/api/terminal-api.ts new file mode 100644 index 0000000000..46337cccd7 --- /dev/null +++ b/dashboard/client/api/terminal-api.ts @@ -0,0 +1,172 @@ +import { stringify } from "querystring"; +import { autobind, base64, EventEmitter, interval } from "../utils"; +import { WebSocketApi } from "./websocket-api"; +import { configStore } from "../config.store"; +import isEqual from "lodash/isEqual" + +export enum TerminalChannels { + STDIN = 0, + STDOUT = 1, + STDERR = 2, + TERMINAL_SIZE = 4, + TOKEN = 9, +} + +enum TerminalColor { + RED = "\u001b[31m", + GREEN = "\u001b[32m", + YELLOW = "\u001b[33m", + BLUE = "\u001b[34m", + MAGENTA = "\u001b[35m", + CYAN = "\u001b[36m", + GRAY = "\u001b[90m", + LIGHT_GRAY = "\u001b[37m", + NO_COLOR = "\u001b[0m", +} + +export interface ITerminalApiOptions { + id: string; + node?: string; + colorTheme?: "light" | "dark"; +} + +export class TerminalApi extends WebSocketApi { + protected size: { Width: number; Height: number }; + protected currentToken: string; + protected tokenInterval = interval(60, this.sendNewToken); // refresh every minute + + public onReady = new EventEmitter<[]>(); + public isReady = false; + + constructor(protected options: ITerminalApiOptions) { + super({ + logging: configStore.isDevelopment, + flushOnOpen: false, + pingIntervalSeconds: 30, + }); + } + + async getUrl(token: string) { + const { hostname, protocol } = location; + const { id, node } = this.options; + const apiPrefix = configStore.apiPrefix.TERMINAL; + const wss = `ws${protocol === "https:" ? "s" : ""}://`; + const queryParams = { token, id }; + if (node) { + Object.assign(queryParams, { + node: node, + type: "node" + }); + } + + return `${wss}${hostname}${configStore.serverPort}${apiPrefix}/api?${stringify(queryParams)}`; + } + + async connect() { + const token = await configStore.getToken(); + const apiUrl = await this.getUrl(token); + const { colorTheme } = this.options; + this.emitStatus("Connecting...", { + color: colorTheme == "light" ? TerminalColor.GRAY : TerminalColor.LIGHT_GRAY + }); + this.onData.addListener(this._onReady, { prepend: true }); + this.currentToken = token; + this.tokenInterval.start(); + return super.connect(apiUrl); + } + + @autobind() + async sendNewToken() { + const token = await configStore.getToken(); + if (!this.isReady || token == this.currentToken) return; + this.sendCommand(token, TerminalChannels.TOKEN); + this.currentToken = token; + } + + destroy() { + if (!this.socket) return; + const exitCode = String.fromCharCode(4); // ctrl+d + this.sendCommand(exitCode); + this.tokenInterval.stop(); + setTimeout(() => super.destroy(), 2000); + } + + removeAllListeners() { + super.removeAllListeners(); + this.onReady.removeAllListeners(); + } + + @autobind() + protected _onReady(data: string) { + if (!data) return; + this.isReady = true; + this.onReady.emit(); + this.onData.removeListener(this._onReady); + this.flush(); + this.onData.emit(data); // re-emit data + return false; // prevent calling rest of listeners + } + + reconnect() { + const { reconnectDelaySeconds } = this.params; + if (reconnectDelaySeconds) { + this.emitStatus(`Reconnect in ${reconnectDelaySeconds} seconds`, { + color: TerminalColor.YELLOW, + showTime: true, + }); + } + super.reconnect(); + } + + sendCommand(key: string, channel = TerminalChannels.STDIN) { + return this.send(channel + base64.encode(key)); + } + + sendTerminalSize(cols: number, rows: number) { + const newSize = { Width: cols, Height: rows }; + if (!isEqual(this.size, newSize)) { + this.sendCommand(JSON.stringify(newSize), TerminalChannels.TERMINAL_SIZE); + this.size = newSize; + } + } + + protected parseMessage(data: string) { + data = data.substr(1); // skip channel + return base64.decode(data); + } + + protected _onOpen(evt: Event) { + // Client should send terminal size in special channel 4, + // But this size will be changed by terminal.fit() + this.sendTerminalSize(120, 80); + super._onOpen(evt); + } + + protected _onClose(evt: CloseEvent) { + const { code, reason, wasClean } = evt; + if (code !== 1000 || !wasClean) { + this.emitStatus("\r\n"); + this.emitError(`Closed by "${reason}" (code: ${code}) at ${new Date()}.`); + } + super._onClose(evt); + this.isReady = false; + } + + protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) { + const { color, showTime } = options; + if (color) { + data = `${color}${data}${TerminalColor.NO_COLOR}`; + } + let time; + if (showTime) { + time = (new Date()).toLocaleString() + " "; + } + this.onData.emit(`${showTime ? time : ""}${data}\r\n`); + } + + protected emitError(error: string) { + this.emitStatus(error, { + color: TerminalColor.RED + }); + } +} diff --git a/dashboard/client/api/websocket-api.ts b/dashboard/client/api/websocket-api.ts new file mode 100644 index 0000000000..daf46b286c --- /dev/null +++ b/dashboard/client/api/websocket-api.ts @@ -0,0 +1,169 @@ +import { observable } from "mobx"; +import { EventEmitter } from "../utils/eventEmitter"; + +interface IParams { + url?: string; // connection url, starts with ws:// or wss:// + autoConnect?: boolean; // auto-connect in constructor + flushOnOpen?: boolean; // flush pending commands on open socket + reconnectDelaySeconds?: number; // reconnect timeout in case of error (0 - don't reconnect) + pingIntervalSeconds?: number; // send ping message for keeping connection alive in some env, e.g. AWS (0 - don't ping) + logging?: boolean; // show logs in console +} + +interface IMessage { + id: string; + data: string; +} + +export enum WebSocketApiState { + PENDING = -1, + OPEN, + CONNECTING, + RECONNECTING, + CLOSED, +} + +export class WebSocketApi { + protected socket: WebSocket; + protected pendingCommands: IMessage[] = []; + protected reconnectTimer: any; + protected pingTimer: any; + protected pingMessage = "PING"; + + @observable readyState = WebSocketApiState.PENDING; + + public onOpen = new EventEmitter<[]>(); + public onData = new EventEmitter<[string]>(); + public onClose = new EventEmitter<[]>(); + + static defaultParams: Partial = { + autoConnect: true, + logging: false, + reconnectDelaySeconds: 10, + pingIntervalSeconds: 0, + flushOnOpen: true, + }; + + constructor(protected params: IParams) { + this.params = Object.assign({}, WebSocketApi.defaultParams, params); + const { autoConnect, pingIntervalSeconds } = this.params; + if (autoConnect) { + setTimeout(() => this.connect()); + } + if (pingIntervalSeconds) { + this.pingTimer = setInterval(() => this.ping(), pingIntervalSeconds * 1000); + } + } + + get isConnected() { + const state = this.socket ? this.socket.readyState : -1; + return state === WebSocket.OPEN && this.isOnline; + } + + get isOnline() { + return navigator.onLine; + } + + setParams(params: Partial) { + Object.assign(this.params, params); + } + + connect(url = this.params.url) { + if (this.socket) { + this.socket.close(); // close previous connection first + } + this.socket = new WebSocket(url); + this.socket.onopen = this._onOpen.bind(this); + this.socket.onmessage = this._onMessage.bind(this); + this.socket.onerror = this._onError.bind(this); + this.socket.onclose = this._onClose.bind(this); + this.readyState = WebSocketApiState.CONNECTING; + } + + ping() { + if (!this.isConnected) return; + this.send(this.pingMessage); + } + + reconnect() { + const { reconnectDelaySeconds } = this.params; + if (!reconnectDelaySeconds) return; + this.writeLog('reconnect after', reconnectDelaySeconds + "ms"); + this.reconnectTimer = setTimeout(() => this.connect(), reconnectDelaySeconds * 1000); + this.readyState = WebSocketApiState.RECONNECTING; + } + + destroy() { + if (!this.socket) return; + this.socket.close(); + this.socket = null; + this.pendingCommands = []; + this.removeAllListeners(); + clearTimeout(this.reconnectTimer); + clearInterval(this.pingTimer); + this.readyState = WebSocketApiState.PENDING; + } + + removeAllListeners() { + this.onOpen.removeAllListeners(); + this.onData.removeAllListeners(); + this.onClose.removeAllListeners(); + } + + send(command: string) { + const msg: IMessage = { + id: (Math.random() * Date.now()).toString(16).replace(".", ""), + data: command, + }; + if (this.isConnected) { + this.socket.send(msg.data); + } + else { + this.pendingCommands.push(msg); + } + } + + protected flush() { + this.pendingCommands.forEach(msg => this.send(msg.data)); + this.pendingCommands.length = 0; + } + + protected parseMessage(data: string) { + return data; + } + + protected _onOpen(evt: Event) { + this.onOpen.emit(); + if (this.params.flushOnOpen) this.flush(); + this.readyState = WebSocketApiState.OPEN; + this.writeLog('%cOPEN', 'color:green;font-weight:bold;', evt); + } + + protected _onMessage(evt: MessageEvent) { + const data = this.parseMessage(evt.data); + this.onData.emit(data); + this.writeLog('%cMESSAGE', 'color:black;font-weight:bold;', data); + } + + protected _onError(evt: Event) { + this.writeLog('%cERROR', 'color:red;font-weight:bold;', evt) + } + + protected _onClose(evt: CloseEvent) { + const error = evt.code !== 1000 || !evt.wasClean; + if (error) { + this.reconnect(); + } + else { + this.readyState = WebSocketApiState.CLOSED; + this.onClose.emit(); + } + this.writeLog('%cCLOSE', `color:${error ? "red" : "black"};font-weight:bold;`, evt); + } + + protected writeLog(...data: any[]) { + if (this.params.logging) { + console.log(...data); + } + } +} diff --git a/dashboard/client/api/workload-kube-object.ts b/dashboard/client/api/workload-kube-object.ts new file mode 100644 index 0000000000..087a5b2f7a --- /dev/null +++ b/dashboard/client/api/workload-kube-object.ts @@ -0,0 +1,100 @@ +import get from "lodash/get"; +import { IKubeObjectMetadata, KubeObject } from "./kube-object"; + +interface IToleration { + key?: string; + operator?: string; + effect?: string; + tolerationSeconds?: number; +} + +interface IMatchExpression { + key: string; + operator: string; + values: string[]; +} + +interface INodeAffinity { + nodeSelectorTerms?: { + matchExpressions: IMatchExpression[]; + }[]; + weight: number; + preference: { + matchExpressions: IMatchExpression[]; + }; +} + +interface IPodAffinity { + labelSelector: { + matchExpressions: IMatchExpression[]; + }; + topologyKey: string; +} + +export interface IAffinity { + nodeAffinity?: { + requiredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[]; + preferredDuringSchedulingIgnoredDuringExecution?: INodeAffinity[]; + }; + podAffinity?: { + requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; + preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; + }; + podAntiAffinity?: { + requiredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; + preferredDuringSchedulingIgnoredDuringExecution?: IPodAffinity[]; + }; +} + +export class WorkloadKubeObject extends KubeObject { + metadata: IKubeObjectMetadata & { + ownerReferences?: { + apiVersion: string; + kind: string; + name: string; + uid: string; + controller: boolean; + blockOwnerDeletion: boolean; + }[]; + } + + // fixme: add type + spec: any; + + getOwnerRefs() { + const refs = this.metadata.ownerReferences || []; + return refs.map(ownerRef => ({ + ...ownerRef, + namespace: this.getNs(), + })) + } + + getSelectors(): string[] { + const selector = this.spec.selector; + return KubeObject.stringifyLabels(selector ? selector.matchLabels : null); + } + + getNodeSelectors(): string[] { + const nodeSelector = get(this, "spec.template.spec.nodeSelector"); + return KubeObject.stringifyLabels(nodeSelector); + } + + getTemplateLabels(): string[] { + const labels = get(this, "spec.template.metadata.labels"); + return KubeObject.stringifyLabels(labels); + } + + getTolerations(): IToleration[] { + return get(this, "spec.template.spec.tolerations", []) + } + + getAffinity(): IAffinity { + return get(this, "spec.template.spec.affinity") + } + + getAffinityNumber() { + const affinity = this.getAffinity() + if (!affinity) return 0 + return Object.keys(affinity).length + } +} \ No newline at end of file diff --git a/dashboard/client/browser-check.tsx b/dashboard/client/browser-check.tsx new file mode 100644 index 0000000000..51b85f2046 --- /dev/null +++ b/dashboard/client/browser-check.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { Notifications } from "./components/notifications"; +import { Trans } from "@lingui/macro"; + +export function browserCheck() { + const ua = window.navigator.userAgent + const msie = ua.indexOf('MSIE ') // IE < 11 + const trident = ua.indexOf('Trident/') // IE 11 + const edge = ua.indexOf('Edge') // Edge + if (msie > 0 || trident > 0 || edge > 0) { + Notifications.info( +

+ + Your browser does not support all Kontena Lens features. {" "} + Please consider using another browser. + +

+ ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+404/index.ts b/dashboard/client/components/+404/index.ts new file mode 100644 index 0000000000..7314f53562 --- /dev/null +++ b/dashboard/client/components/+404/index.ts @@ -0,0 +1 @@ +export * from "./not-found" diff --git a/dashboard/client/components/+404/not-found.tsx b/dashboard/client/components/+404/not-found.tsx new file mode 100644 index 0000000000..de4616e80e --- /dev/null +++ b/dashboard/client/components/+404/not-found.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import { Trans } from "@lingui/macro"; +import { MainLayout } from "../layout/main-layout"; + +export class NotFound extends React.Component { + render() { + return ( + +

+ Page not found +

+
+ ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+apps-helm-charts/helm-chart-details.scss b/dashboard/client/components/+apps-helm-charts/helm-chart-details.scss new file mode 100644 index 0000000000..c6d0a73786 --- /dev/null +++ b/dashboard/client/components/+apps-helm-charts/helm-chart-details.scss @@ -0,0 +1,53 @@ +.HelmChartDetails { + .intro-logo { + margin-right: $margin * 2; + background: $helmLogoBackground; + border-radius: $radius; + max-width: 150px; + max-height: 100px; + padding: $padding; + box-sizing: content-box; + } + + .intro-contents { + .description { + font-weight: bold; + color: $textColorAccent; + padding-bottom: $padding; + + .Button { + padding-left: $padding * 3; + padding-right: $padding * 3; + margin-left: $margin * 2; + align-self: flex-start; + } + } + + .version { + .Select { + width: 80px; + min-width: 80px; + white-space: nowrap; + } + + .Icon { + margin-right: $margin; + } + } + + .maintainers { + a { + display: inline-block; + margin-right: $margin; + } + } + + .DrawerItem { + align-items: center; + } + } + + .chart-description { + margin-top: $margin * 2; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+apps-helm-charts/helm-chart-details.tsx b/dashboard/client/components/+apps-helm-charts/helm-chart-details.tsx new file mode 100644 index 0000000000..32c6cd6a3e --- /dev/null +++ b/dashboard/client/components/+apps-helm-charts/helm-chart-details.tsx @@ -0,0 +1,138 @@ +import "./helm-chart-details.scss"; + +import React, { Component } from "react"; +import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; +import { t, Trans } from "@lingui/macro"; +import { autorun, observable } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { Drawer, DrawerItem } from "../drawer"; +import { autobind, stopPropagation } from "../../utils"; +import { MarkdownViewer } from "../markdown-viewer"; +import { Spinner } from "../spinner"; +import { CancelablePromise } from "../../utils/cancelableFetch"; +import { Button } from "../button"; +import { Select, SelectOption } from "../select"; +import { createInstallChartTab } from "../dock/install-chart.store"; +import { Badge } from "../badge"; +import { _i18n } from "../../i18n"; + +interface Props { + chart: HelmChart; + hideDetails(): void; +} + +@observer +export class HelmChartDetails extends Component { + @observable chartVersions: HelmChart[]; + @observable selectedChart: HelmChart; + @observable description: string = null; + + private chartPromise: CancelablePromise<{ readme: string; versions: HelmChart[] }>; + + @disposeOnUnmount + chartSelector = autorun(async () => { + if (!this.props.chart) return; + this.chartVersions = null; + this.selectedChart = null; + this.description = null; + this.loadChartData(); + this.chartPromise.then(data => { + this.description = data.readme; + this.chartVersions = data.versions; + this.selectedChart = data.versions[0]; + }); + }); + + loadChartData(version?: string) { + const { chart: { name, repo } } = this.props; + if (this.chartPromise) this.chartPromise.cancel(); + this.chartPromise = helmChartsApi.get(repo, name, version); + } + + @autobind() + onVersionChange(opt: SelectOption) { + const version = opt.value; + this.selectedChart = this.chartVersions.find(chart => chart.version === version); + this.description = null; + this.loadChartData(version); + this.chartPromise.then(data => { + this.description = data.readme + }); + } + + @autobind() + install() { + createInstallChartTab(this.selectedChart); + this.props.hideDetails() + } + + renderIntroduction() { + const { selectedChart, chartVersions, onVersionChange } = this; + const placeholder = require("./helm-placeholder.svg"); + return ( +
+ event.currentTarget.src = placeholder} + /> +
+
+ {selectedChart.getDescription()} +
+ + ) => `${value.revision} - ${value.chart}`} + onChange={({ value }: SelectOption) => this.revision = value} + /> +
+ ) + } + + render() { + const { ...dialogProps } = this.props; + const releaseName = this.release ? this.release.getName() : ""; + const header =
Rollback {releaseName}
+ return ( + + + Rollback} + next={this.rollback} + loading={this.isLoading} + > + {this.renderContent()} + + + + ) + } +} diff --git a/dashboard/client/components/+apps-releases/release.mixins.scss b/dashboard/client/components/+apps-releases/release.mixins.scss new file mode 100644 index 0000000000..28f883cc99 --- /dev/null +++ b/dashboard/client/components/+apps-releases/release.mixins.scss @@ -0,0 +1,25 @@ +$release-status-color-list: ( + deployed: $colorSuccess, + failed: $colorError, + deleting: $colorWarning, + pendingInstall: $colorInfo, + pendingUpgrade: $colorInfo, + pendingRollback: $colorInfo, +); + +@mixin release-status-bgs { + @each $status, $color in $release-status-color-list { + &.#{$status} { + color: white; + background: $color; + } + } +} + +@mixin release-status-colors { + @each $status, $color in $release-status-color-list { + &.#{$status} { + color: $color; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+apps-releases/release.route.ts b/dashboard/client/components/+apps-releases/release.route.ts new file mode 100644 index 0000000000..673a875b08 --- /dev/null +++ b/dashboard/client/components/+apps-releases/release.route.ts @@ -0,0 +1,14 @@ +import { RouteProps } from "react-router" +import { appsRoute } from "../+apps/apps.route"; +import { buildURL } from "../../navigation"; + +export const releaseRoute: RouteProps = { + path: appsRoute.path + "/releases/:namespace?/:name?" +} + +export interface IReleaseRouteParams { + name?: string; + namespace?: string; +} + +export const releaseURL = buildURL(releaseRoute.path); diff --git a/dashboard/client/components/+apps-releases/release.store.ts b/dashboard/client/components/+apps-releases/release.store.ts new file mode 100644 index 0000000000..6ef02fca6b --- /dev/null +++ b/dashboard/client/components/+apps-releases/release.store.ts @@ -0,0 +1,112 @@ +import isEqual from "lodash/isEqual"; +import { action, observable, when, IReactionDisposer, reaction } from "mobx"; +import { autobind } from "../../utils"; +import { HelmRelease, helmReleasesApi, IReleaseCreatePayload, IReleaseUpdatePayload } from "../../api/endpoints/helm-releases.api"; +import { ItemStore } from "../../item.store"; +import { configStore } from "../../config.store"; +import { secretsStore } from "../+config-secrets/secrets.store"; +import { Secret } from "../../api/endpoints"; + +@autobind() +export class ReleaseStore extends ItemStore { + @observable releaseSecrets: Secret[] = []; + @observable secretWatcher: IReactionDisposer; + + constructor() { + super(); + when(() => secretsStore.isLoaded, () => { + this.releaseSecrets = this.getReleaseSecrets(); + }); + } + + watch() { + this.secretWatcher = reaction(() => secretsStore.items.toJS(), () => { + if (this.isLoading) return; + const secrets = this.getReleaseSecrets(); + const amountChanged = secrets.length !== this.releaseSecrets.length; + const labelsChanged = this.releaseSecrets.some(item => { + const secret = secrets.find(secret => secret.getId() == item.getId()); + if (!secret) return; + return !isEqual(item.getLabels(), secret.getLabels()); + }); + if (amountChanged || labelsChanged) { + this.loadAll(); + } + this.releaseSecrets = [...secrets]; + }) + } + + unwatch() { + this.secretWatcher(); + } + + getReleaseSecrets() { + return secretsStore.getByLabel({ owner: "helm" }); + } + + getReleaseSecret(release: HelmRelease) { + const labels = { + owner: "helm", + name: release.getName() + } + return secretsStore.getByLabel(labels) + .filter(secret => secret.getNs() == release.getNs())[0]; + } + + @action + async loadAll() { + this.isLoading = true; + let items; + try { + const { isClusterAdmin, allowedNamespaces } = configStore; + items = await this.loadItems(!isClusterAdmin ? allowedNamespaces : null); + } finally { + if (items) { + items = this.sortItems(items); + this.items.replace(items); + } + this.isLoaded = true; + this.isLoading = false; + } + } + + async loadItems(namespaces?: string[]) { + if (!namespaces) { + return helmReleasesApi.list(); + } + else { + return Promise + .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) + .then(items => items.flat()); + } + } + + async create(payload: IReleaseCreatePayload) { + const response = await helmReleasesApi.create(payload); + if (this.isLoaded) this.loadAll(); + return response; + } + + async update(name: string, namespace: string, payload: IReleaseUpdatePayload) { + const response = await helmReleasesApi.update(name, namespace, payload); + if (this.isLoaded) this.loadAll(); + return response; + } + + async rollback(name: string, namespace: string, revision: number) { + const response = await helmReleasesApi.rollback(name, namespace, revision); + if (this.isLoaded) this.loadAll(); + return response; + } + + async remove(release: HelmRelease) { + return super.removeItem(release, () => helmReleasesApi.delete(release.getName(), release.getNs())); + } + + async removeSelectedItems() { + if (!this.selectedItems.length) return; + return Promise.all(this.selectedItems.map(this.remove)); + } +} + +export const releaseStore = new ReleaseStore(); diff --git a/dashboard/client/components/+apps-releases/releases.scss b/dashboard/client/components/+apps-releases/releases.scss new file mode 100644 index 0000000000..0cc1a2f07a --- /dev/null +++ b/dashboard/client/components/+apps-releases/releases.scss @@ -0,0 +1,19 @@ +@import "./release.mixins.scss"; + +.HelmReleases { + .TableCell { + &.status { + @include release-status-colors; + } + + &.version { + > .Icon { + margin-left: $margin; + + &.new-version { + color: $colorInfo; + } + } + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+apps-releases/releases.tsx b/dashboard/client/components/+apps-releases/releases.tsx new file mode 100644 index 0000000000..558aee7d36 --- /dev/null +++ b/dashboard/client/components/+apps-releases/releases.tsx @@ -0,0 +1,183 @@ +import "./releases.scss"; + +import React, { Component } from "react"; +import kebabCase from "lodash/kebabCase"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { RouteComponentProps } from "react-router"; +import { autobind, interval } from "../../utils"; +import { releaseStore } from "./release.store"; +import { helmChartStore } from "../+apps-helm-charts/helm-chart.store"; +import { IReleaseRouteParams, releaseURL } from "./release.route"; +import { HelmRelease } from "../../api/endpoints/helm-releases.api"; +import { ReleaseDetails } from "./release-details"; +import { ReleaseRollbackDialog } from "./release-rollback-dialog"; +import { navigation } from "../../navigation"; +import { ItemListLayout } from "../item-object-list/item-list-layout"; +import { HelmReleaseMenu } from "./release-menu"; +import { Icon } from "../icon"; +import { secretsStore } from "../+config-secrets/secrets.store"; +import { when } from "mobx"; + +enum sortBy { + name = "name", + namespace = "namespace", + revision = "revision", + chart = "chart", + status = "status", + updated = "update" +} + +interface Props extends RouteComponentProps { +} + +@observer +export class HelmReleases extends Component { + private versionsWatcher = interval(3600, this.checkVersions); + + componentDidMount() { + // Watch for secrets associated with releases and react to their changes + releaseStore.watch(); + this.versionsWatcher.start(); + when(() => releaseStore.isLoaded, this.checkVersions); + } + + componentWillUnmount() { + releaseStore.unwatch(); + this.versionsWatcher.stop(); + } + + // Check all available versions every 1 hour for installed releases. + // This required to show "upgrade" icon in the list and upgrade button in the details view. + @autobind() + checkVersions() { + const charts = releaseStore.items.map(release => release.getChart()); + return charts.reduce((promise, chartName) => { + const loadVersions = () => helmChartStore.getVersions(chartName, true); + return promise.then(loadVersions, loadVersions); + }, Promise.resolve({})) + }; + + get selectedRelease() { + const { match: { params: { name, namespace } } } = this.props; + return releaseStore.items.find(release => { + return release.getName() == name && release.getNs() == namespace; + }); + } + + showDetails = (item: HelmRelease) => { + if (!item) { + navigation.merge(releaseURL()) + } + else { + navigation.merge(releaseURL({ + params: { + name: item.getName(), + namespace: item.getNs() + } + })) + } + } + + hideDetails = () => { + this.showDetails(null); + } + + renderRemoveDialogMessage(selectedItems: HelmRelease[]) { + const releaseNames = selectedItems.map(item => item.getName()).join(", "); + return ( +
+ Remove {releaseNames}? +

+ Note: StatefulSet Volumes won't be deleted automatically +

+
+ ) + } + + render() { + return ( + <> + release.getName(), + [sortBy.namespace]: (release: HelmRelease) => release.getNs(), + [sortBy.revision]: (release: HelmRelease) => release.getRevision(), + [sortBy.chart]: (release: HelmRelease) => release.getChart(), + [sortBy.status]: (release: HelmRelease) => release.getStatus(), + [sortBy.updated]: (release: HelmRelease) => release.getUpdated(false, false), + }} + searchFilters={[ + (release: HelmRelease) => release.getName(), + (release: HelmRelease) => release.getNs(), + (release: HelmRelease) => release.getChart(), + (release: HelmRelease) => release.getStatus(), + (release: HelmRelease) => release.getVersion(), + ]} + renderHeaderTitle={Releases} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Chart, className: "chart", sortBy: sortBy.chart }, + { title: Revision, className: "revision", sortBy: sortBy.revision }, + { title: Version, className: "version" }, + { title: App Version, className: "app-version" }, + { title: Status, className: "status", sortBy: sortBy.status }, + { title: Updated, className: "updated", sortBy: sortBy.updated }, + ]} + renderTableContents={(release: HelmRelease) => { + const version = release.getVersion(); + const lastVersion = release.getLastVersion(); + return [ + release.getName(), + release.getNs(), + release.getChart(), + release.getRevision(), + <> + {version} + {!lastVersion && ( + Checking update} + /> + )} + {release.hasNewVersion() && ( + New version: {lastVersion}} + /> + )} + , + release.appVersion, + { title: release.getStatus(), className: kebabCase(release.getStatus()) }, + release.getUpdated(), + ] + }} + renderItemMenu={(release: HelmRelease) => { + return ( + + ) + }} + customizeRemoveDialog={(selectedItems: HelmRelease[]) => ({ + message: this.renderRemoveDialogMessage(selectedItems) + })} + detailsItem={this.selectedRelease} + onDetails={this.showDetails} + /> + + + + ); + } +} \ No newline at end of file diff --git a/dashboard/client/components/+apps/apps.route.ts b/dashboard/client/components/+apps/apps.route.ts new file mode 100644 index 0000000000..065fbe05d6 --- /dev/null +++ b/dashboard/client/components/+apps/apps.route.ts @@ -0,0 +1,8 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const appsRoute: RouteProps = { + path: "/apps", +}; + +export const appsURL = buildURL(appsRoute.path); diff --git a/dashboard/client/components/+apps/apps.tsx b/dashboard/client/components/+apps/apps.tsx new file mode 100644 index 0000000000..8d54a0cee6 --- /dev/null +++ b/dashboard/client/components/+apps/apps.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { Redirect, Route, Switch } from "react-router"; +import { Trans } from "@lingui/macro"; +import { MainLayout, TabRoute } from "../layout/main-layout"; +import { HelmCharts, helmChartsRoute, helmChartsURL } from "../+apps-helm-charts"; +import { HelmReleases, releaseRoute, releaseURL } from "../+apps-releases"; +import { namespaceStore } from "../+namespaces/namespace.store"; + +@observer +export class Apps extends React.Component { + static get tabRoutes(): TabRoute[] { + const query = namespaceStore.getContextParams(); + return [ + { + title: Charts, + component: HelmCharts, + url: helmChartsURL(), + path: helmChartsRoute.path, + }, + { + title: Releases, + component: HelmReleases, + url: releaseURL({ query }), + path: releaseRoute.path, + }, + ] + } + + render() { + const tabRoutes = Apps.tabRoutes; + return ( + + + {tabRoutes.map((route, index) => )} + + + + ) + } +} diff --git a/dashboard/client/components/+apps/index.ts b/dashboard/client/components/+apps/index.ts new file mode 100644 index 0000000000..70c0169777 --- /dev/null +++ b/dashboard/client/components/+apps/index.ts @@ -0,0 +1,2 @@ +export * from "./apps"; +export * from "./apps.route"; \ No newline at end of file diff --git a/dashboard/client/components/+cluster/cluster-issues.scss b/dashboard/client/components/+cluster/cluster-issues.scss new file mode 100644 index 0000000000..b552cf6877 --- /dev/null +++ b/dashboard/client/components/+cluster/cluster-issues.scss @@ -0,0 +1,58 @@ +.ClusterIssues { + min-height: 350px; + position: relative; + + @include media("<1024px") { + grid-column-start: 1!important; + grid-column-end: 1!important; + } + + &.wide { + grid-column-start: 1; + grid-column-end: 3; + } + + .SubHeader { + .Icon { + font-size: 130%; + color: $colorError; + } + } + + .Table { + .TableHead { + background-color: transparent; + border-bottom: 1px solid $borderFaintColor; + + .TableCell { + padding-top: 0; + padding-bottom: floor($padding / 1.33); + } + } + + .TableCell { + white-space: nowrap; + text-overflow: ellipsis; + + &.message { + flex-grow: 3; + } + + &.object { + flex-grow: 2; + } + } + } + + .no-issues { + .Icon { + color: white; + } + + .ok-title { + font-size: large; + color: $textColorAccent; + font-weight: bold; + } + } +} diff --git a/dashboard/client/components/+cluster/cluster-issues.tsx b/dashboard/client/components/+cluster/cluster-issues.tsx new file mode 100644 index 0000000000..64077cba4a --- /dev/null +++ b/dashboard/client/components/+cluster/cluster-issues.tsx @@ -0,0 +1,149 @@ +import "./cluster-issues.scss" + +import * as React from "react"; +import { observer } from "mobx-react"; +import { computed } from "mobx"; +import { Trans } from "@lingui/macro"; +import { Icon } from "../icon"; +import { SubHeader } from "../layout/sub-header"; +import { Table, TableCell, TableHead, TableRow } from "../table"; +import { nodesStore } from "../+nodes/nodes.store"; +import { eventStore } from "../+events/event.store"; +import { autobind, cssNames, prevDefault } from "../../utils"; +import { getSelectedDetails, showDetails } from "../../navigation"; +import { ItemObject } from "../../item.store"; +import { Spinner } from "../spinner"; +import { themeStore } from "../../theme.store"; +import { lookupApiLink } from "../../api/kube-api"; + +interface Props { + className?: string; +} + +interface IWarning extends ItemObject { + kind: string; + message: string; + selfLink: string; +} + +enum sortBy { + type = "type", + object = "object" +} + +@observer +export class ClusterIssues extends React.Component { + private sortCallbacks = { + [sortBy.type]: (warning: IWarning) => warning.kind, + [sortBy.object]: (warning: IWarning) => warning.getName(), + }; + + @computed get warnings() { + const warnings: IWarning[] = []; + + // Node bad conditions + nodesStore.items.forEach(node => { + const { kind, selfLink, getId, getName } = node + node.getWarningConditions().forEach(({ message }) => { + warnings.push({ + kind, + getId, + getName, + selfLink, + message, + }) + }) + }); + + // Warning events for Workloads + const events = eventStore.getWarnings(); + events.forEach(error => { + const { message, involvedObject } = error; + const { uid, name, kind } = involvedObject; + warnings.push({ + getId: () => uid, + getName: () => name, + message, + kind, + selfLink: lookupApiLink(involvedObject, error), + }); + }) + + return warnings; + } + + @autobind() + getTableRow(uid: string) { + const { warnings } = this; + const warning = warnings.find(warn => warn.getId() == uid); + const { getId, getName, message, kind, selfLink } = warning; + return ( + showDetails(selfLink))} + > + + {message} + + + {getName()} + + + {kind} + + + ); + } + + renderContent() { + const { warnings } = this; + if (!eventStore.isLoaded) { + return ( + + ); + } + if (!warnings.length) { + return ( +
+
+
No issues found
+ Everything is fine in the Cluster +
+ ); + } + return ( + <> + + {" "} + Warnings: {warnings.length} + + + + Message + Object + Type + +
+ + ); + } + + render() { + return ( +
+ {this.renderContent()} +
+ ); + } +} diff --git a/dashboard/client/components/+cluster/cluster-metric-switchers.scss b/dashboard/client/components/+cluster/cluster-metric-switchers.scss new file mode 100644 index 0000000000..c0359c734a --- /dev/null +++ b/dashboard/client/components/+cluster/cluster-metric-switchers.scss @@ -0,0 +1,7 @@ +.ClusterMetricSwitchers { + margin-bottom: $margin * 2; + + .metric-switch { + text-align: right; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+cluster/cluster-metric-switchers.tsx b/dashboard/client/components/+cluster/cluster-metric-switchers.tsx new file mode 100644 index 0000000000..76c9ca6a0c --- /dev/null +++ b/dashboard/client/components/+cluster/cluster-metric-switchers.tsx @@ -0,0 +1,43 @@ +import "./cluster-metric-switchers.scss"; + +import React from "react"; +import { Trans } from "@lingui/macro"; +import { observer } from "mobx-react"; +import { nodesStore } from "../+nodes/nodes.store"; +import { cssNames } from "../../utils"; +import { Radio, RadioGroup } from "../radio"; +import { clusterStore, MetricNodeRole, MetricType } from "./cluster.store"; + +export const ClusterMetricSwitchers = observer(() => { + const { metricType, metricNodeRole, getMetricsValues, metrics } = clusterStore; + const { masterNodes, workerNodes } = nodesStore; + const metricsValues = getMetricsValues(metrics); + const disableRoles = !masterNodes.length || !workerNodes.length; + const disableMetrics = !metricsValues.length; + return ( +
+
+ clusterStore.metricNodeRole = metric} + > + Master} value={MetricNodeRole.MASTER}/> + Worker} value={MetricNodeRole.WORKER}/> + +
+
+ clusterStore.metricType = value} + > + CPU} value={MetricType.CPU}/> + Memory} value={MetricType.MEMORY}/> + +
+
+ ); +}); \ No newline at end of file diff --git a/dashboard/client/components/+cluster/cluster-metrics.scss b/dashboard/client/components/+cluster/cluster-metrics.scss new file mode 100644 index 0000000000..90e7d19465 --- /dev/null +++ b/dashboard/client/components/+cluster/cluster-metrics.scss @@ -0,0 +1,16 @@ +.ClusterMetrics { + position: relative; + min-height: 280px; + + .Chart { + .chart-container { + width: 100%; + height: 100%; + } + } + + .empty { + margin-top: -45px; + text-align: center; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+cluster/cluster-metrics.tsx b/dashboard/client/components/+cluster/cluster-metrics.tsx new file mode 100644 index 0000000000..077adde70a --- /dev/null +++ b/dashboard/client/components/+cluster/cluster-metrics.tsx @@ -0,0 +1,95 @@ +import "./cluster-metrics.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { ChartOptions, ChartPoint } from "chart.js"; +import { clusterStore, MetricType } from "./cluster.store"; +import { BarChart } from "../chart"; +import { bytesToUnits } from "../../utils"; +import { Spinner } from "../spinner"; +import { ZebraStripes } from "../chart/zebra-stripes.plugin"; +import { ClusterNoMetrics } from "./cluster-no-metrics"; +import { ClusterMetricSwitchers } from "./cluster-metric-switchers"; +import { getMetricLastPoints } from "../../api/endpoints/metrics.api"; + +export const ClusterMetrics = observer(() => { + const { metricType, metricNodeRole, getMetricsValues, metricsLoaded, metrics, liveMetrics } = clusterStore; + const { memoryCapacity, cpuCapacity } = getMetricLastPoints(clusterStore.metrics); + const metricValues = getMetricsValues(metrics); + const liveMetricValues = getMetricsValues(liveMetrics); + const colors = { cpu: "#3D90CE", memory: "#C93DCE" }; + const data = metricValues.map(value => ({ + x: value[0], + y: parseFloat(value[1]).toFixed(3) + })); + + const datasets = [{ + id: metricType + metricNodeRole, + label: metricType.toUpperCase() + " usage", + borderColor: colors[metricType], + data: data + }]; + const cpuOptions: ChartOptions = { + scales: { + yAxes: [{ + ticks: { + suggestedMax: cpuCapacity, + callback: (value) => value + } + }] + }, + tooltips: { + callbacks: { + label: ({ index }, data) => { + const value = data.datasets[0].data[index] as ChartPoint; + return value.y.toString(); + } + } + } + }; + const memoryOptions: ChartOptions = { + scales: { + yAxes: [{ + ticks: { + suggestedMax: memoryCapacity, + callback: (value: string) => !value ? 0 : bytesToUnits(parseInt(value)) + } + }] + }, + tooltips: { + callbacks: { + label: ({ index }, data) => { + const value = data.datasets[0].data[index] as ChartPoint; + return bytesToUnits(parseInt(value.y as string), 3); + } + } + } + }; + const options = metricType === MetricType.CPU ? cpuOptions : memoryOptions; + + const renderMetrics = () => { + if ((!metricValues.length || !liveMetricValues.length) && !metricsLoaded) { + return ; + } + if (!memoryCapacity || !cpuCapacity) { + return + } + return ( + + ); + }; + + return ( +
+ + {renderMetrics()} +
+ ); +}); \ No newline at end of file diff --git a/dashboard/client/components/+cluster/cluster-no-metrics.tsx b/dashboard/client/components/+cluster/cluster-no-metrics.tsx new file mode 100644 index 0000000000..f3f6777ea2 --- /dev/null +++ b/dashboard/client/components/+cluster/cluster-no-metrics.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { Icon } from "../icon"; +import { Trans } from "@lingui/macro"; +import { cssNames } from "../../utils"; + +interface Props { + className: string; +} + +export function ClusterNoMetrics({ className }: Props) { + return ( +
+ +

Metrics are not available due to missing or invalid Prometheus configuration.

+

Right click cluster icon to open cluster settings.

+
+ ); +} diff --git a/dashboard/client/components/+cluster/cluster-pie-charts.scss b/dashboard/client/components/+cluster/cluster-pie-charts.scss new file mode 100644 index 0000000000..b6f6e2dd27 --- /dev/null +++ b/dashboard/client/components/+cluster/cluster-pie-charts.scss @@ -0,0 +1,29 @@ +.ClusterPieCharts { + background: transparent!important; + padding: 0!important; + + .empty { + background: $contentColor; + min-height: 280px; + text-align: center; + padding: $padding * 2; + } + + .NodeCharts { + margin-bottom: 0; + } + + .chart { + --flex-gap: #{$padding * 2}; + background: $contentColor; + padding: $padding * 2 $padding; + + .chart-title { + margin-bottom: 0; + } + + .legend { + --flex-gap: #{$padding}; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+cluster/cluster-pie-charts.tsx b/dashboard/client/components/+cluster/cluster-pie-charts.tsx new file mode 100644 index 0000000000..6b0bf562f8 --- /dev/null +++ b/dashboard/client/components/+cluster/cluster-pie-charts.tsx @@ -0,0 +1,204 @@ +import "./cluster-pie-charts.scss"; + +import * as React from "react"; +import { observer } from "mobx-react"; +import { t, Trans } from "@lingui/macro"; +import { useLingui } from "@lingui/react"; +import { clusterStore, MetricNodeRole } from "./cluster.store"; +import { Spinner } from "../spinner"; +import { Icon } from "../icon"; +import { nodesStore } from "../+nodes/nodes.store"; +import { ChartData, PieChart } from "../chart"; +import { ClusterNoMetrics } from "./cluster-no-metrics"; +import { bytesToUnits } from "../../utils"; +import { themeStore } from "../../theme.store"; +import { getMetricLastPoints } from "../../api/endpoints/metrics.api"; + +export const ClusterPieCharts = observer(() => { + const { i18n } = useLingui(); + + const renderLimitWarning = () => { + return ( +
+ +

Specified limits are higher than node capacity!

+
+ ); + } + + const renderCharts = () => { + const data = getMetricLastPoints(clusterStore.metrics); + const { memoryUsage, memoryRequests, memoryCapacity, memoryLimits } = data; + const { cpuUsage, cpuRequests, cpuCapacity, cpuLimits } = data; + const { podUsage, podCapacity } = data; + const cpuLimitsOverload = cpuLimits > cpuCapacity; + const memoryLimitsOverload = memoryLimits > memoryCapacity; + const defaultColor = themeStore.activeTheme.colors.pieChartDefaultColor; + + if (!memoryCapacity || !cpuCapacity || !podCapacity) return null; + const cpuData: ChartData = { + datasets: [ + { + data: [ + cpuUsage, + cpuUsage ? cpuCapacity - cpuUsage : 1, + ], + backgroundColor: [ + "#c93dce", + defaultColor, + ], + id: "cpuUsage" + }, + { + data: [ + cpuRequests, + cpuRequests ? cpuCapacity - cpuRequests : 1, + ], + backgroundColor: [ + "#4caf50", + defaultColor, + ], + id: "cpuRequests" + }, + { + data: [ + cpuLimits, + cpuLimitsOverload ? 0 : cpuCapacity - cpuLimits, + ], + backgroundColor: [ + "#3d90ce", + defaultColor, + ], + id: "cpuLimits" + }, + ], + labels: [ + i18n._(t`Usage`) + `: ${cpuUsage ? cpuUsage.toFixed(2) : "N/A"}`, + i18n._(t`Requests`) + `: ${cpuRequests ? cpuRequests.toFixed(2) : "N/A"}`, + i18n._(t`Limits`) + `: ${cpuLimits ? cpuLimits.toFixed(2) : "N/A"}`, + i18n._(t`Capacity`) + `: ${cpuCapacity || "N/A"}` + ] + }; + const memoryData: ChartData = { + datasets: [ + { + data: [ + memoryUsage, + memoryUsage ? memoryCapacity - memoryUsage : 1, + ], + backgroundColor: [ + "#c93dce", + defaultColor, + ], + id: "memoryUsage" + }, + { + data: [ + memoryRequests, + memoryRequests ? memoryCapacity - memoryRequests : 1, + ], + backgroundColor: [ + "#4caf50", + defaultColor, + ], + id: "memoryRequests" + }, + { + data: [ + memoryLimits, + memoryLimitsOverload ? 0 : memoryCapacity - memoryLimits, + ], + backgroundColor: [ + "#3d90ce", + defaultColor, + ], + id: "memoryLimits" + }, + ], + labels: [ + i18n._(t`Usage`) + `: ${bytesToUnits(memoryUsage)}`, + i18n._(t`Requests`) + `: ${bytesToUnits(memoryRequests)}`, + i18n._(t`Limits`) + `: ${bytesToUnits(memoryLimits)}`, + i18n._(t`Capacity`) + `: ${bytesToUnits(memoryCapacity)}`, + ] + }; + const podsData: ChartData = { + datasets: [ + { + data: [ + podUsage, + podUsage ? podCapacity - podUsage : 1, + ], + backgroundColor: [ + "#4caf50", + defaultColor, + ], + id: "podUsage" + }, + ], + labels: [ + i18n._(t`Usage`) + `: ${podUsage || 0}`, + i18n._(t`Capacity`) + `: ${podCapacity}`, + ] + }; + return ( +
+
+ + {cpuLimitsOverload && renderLimitWarning()} +
+
+ + {memoryLimitsOverload && renderLimitWarning()} +
+
+ +
+
+ ); + } + + const renderContent = () => { + const { masterNodes, workerNodes } = nodesStore; + const { metricNodeRole, metricsLoaded } = clusterStore; + const nodes = metricNodeRole === MetricNodeRole.MASTER ? masterNodes : workerNodes; + if (!nodes.length) { + return ( +
+ + No Nodes Available. +
+ ); + } + if (!metricsLoaded) { + return ( +
+ +
+ ); + } + const { memoryCapacity, cpuCapacity, podCapacity } = getMetricLastPoints(clusterStore.metrics); + if (!memoryCapacity || !cpuCapacity || !podCapacity) { + return ; + } + return renderCharts(); + } + + return ( +
+ {renderContent()} +
+ ); +}) \ No newline at end of file diff --git a/dashboard/client/components/+cluster/cluster.routes.ts b/dashboard/client/components/+cluster/cluster.routes.ts new file mode 100644 index 0000000000..f541d83945 --- /dev/null +++ b/dashboard/client/components/+cluster/cluster.routes.ts @@ -0,0 +1,8 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const clusterRoute: RouteProps = { + path: "/cluster" +} + +export const clusterURL = buildURL(clusterRoute.path) diff --git a/dashboard/client/components/+cluster/cluster.scss b/dashboard/client/components/+cluster/cluster.scss new file mode 100644 index 0000000000..32739c378c --- /dev/null +++ b/dashboard/client/components/+cluster/cluster.scss @@ -0,0 +1,24 @@ +.Cluster { + $gridGap: $margin * 2; + + position: relative; + height: 100%; + min-height: 650px; + display: grid; + grid-gap: $gridGap; + grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); + grid-template-rows: 1fr 1fr; + + @include media(">1600px") { + grid-template-columns: 1fr 1fr; + } + + > *:not(.Spinner) { + padding: $gridGap; + background: $contentColor; + + > .SubHeader { + padding-top: 0; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+cluster/cluster.store.ts b/dashboard/client/components/+cluster/cluster.store.ts new file mode 100644 index 0000000000..066ecad2f2 --- /dev/null +++ b/dashboard/client/components/+cluster/cluster.store.ts @@ -0,0 +1,110 @@ +import { observable, reaction, when } from "mobx"; +import { KubeObjectStore } from "../../kube-object.store"; +import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints"; +import { autobind, createStorage } from "../../utils"; +import { IMetricsReqParams, normalizeMetrics } from "../../api/endpoints/metrics.api"; +import { nodesStore } from "../+nodes/nodes.store"; +import { apiManager } from "../../api/api-manager"; + +export enum MetricType { + MEMORY = "memory", + CPU = "cpu" +} + +export enum MetricNodeRole { + MASTER = "master", + WORKER = "worker" +} + +@autobind() +export class ClusterStore extends KubeObjectStore { + api = clusterApi + + @observable metrics: Partial = {}; + @observable liveMetrics: Partial = {}; + @observable metricsLoaded = false; + @observable metricType: MetricType; + @observable metricNodeRole: MetricNodeRole; + + constructor() { + super(); + this.resetMetrics(); + + // sync user setting with local storage + const storage = createStorage("cluster_metric_switchers", {}); + Object.assign(this, storage.get()); + reaction(() => { + const { metricType, metricNodeRole } = this; + return { metricType, metricNodeRole } + }, + settings => storage.set(settings) + ); + + // auto-update metrics + reaction(() => this.metricNodeRole, () => { + if (!this.metricsLoaded) return; + this.metrics = {}; + this.liveMetrics = {}; + this.metricsLoaded = false; + this.getAllMetrics(); + }); + + // check which node type to select + reaction(() => nodesStore.items.length, () => { + const { masterNodes, workerNodes } = nodesStore; + if (!masterNodes.length) this.metricNodeRole = MetricNodeRole.WORKER; + if (!workerNodes.length) this.metricNodeRole = MetricNodeRole.MASTER; + }); + } + + async loadMetrics(params?: IMetricsReqParams) { + await when(() => nodesStore.isLoaded); + const { masterNodes, workerNodes } = nodesStore; + const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes; + return clusterApi.getMetrics(nodes.map(node => node.getName()), params); + } + + async getAllMetrics() { + await this.getMetrics(); + await this.getLiveMetrics(); + this.metricsLoaded = true; + } + + async getMetrics() { + this.metrics = await this.loadMetrics(); + } + + async getLiveMetrics() { + const step = 3; + const range = 15; + const end = Date.now() / 1000; + const start = end - range; + this.liveMetrics = await this.loadMetrics({ start, end, step, range }); + } + + getMetricsValues(source: Partial) { + const metrics = + this.metricType === MetricType.CPU ? source.cpuUsage : + this.metricType === MetricType.MEMORY ? source.memoryUsage + : null; + if (!metrics) { + return []; + } + return normalizeMetrics(metrics).data.result[0].values; + } + + resetMetrics() { + this.metrics = {}; + this.metricsLoaded = false; + this.metricType = MetricType.CPU; + this.metricNodeRole = MetricNodeRole.WORKER; + } + + reset() { + super.reset(); + this.resetMetrics(); + } +} + +export const clusterStore = new ClusterStore(); +apiManager.registerStore(clusterApi, clusterStore); diff --git a/dashboard/client/components/+cluster/cluster.tsx b/dashboard/client/components/+cluster/cluster.tsx new file mode 100644 index 0000000000..979f811067 --- /dev/null +++ b/dashboard/client/components/+cluster/cluster.tsx @@ -0,0 +1,69 @@ +import "./cluster.scss" + +import React from "react"; +import { computed, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { MainLayout } from "../layout/main-layout"; +import { ClusterIssues } from "./cluster-issues"; +import { Spinner } from "../spinner"; +import { cssNames, interval, isElectron } from "../../utils"; +import { ClusterPieCharts } from "./cluster-pie-charts"; +import { ClusterMetrics } from "./cluster-metrics"; +import { nodesStore } from "../+nodes/nodes.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { clusterStore } from "./cluster.store"; +import { eventStore } from "../+events/event.store"; + +@observer +export class Cluster extends React.Component { + private watchers = [ + interval(60, () => clusterStore.getMetrics()), + interval(20, () => eventStore.loadAll()) + ]; + + private dependentStores = [nodesStore, podsStore]; + + async componentDidMount() { + const { dependentStores } = this; + this.watchers.forEach(watcher => watcher.start(true)); + + await Promise.all([ + ...dependentStores.map(store => store.loadAll()), + clusterStore.getAllMetrics() + ]); + + disposeOnUnmount(this, [ + ...dependentStores.map(store => store.subscribe()), + () => this.watchers.forEach(watcher => watcher.stop()), + reaction( + () => clusterStore.metricNodeRole, + () => this.watchers.forEach(watcher => watcher.restart()) + ) + ]) + } + + @computed get isLoaded() { + return ( + nodesStore.isLoaded && + podsStore.isLoaded + ) + } + + render() { + const { isLoaded } = this; + return ( + +
+ {!isLoaded && } + {isLoaded && ( + <> + + + + + )} +
+
+ ) + } +} diff --git a/dashboard/client/components/+cluster/index.ts b/dashboard/client/components/+cluster/index.ts new file mode 100644 index 0000000000..dfc259440e --- /dev/null +++ b/dashboard/client/components/+cluster/index.ts @@ -0,0 +1,2 @@ +export * from "./cluster.routes" + diff --git a/dashboard/client/components/+config-autoscalers/autoscaler.mixins.scss b/dashboard/client/components/+config-autoscalers/autoscaler.mixins.scss new file mode 100644 index 0000000000..fe45f91beb --- /dev/null +++ b/dashboard/client/components/+config-autoscalers/autoscaler.mixins.scss @@ -0,0 +1,14 @@ +$hpa-status-colors: ( + abletoscale: $colorOk, + scalingactive: $colorInfo, + scalinglimited: $colorSoftError, +); + +@mixin hpa-status-bgc { + @each $status, $color in $hpa-status-colors { + &.#{$status} { + background: $color; + color: white; + } + } +} diff --git a/dashboard/client/components/+config-autoscalers/hpa-details.scss b/dashboard/client/components/+config-autoscalers/hpa-details.scss new file mode 100644 index 0000000000..95cb070869 --- /dev/null +++ b/dashboard/client/components/+config-autoscalers/hpa-details.scss @@ -0,0 +1,17 @@ +@import "autoscaler.mixins"; + +.HpaDetails { + .Badge { + @include hpa-status-bgc; + } + + .metrics .Table { + .TableCell { + word-break: break-word; + + &.name { + flex-grow: 2; + } + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config-autoscalers/hpa-details.tsx b/dashboard/client/components/+config-autoscalers/hpa-details.tsx new file mode 100644 index 0000000000..db068885f5 --- /dev/null +++ b/dashboard/client/components/+config-autoscalers/hpa-details.tsx @@ -0,0 +1,133 @@ +import "./hpa-details.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { Link } from "react-router-dom"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { Badge } from "../badge"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { cssNames } from "../../utils"; +import { HorizontalPodAutoscaler, hpaApi, HpaMetricType, IHpaMetric } from "../../api/endpoints/hpa.api"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { Trans } from "@lingui/macro"; +import { Table, TableCell, TableHead, TableRow } from "../table"; +import { getDetailsUrl } from "../../navigation"; +import { lookupApiLink } from "../../api/kube-api"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class HpaDetails extends React.Component { + renderMetrics() { + const { object: hpa } = this.props; + + const renderName = (metric: IHpaMetric) => { + switch (metric.type) { + case HpaMetricType.Resource: + const addition = metric.resource.targetAverageUtilization ? (as a percentage of request) : ""; + return Resource {metric.resource.name} on Pods {addition}; + + case HpaMetricType.Pods: + return {metric.pods.metricName} on Pods; + + case HpaMetricType.Object: + const { target } = metric.object; + const { kind, name } = target; + const objectUrl = getDetailsUrl(lookupApiLink(target, hpa)); + return ( + + {metric.object.metricName} on{" "} + {kind}/{name} + + ); + case HpaMetricType.External: + return ( + + {metric.external.metricName} on{" "} + {JSON.stringify(metric.external.selector)} + + ); + } + } + + return ( + + + Name + Current / Target + + { + hpa.getMetrics().map((metric, index) => { + const name = renderName(metric); + const values = hpa.getMetricValues(metric); + return ( + + {name} + {values} + + ) + }) + } +
+ ); + } + + render() { + const { object: hpa } = this.props; + if (!hpa) return; + const { scaleTargetRef } = hpa.spec; + return ( +
+ + + Reference}> + {scaleTargetRef && ( + + {scaleTargetRef.kind}/{scaleTargetRef.name} + + )} + + + Min Pods}> + {hpa.getMinPods()} + + + Max Pods}> + {hpa.getMaxPods()} + + + Replicas}> + {hpa.getReplicas()} + + + Status} labelsOnly> + {hpa.getConditions().map(({ type, tooltip, isReady }) => { + if (!isReady) return null; + return ( + + ) + })} + + + +
+ {this.renderMetrics()} +
+ + +
+ ); + } +} + +apiManager.registerViews(hpaApi, { + Details: HpaDetails, +}); diff --git a/dashboard/client/components/+config-autoscalers/hpa.route.ts b/dashboard/client/components/+config-autoscalers/hpa.route.ts new file mode 100644 index 0000000000..500b260062 --- /dev/null +++ b/dashboard/client/components/+config-autoscalers/hpa.route.ts @@ -0,0 +1,11 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const hpaRoute: RouteProps = { + path: "/hpa" +} + +export interface IHpaRouteParams { +} + +export const hpaURL = buildURL(hpaRoute.path) diff --git a/dashboard/client/components/+config-autoscalers/hpa.scss b/dashboard/client/components/+config-autoscalers/hpa.scss new file mode 100644 index 0000000000..de331cde2f --- /dev/null +++ b/dashboard/client/components/+config-autoscalers/hpa.scss @@ -0,0 +1,26 @@ +@import "./autoscaler.mixins"; + +.HorizontalPodAutoscalers { + .TableCell { + &.name { + flex: 1.5; + } + + &.metrics { + flex: 1.5; + } + + &.status { + flex: 1.5; + @include table-cell-labels-offsets; + + .Badge { + @include hpa-status-bgc; + } + } + + &.age { + flex: .5; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config-autoscalers/hpa.store.ts b/dashboard/client/components/+config-autoscalers/hpa.store.ts new file mode 100644 index 0000000000..62b971dd5c --- /dev/null +++ b/dashboard/client/components/+config-autoscalers/hpa.store.ts @@ -0,0 +1,12 @@ +import { autobind } from "../../utils"; +import { KubeObjectStore } from "../../kube-object.store"; +import { HorizontalPodAutoscaler, hpaApi } from "../../api/endpoints/hpa.api"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class HPAStore extends KubeObjectStore { + api = hpaApi +} + +export const hpaStore = new HPAStore(); +apiManager.registerStore(hpaApi, hpaStore); diff --git a/dashboard/client/components/+config-autoscalers/hpa.tsx b/dashboard/client/components/+config-autoscalers/hpa.tsx new file mode 100644 index 0000000000..0017617d94 --- /dev/null +++ b/dashboard/client/components/+config-autoscalers/hpa.tsx @@ -0,0 +1,99 @@ +import "./hpa.scss" + +import * as React from "react"; +import { observer } from "mobx-react"; +import { RouteComponentProps } from "react-router"; +import { Trans } from "@lingui/macro"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { KubeObjectListLayout } from "../kube-object"; +import { IHpaRouteParams } from "./hpa.route"; +import { HorizontalPodAutoscaler, hpaApi } from "../../api/endpoints/hpa.api"; +import { hpaStore } from "./hpa.store"; +import { Badge } from "../badge"; +import { cssNames } from "../../utils"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + minPods = "min-pods", + maxPods = "max-pods", + replicas = "replicas", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class HorizontalPodAutoscalers extends React.Component { + getTargets(hpa: HorizontalPodAutoscaler) { + const metrics = hpa.getMetrics(); + const metricsRemainCount = metrics.length - 1; + const metricsRemain = metrics.length > 1 ? {metricsRemainCount} more... : null; + const metricValues = hpa.getMetricValues(metrics[0]); + return

{metricValues} {metricsRemain && "+"}{metricsRemain}

; + } + + render() { + return ( + item.getName(), + [sortBy.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(), + [sortBy.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(), + [sortBy.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(), + [sortBy.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas() + }} + searchFilters={[ + (item: HorizontalPodAutoscaler) => item.getSearchFields() + ]} + renderHeaderTitle={Horizontal Pod Autoscalers} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Metrics, className: "metrics" }, + { title: Min Pods, className: "min-pods", sortBy: sortBy.minPods }, + { title: Max Pods, className: "max-pods", sortBy: sortBy.maxPods }, + { title: Replicas, className: "replicas", sortBy: sortBy.replicas }, + { title: Age, className: "age", sortBy: sortBy.age }, + { title: Status, className: "status" }, + ]} + renderTableContents={(hpa: HorizontalPodAutoscaler) => [ + hpa.getName(), + hpa.getNs(), + this.getTargets(hpa), + hpa.getMinPods(), + hpa.getMaxPods(), + hpa.getReplicas(), + hpa.getAge(), + hpa.getConditions().map(({ type, tooltip, isReady }) => { + if (!isReady) return null; + return ( + + ) + }) + ]} + renderItemMenu={(item: HorizontalPodAutoscaler) => { + return + }} + /> + ); + } +} + +export function HpaMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews(hpaApi, { + Menu: HpaMenu, +}) diff --git a/dashboard/client/components/+config-autoscalers/index.ts b/dashboard/client/components/+config-autoscalers/index.ts new file mode 100644 index 0000000000..4d3cedf89c --- /dev/null +++ b/dashboard/client/components/+config-autoscalers/index.ts @@ -0,0 +1,3 @@ +export * from "./hpa" +export * from "./hpa-details" +export * from "./hpa.route" diff --git a/dashboard/client/components/+config-maps/config-map-details.scss b/dashboard/client/components/+config-maps/config-map-details.scss new file mode 100644 index 0000000000..c9732a97c3 --- /dev/null +++ b/dashboard/client/components/+config-maps/config-map-details.scss @@ -0,0 +1,15 @@ +.ConfigMapDetails { + .data { + margin-bottom: $margin * 2; + + .name { + color: $textColorSecondary; + font-weight: $font-weight-bold; + padding-bottom: $padding / 2; + } + } + + .save-btn { + margin-top: $margin; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config-maps/config-map-details.tsx b/dashboard/client/components/+config-maps/config-map-details.tsx new file mode 100644 index 0000000000..b676c1e1e4 --- /dev/null +++ b/dashboard/client/components/+config-maps/config-map-details.tsx @@ -0,0 +1,99 @@ +import "./config-map-details.scss"; + +import * as React from "react"; +import { autorun, observable } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { DrawerTitle } from "../drawer"; +import { Notifications } from "../notifications"; +import { Input } from "../input"; +import { Button } from "../button"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { configMapsStore } from "./config-maps.store"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { ConfigMap, configMapApi } from "../../api/endpoints"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class ConfigMapDetails extends React.Component { + @observable isSaving = false; + @observable data = observable.map(); + + async componentDidMount() { + disposeOnUnmount(this, [ + autorun(() => { + const { object: configMap } = this.props; + if (configMap) { + this.data.replace(configMap.data); // refresh + } + }) + ]) + } + + save = async () => { + const { object: configMap } = this.props; + try { + this.isSaving = true; + await configMapsStore.update(configMap, { ...configMap, data: this.data.toJSON() }); + Notifications.ok( +

+ ConfigMap {configMap.getName()} successfully updated. +

+ ); + } finally { + this.isSaving = false; + } + } + + render() { + const { object: configMap } = this.props; + if (!configMap) return null; + const data = Object.entries(this.data.toJSON()); + return ( +
+ + { + data.length > 0 && ( + <> + Data}/> + { + data.map(([name, value]) => { + return ( +
+
{name}
+
+ this.data.set(name, v)} + /> +
+
+ ) + }) + } +
+ ); + } +} + +apiManager.registerViews(configMapApi, { + Details: ConfigMapDetails +}) \ No newline at end of file diff --git a/dashboard/client/components/+config-maps/config-maps.route.ts b/dashboard/client/components/+config-maps/config-maps.route.ts new file mode 100644 index 0000000000..9f68afcfeb --- /dev/null +++ b/dashboard/client/components/+config-maps/config-maps.route.ts @@ -0,0 +1,11 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const configMapsRoute: RouteProps = { + path: "/configmaps" +} + +export interface IConfigMapsRouteParams { +} + +export const configMapsURL = buildURL(configMapsRoute.path); diff --git a/dashboard/client/components/+config-maps/config-maps.scss b/dashboard/client/components/+config-maps/config-maps.scss new file mode 100644 index 0000000000..68da0beaf5 --- /dev/null +++ b/dashboard/client/components/+config-maps/config-maps.scss @@ -0,0 +1,15 @@ +.ConfigMaps { + .TableCell { + &.name { + flex: 2; + } + + &.keys { + flex: 2.5; + } + + &.age { + flex: .5; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config-maps/config-maps.store.ts b/dashboard/client/components/+config-maps/config-maps.store.ts new file mode 100644 index 0000000000..5fd2526641 --- /dev/null +++ b/dashboard/client/components/+config-maps/config-maps.store.ts @@ -0,0 +1,12 @@ +import { KubeObjectStore } from "../../kube-object.store"; +import { autobind } from "../../utils"; +import { ConfigMap, configMapApi } from "../../api/endpoints/configmap.api"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class ConfigMapsStore extends KubeObjectStore { + api = configMapApi +} + +export const configMapsStore = new ConfigMapsStore(); +apiManager.registerStore(configMapApi, configMapsStore); diff --git a/dashboard/client/components/+config-maps/config-maps.tsx b/dashboard/client/components/+config-maps/config-maps.tsx new file mode 100644 index 0000000000..ba4d0646fc --- /dev/null +++ b/dashboard/client/components/+config-maps/config-maps.tsx @@ -0,0 +1,69 @@ +import "./config-maps.scss" + +import * as React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { RouteComponentProps } from "react-router"; +import { configMapsStore } from "./config-maps.store"; +import { ConfigMap, configMapApi } from "../../api/endpoints/configmap.api"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { KubeObjectListLayout } from "../kube-object"; +import { IConfigMapsRouteParams } from "./config-maps.route"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + keys = "keys", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class ConfigMaps extends React.Component { + render() { + return ( + item.getName(), + [sortBy.namespace]: (item: ConfigMap) => item.getNs(), + [sortBy.keys]: (item: ConfigMap) => item.getKeys(), + [sortBy.age]: (item: ConfigMap) => item.getAge(false), + }} + searchFilters={[ + (item: ConfigMap) => item.getSearchFields(), + (item: ConfigMap) => item.getKeys() + ]} + renderHeaderTitle={Config Maps} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Keys, className: "keys", sortBy: sortBy.keys }, + { title: Age, className: "age", sortBy: sortBy.age }, + ]} + renderTableContents={(configMap: ConfigMap) => [ + configMap.getName(), + configMap.getNs(), + configMap.getKeys().join(", "), + configMap.getAge(), + ]} + renderItemMenu={(item: ConfigMap) => { + return + }} + /> + ); + } +} + +export function ConfigMapMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews(configMapApi, { + Menu: ConfigMapMenu, +}) \ No newline at end of file diff --git a/dashboard/client/components/+config-maps/index.ts b/dashboard/client/components/+config-maps/index.ts new file mode 100644 index 0000000000..37b78f5097 --- /dev/null +++ b/dashboard/client/components/+config-maps/index.ts @@ -0,0 +1,3 @@ +export * from "./config-maps.route" +export * from "./config-maps" +export * from "./config-map-details" diff --git a/dashboard/client/components/+config-resource-quotas/add-quota-dialog.scss b/dashboard/client/components/+config-resource-quotas/add-quota-dialog.scss new file mode 100644 index 0000000000..4e9ca15787 --- /dev/null +++ b/dashboard/client/components/+config-resource-quotas/add-quota-dialog.scss @@ -0,0 +1,38 @@ +.AddQuotaDialog { + .quota-select { + flex-basis: 55%; + } + + .quota-entries { + margin: -$margin / 2; + + &:empty { + display: none; + } + + .quota { + --flex-gap: #{$padding}; + border: 1px solid $halfGray; + border-radius: $radius; + margin: $margin / 2; + padding: $padding / 2 $padding; + transition: all 150ms ease; + + &:hover { + box-shadow: inset 0 0 0 1px $borderColor; + } + + .name { + font-weight: $font-weight-bold; + } + + .value { + color: $contentColor; + } + + .Icon:hover { + color: black; + } + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config-resource-quotas/add-quota-dialog.tsx b/dashboard/client/components/+config-resource-quotas/add-quota-dialog.tsx new file mode 100644 index 0000000000..abb4c6d120 --- /dev/null +++ b/dashboard/client/components/+config-resource-quotas/add-quota-dialog.tsx @@ -0,0 +1,204 @@ +import "./add-quota-dialog.scss"; + +import React from "react"; +import { computed, 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 { systemName } from "../input/input.validators"; +import { IResourceQuotaValues, resourceQuotaApi } from "../../api/endpoints/resource-quota.api"; +import { Select } from "../select"; +import { Icon } from "../icon"; +import { Button } from "../button"; +import { Notifications } from "../notifications"; +import { NamespaceSelect } from "../+namespaces/namespace-select"; +import { SubTitle } from "../layout/sub-title"; + +interface Props extends DialogProps { +} + +@observer +export class AddQuotaDialog extends React.Component { + @observable static isOpen = false; + + static defaultQuotas: IResourceQuotaValues = { + "limits.cpu": "", + "limits.memory": "", + "requests.cpu": "", + "requests.memory": "", + "requests.storage": "", + "persistentvolumeclaims": "", + "count/pods": "", + "count/persistentvolumeclaims": "", + "count/services": "", + "count/secrets": "", + "count/configmaps": "", + "count/replicationcontrollers": "", + "count/deployments.apps": "", + "count/replicasets.apps": "", + "count/statefulsets.apps": "", + "count/jobs.batch": "", + "count/cronjobs.batch": "", + "count/deployments.extensions": "", + }; + + public defaultNamespace = "default" + + @observable quotaName = ""; + @observable quotaSelectValue = ""; + @observable quotaInputValue = ""; + @observable namespace = this.defaultNamespace; + @observable quotas = AddQuotaDialog.defaultQuotas; + + static open() { + AddQuotaDialog.isOpen = true; + } + + static close() { + AddQuotaDialog.isOpen = false; + } + + @computed get quotaEntries() { + return Object.entries(this.quotas) + .filter(([type, value]) => !!value.trim()); + } + + @computed get quotaOptions() { + return Object.keys(this.quotas).map(quota => { + const isCompute = quota.endsWith(".cpu") || quota.endsWith(".memory"); + const isStorage = quota.endsWith(".storage") || quota === "persistentvolumeclaims"; + const isCount = quota.startsWith("count/"); + const icon = isCompute ? "memory" : isStorage ? "storage" : isCount ? "looks_one" : ""; + return { + label: icon ? {quota} : quota, + value: quota, + }; + }); + } + + setQuota = () => { + if (!this.quotaSelectValue) return; + this.quotas[this.quotaSelectValue] = this.quotaInputValue; + this.quotaInputValue = ""; + } + + close = () => { + AddQuotaDialog.close(); + } + + reset = () => { + this.quotaName = ""; + this.quotaSelectValue = ""; + this.quotaInputValue = ""; + this.namespace = this.defaultNamespace; + this.quotas = AddQuotaDialog.defaultQuotas; + } + + addQuota = async () => { + try { + const { quotaName, namespace } = this; + const quotas = this.quotaEntries.reduce((quotas, [name, value]) => { + quotas[name] = value; + return quotas; + }, {}); + await resourceQuotaApi.create({ namespace, name: quotaName }, { + spec: { + hard: quotas + } + }); + this.close(); + } catch (err) { + Notifications.error(err); + } + } + + onInputQuota = (evt: React.KeyboardEvent) => { + switch (evt.key) { + case "Enter": + this.setQuota(); + evt.preventDefault(); // don't submit form + break; + } + } + + render() { + const { ...dialogProps } = this.props; + const header =
Create ResourceQuota
; + return ( + + + Create} + next={this.addQuota} + > +
+ this.quotaName = v.toLowerCase()} + className="box grow" + /> +
+ + Namespace}/> + this.namespace = value} + /> + + Values}/> +
+ this.quotaInputValue = v} + onKeyDown={this.onInputQuota} + className="box grow" + /> + +
+
+ {this.quotaEntries.map(([quota, value]) => { + return ( +
+
{quota}
+
{value}
+ this.quotas[quota] = ""}/> +
+ ) + })} +
+
+
+
+ ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config-resource-quotas/index.ts b/dashboard/client/components/+config-resource-quotas/index.ts new file mode 100644 index 0000000000..792f8946c3 --- /dev/null +++ b/dashboard/client/components/+config-resource-quotas/index.ts @@ -0,0 +1,3 @@ +export * from "./resource-quotas.route" +export * from "./resource-quotas" +export * from "./resource-quota-details" diff --git a/dashboard/client/components/+config-resource-quotas/resource-quota-details.scss b/dashboard/client/components/+config-resource-quotas/resource-quota-details.scss new file mode 100644 index 0000000000..fb3c678415 --- /dev/null +++ b/dashboard/client/components/+config-resource-quotas/resource-quota-details.scss @@ -0,0 +1,16 @@ +.ResourceQuotaDetails { + .quota-list { + .param { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding-bottom: $padding * 2; + + .LineProgress { + margin-top: 3px; + width: 100%; + color: $colorInfo; + } + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config-resource-quotas/resource-quota-details.tsx b/dashboard/client/components/+config-resource-quotas/resource-quota-details.tsx new file mode 100644 index 0000000000..6a2665e741 --- /dev/null +++ b/dashboard/client/components/+config-resource-quotas/resource-quota-details.tsx @@ -0,0 +1,94 @@ +import "./resource-quota-details.scss"; +import React from "react"; +import kebabCase from "lodash/kebabCase"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { cpuUnitsToNumber, cssNames, unitsToBytes } from "../../utils"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api"; +import { LineProgress } from "../line-progress"; +import { Table, TableCell, TableHead, TableRow } from "../table"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class ResourceQuotaDetails extends React.Component { + renderQuotas = (quota: ResourceQuota) => { + const { hard, used } = quota.status + if (!hard || !used) return null + const transformUnit = (name: string, value: string) => { + if (name.includes("memory") || name.includes("storage")) { + return unitsToBytes(value) + } + if (name.includes("cpu")) { + return cpuUnitsToNumber(value) + } + return parseInt(value) + } + return Object.entries(hard).map(([name, value]) => { + if (!used[name]) return null + const current = transformUnit(name, used[name]) + const max = transformUnit(name, value) + return ( +
+ {name} + {used[name]} / {value} + Set: {value}. Used: {Math.ceil(current / max * 100) + "%"}

+ } + /> +
+ ) + }) + } + + render() { + const { object: quota } = this.props; + if (!quota) return null; + return ( +
+ + + Quotas} className="quota-list"> + {this.renderQuotas(quota)} + + + {quota.getScopeSelector().length > 0 && ( + <> + Scope Selector}/> + + + Operator + Scope name + Values + + { + quota.getScopeSelector().map((selector, index) => { + const { operator, scopeName, values } = selector; + return ( + + {operator} + {scopeName} + {values.join(", ")} + + ); + }) + } +
+ + )} +
+ ); + } +} + +apiManager.registerViews(resourceQuotaApi, { + Details: ResourceQuotaDetails +}) \ No newline at end of file diff --git a/dashboard/client/components/+config-resource-quotas/resource-quotas.route.ts b/dashboard/client/components/+config-resource-quotas/resource-quotas.route.ts new file mode 100644 index 0000000000..1040f2dc09 --- /dev/null +++ b/dashboard/client/components/+config-resource-quotas/resource-quotas.route.ts @@ -0,0 +1,11 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const resourceQuotaRoute: RouteProps = { + path: "/resourcequotas" +} + +export interface IResourceQuotaRouteParams { +} + +export const resourceQuotaURL = buildURL(resourceQuotaRoute.path); diff --git a/dashboard/client/components/+config-resource-quotas/resource-quotas.scss b/dashboard/client/components/+config-resource-quotas/resource-quotas.scss new file mode 100644 index 0000000000..6f7b2d70c8 --- /dev/null +++ b/dashboard/client/components/+config-resource-quotas/resource-quotas.scss @@ -0,0 +1,2 @@ +.ResourceQuotas { +} \ No newline at end of file diff --git a/dashboard/client/components/+config-resource-quotas/resource-quotas.store.ts b/dashboard/client/components/+config-resource-quotas/resource-quotas.store.ts new file mode 100644 index 0000000000..6f5601ca00 --- /dev/null +++ b/dashboard/client/components/+config-resource-quotas/resource-quotas.store.ts @@ -0,0 +1,12 @@ +import { KubeObjectStore } from "../../kube-object.store"; +import { autobind } from "../../utils"; +import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class ResourceQuotasStore extends KubeObjectStore { + api = resourceQuotaApi +} + +export const resourceQuotaStore = new ResourceQuotasStore(); +apiManager.registerStore(resourceQuotaApi, resourceQuotaStore); diff --git a/dashboard/client/components/+config-resource-quotas/resource-quotas.tsx b/dashboard/client/components/+config-resource-quotas/resource-quotas.tsx new file mode 100644 index 0000000000..baa1840054 --- /dev/null +++ b/dashboard/client/components/+config-resource-quotas/resource-quotas.tsx @@ -0,0 +1,73 @@ +import "./resource-quotas.scss"; + +import * as React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { RouteComponentProps } from "react-router"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { KubeObjectListLayout } from "../kube-object"; +import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api"; +import { AddQuotaDialog } from "./add-quota-dialog"; +import { resourceQuotaStore } from "./resource-quotas.store"; +import { IResourceQuotaRouteParams } from "./resource-quotas.route"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + age = "age" +} + +interface Props extends RouteComponentProps { +} + +@observer +export class ResourceQuotas extends React.Component { + render() { + return ( + <> + item.getName(), + [sortBy.namespace]: (item: ResourceQuota) => item.getNs(), + [sortBy.age]: (item: ResourceQuota) => item.getAge(false), + }} + searchFilters={[ + (item: ResourceQuota) => item.getSearchFields(), + (item: ResourceQuota) => item.getName(), + ]} + renderHeaderTitle={Resource Quotas} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Age, className: "age", sortBy: sortBy.age }, + ]} + renderTableContents={(resourceQuota: ResourceQuota) => [ + resourceQuota.getName(), + resourceQuota.getNs(), + resourceQuota.getAge(), + ]} + renderItemMenu={(item: ResourceQuota) => { + return + }} + addRemoveButtons={{ + onAdd: () => AddQuotaDialog.open(), + addTooltip: Create new ResourceQuota + }} + /> + + + ); + } +} + +export function ResourceQuotaMenu(props: KubeObjectMenuProps) { + return ( + + ); +} + +apiManager.registerViews(resourceQuotaApi, { + Menu: ResourceQuotaMenu, +}) \ No newline at end of file diff --git a/dashboard/client/components/+config-secrets/add-secret-dialog.scss b/dashboard/client/components/+config-secrets/add-secret-dialog.scss new file mode 100644 index 0000000000..250c19f686 --- /dev/null +++ b/dashboard/client/components/+config-secrets/add-secret-dialog.scss @@ -0,0 +1,19 @@ +.AddSecretDialog { + --flex-gap: #{$margin * 1.5}; + + .Icon { + --color-active: black; + } + + .remove-icon { + flex: 0 0; + width: 18px; // icon size + } + + .fields-title { + display: grid; + grid-template-columns: min-content auto; + align-items: center; + gap: $margin / 2; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config-secrets/add-secret-dialog.tsx b/dashboard/client/components/+config-secrets/add-secret-dialog.tsx new file mode 100644 index 0000000000..b41c34ee3b --- /dev/null +++ b/dashboard/client/components/+config-secrets/add-secret-dialog.tsx @@ -0,0 +1,221 @@ +import "./add-secret-dialog.scss" + +import React from "react"; +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 { systemName } from "../input/input.validators"; +import { Secret, secretsApi, SecretType } from "../../api/endpoints"; +import { SubTitle } from "../layout/sub-title"; +import { NamespaceSelect } from "../+namespaces/namespace-select"; +import { Select, SelectOption } from "../select"; +import { Icon } from "../icon"; +import { IKubeObjectMetadata } from "../../api/kube-object"; +import { base64 } from "../../utils"; +import { Notifications } from "../notifications"; +import { showDetails } from "../../navigation"; +import upperFirst from "lodash/upperFirst"; + +interface Props extends Partial { +} + +interface ISecretTemplateField { + key: string; + value?: string; + required?: boolean; +} + +interface ISecretTemplate { + [field: string]: ISecretTemplateField[]; + annotations?: ISecretTemplateField[]; + labels?: ISecretTemplateField[]; + data?: ISecretTemplateField[]; +} + +type ISecretField = keyof ISecretTemplate; + +@observer +export class AddSecretDialog extends React.Component { + @observable static isOpen = false; + + static open() { + AddSecretDialog.isOpen = true; + } + + static close() { + AddSecretDialog.isOpen = false; + } + + private secretTemplate: { [p: string]: ISecretTemplate } = { + [SecretType.Opaque]: {}, + [SecretType.ServiceAccountToken]: { + annotations: [ + { key: "kubernetes.io/service-account.name", required: true }, + { key: "kubernetes.io/service-account.uid", required: true } + ], + }, + } + + get types() { + return Object.keys(this.secretTemplate) as SecretType[]; + } + + @observable secret = this.secretTemplate; + @observable name = ""; + @observable namespace = "default"; + @observable type = SecretType.Opaque; + + reset = () => { + this.name = ""; + this.secret = this.secretTemplate; + } + + close = () => { + AddSecretDialog.close(); + } + + private getDataFromFields = (fields: ISecretTemplateField[] = [], processValue?: (val: string) => string) => { + return fields.reduce((data, field) => { + const { key, value } = field; + if (key) { + data[key] = processValue ? processValue(value) : value; + } + return data; + }, {}) + } + + createSecret = async () => { + const { name, namespace, type } = this; + const { data = [], labels = [], annotations = [] } = this.secret[type]; + const secret: Partial = { + type: type, + data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""), + metadata: { + name: name, + namespace: namespace, + annotations: this.getDataFromFields(annotations), + labels: this.getDataFromFields(labels), + } as IKubeObjectMetadata + } + try { + const newSecret = await secretsApi.create({ namespace, name }, secret); + showDetails(newSecret.selfLink); + this.reset(); + this.close(); + } catch (err) { + Notifications.error(err); + } + } + + addField = (field: ISecretField) => { + const fields = this.secret[this.type][field] || []; + fields.push({ key: "", value: "" }); + this.secret[this.type][field] = fields; + } + + removeField = (field: ISecretField, index: number) => { + const fields = this.secret[this.type][field] || []; + fields.splice(index, 1); + } + + renderFields(field: ISecretField) { + const fields = this.secret[this.type][field] || []; + return ( + <> + + this.addField(field)} + /> + +
+ {fields.map((item, index) => { + const { key = "", value = "", required } = item; + return ( +
+ item.key = v} + /> + item.value = v} + /> + Required field : Remove field} + className="remove-icon" + material="remove_circle_outline" + onClick={() => this.removeField(field, index)} + /> +
+ ) + })} +
+ + ) + } + + render() { + const { ...dialogProps } = this.props; + const { namespace, name, type } = this; + const header =
Create Secret
; + return ( + + + Create} next={this.createSecret}> +
+ Secret name}/> + this.name = v} + /> +
+
+
+ Namespace}/> + this.namespace = value} + /> +
+
+ Secret type}/> + this.editData(name, value, !revealSecret)} + /> + {decodedVal && ( + Hide : Show} + onClick={() => this.revealSecret[name] = !revealSecret} + />) + } +
+
+ ) + }) + } +
+ ); + } +} + +apiManager.registerViews(secretsApi, { + Details: SecretDetails, +}) diff --git a/dashboard/client/components/+config-secrets/secrets.route.ts b/dashboard/client/components/+config-secrets/secrets.route.ts new file mode 100644 index 0000000000..1dcce30e2f --- /dev/null +++ b/dashboard/client/components/+config-secrets/secrets.route.ts @@ -0,0 +1,11 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const secretsRoute: RouteProps = { + path: "/secrets" +} + +export interface ISecretsRouteParams { +} + +export const secretsURL = buildURL(secretsRoute.path); diff --git a/dashboard/client/components/+config-secrets/secrets.scss b/dashboard/client/components/+config-secrets/secrets.scss new file mode 100644 index 0000000000..dac8b60cb5 --- /dev/null +++ b/dashboard/client/components/+config-secrets/secrets.scss @@ -0,0 +1,11 @@ +.Secrets { + .TableCell { + &.name { + flex: 1.5; + } + + &.labels { + @include table-cell-labels-offsets; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config-secrets/secrets.store.ts b/dashboard/client/components/+config-secrets/secrets.store.ts new file mode 100644 index 0000000000..18f3a3e5d3 --- /dev/null +++ b/dashboard/client/components/+config-secrets/secrets.store.ts @@ -0,0 +1,12 @@ +import { KubeObjectStore } from "../../kube-object.store"; +import { autobind } from "../../utils"; +import { Secret, secretsApi } from "../../api/endpoints"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class SecretsStore extends KubeObjectStore { + api = secretsApi +} + +export const secretsStore = new SecretsStore(); +apiManager.registerStore(secretsApi, secretsStore); diff --git a/dashboard/client/components/+config-secrets/secrets.tsx b/dashboard/client/components/+config-secrets/secrets.tsx new file mode 100644 index 0000000000..296a2a3459 --- /dev/null +++ b/dashboard/client/components/+config-secrets/secrets.tsx @@ -0,0 +1,86 @@ +import "./secrets.scss" + +import * as React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { RouteComponentProps } from "react-router"; +import { Secret, secretsApi } from "../../api/endpoints"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { AddSecretDialog } from "./add-secret-dialog"; +import { ISecretsRouteParams } from "./secrets.route"; +import { KubeObjectListLayout } from "../kube-object"; +import { Badge } from "../badge"; +import { secretsStore } from "./secrets.store"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + labels = "labels", + keys = "keys", + type = "type", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class Secrets extends React.Component { + render() { + return ( + <> + item.getName(), + [sortBy.namespace]: (item: Secret) => item.getNs(), + [sortBy.labels]: (item: Secret) => item.getLabels(), + [sortBy.keys]: (item: Secret) => item.getKeys(), + [sortBy.type]: (item: Secret) => item.type, + [sortBy.age]: (item: Secret) => item.getAge(false), + }} + searchFilters={[ + (item: Secret) => item.getSearchFields(), + (item: Secret) => item.getKeys(), + ]} + renderHeaderTitle={Secrets} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Labels, className: "labels", sortBy: sortBy.labels }, + { title: Keys, className: "keys", sortBy: sortBy.keys }, + { title: Type, className: "type", sortBy: sortBy.type }, + { title: Age, className: "age", sortBy: sortBy.age }, + ]} + renderTableContents={(secret: Secret) => [ + secret.getName(), + secret.getNs(), + secret.getLabels().map(label => ), + secret.getKeys().join(", "), + secret.type, + secret.getAge(), + ]} + renderItemMenu={(item: Secret) => { + return + }} + addRemoveButtons={{ + onAdd: () => AddSecretDialog.open(), + addTooltip: Create new Secret + }} + /> + + + ); + } +} + +export function SecretMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews(secretsApi, { + Menu: SecretMenu, +}) diff --git a/dashboard/client/components/+config/config.route.ts b/dashboard/client/components/+config/config.route.ts new file mode 100644 index 0000000000..9b114c510b --- /dev/null +++ b/dashboard/client/components/+config/config.route.ts @@ -0,0 +1,12 @@ +import { RouteProps } from "react-router"; +import { configMapsURL } from "../+config-maps"; +import { Config } from "./config"; +import { IURLParams } from "../../navigation"; + +export const configRoute: RouteProps = { + get path() { + return Config.tabRoutes.map(({ path }) => path).flat() + } +} + +export const configURL = (params?: IURLParams) => configMapsURL(params); diff --git a/dashboard/client/components/+config/config.tsx b/dashboard/client/components/+config/config.tsx new file mode 100644 index 0000000000..27149cc451 --- /dev/null +++ b/dashboard/client/components/+config/config.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { Redirect, Route, Switch } from "react-router"; +import { Trans } from "@lingui/macro"; +import { MainLayout, TabRoute } from "../layout/main-layout"; +import { ConfigMaps, configMapsRoute, configMapsURL } from "../+config-maps"; +import { Secrets, secretsRoute, secretsURL } from "../+config-secrets"; +import { namespaceStore } from "../+namespaces/namespace.store"; +import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config-resource-quotas"; +import { configURL } from "./config.route"; +import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers"; +import { Certificates, ClusterIssuers, Issuers } from "../+custom-resources/certmanager.k8s.io"; +import { buildURL } from "../../navigation"; + +export const certificatesURL = buildURL("/certificates"); +export const issuersURL = buildURL("/issuers"); +export const clusterIssuersURL = buildURL("/clusterissuers"); + +@observer +export class Config extends React.Component { + static get tabRoutes(): TabRoute[] { + const query = namespaceStore.getContextParams() + return [ + { + title: ConfigMaps, + component: ConfigMaps, + url: configMapsURL({ query }), + path: configMapsRoute.path, + }, + { + title: Secrets, + component: Secrets, + url: secretsURL({ query }), + path: secretsRoute.path, + }, + { + title: Resource Quotas, + component: ResourceQuotas, + url: resourceQuotaURL({ query }), + path: resourceQuotaRoute.path, + }, + { + title: Certificates, + component: Certificates, + url: certificatesURL({ query }), + path: certificatesURL(), + }, + { + title: Issuers, + component: Issuers, + url: issuersURL({ query }), + path: issuersURL(), + }, + { + title: Cluster Issuers, + component: ClusterIssuers, + url: clusterIssuersURL(), + path: clusterIssuersURL(), + }, + { + title: HPA, + component: HorizontalPodAutoscalers, + url: hpaURL({ query }), + path: hpaRoute.path, + }, + ] + } + + render() { + const tabRoutes = Config.tabRoutes; + return ( + + + {tabRoutes.map((route, index) => )} + + + + ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+config/index.ts b/dashboard/client/components/+config/index.ts new file mode 100644 index 0000000000..f3ab3691ce --- /dev/null +++ b/dashboard/client/components/+config/index.ts @@ -0,0 +1,2 @@ +export * from "./config.route" +export * from "./config" diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/cert-manager.mixins.scss b/dashboard/client/components/+custom-resources/certmanager.k8s.io/cert-manager.mixins.scss new file mode 100644 index 0000000000..3e816402a7 --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/cert-manager.mixins.scss @@ -0,0 +1,12 @@ +$cert-status-colors: ( + ready: $colorOk, +); + +@mixin cert-status-bgc { + @each $status, $color in $cert-status-colors { + &.#{$status} { + background: $color; + color: white; + } + } +} diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificate-details.scss b/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificate-details.scss new file mode 100644 index 0000000000..dfde9e7ad9 --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificate-details.scss @@ -0,0 +1,7 @@ +@import "cert-manager.mixins"; + +.CertificateDetails { + .Badge { + @include cert-status-bgc; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx b/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx new file mode 100644 index 0000000000..f7dea85968 --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx @@ -0,0 +1,142 @@ +import "./certificate-details.scss" + +import React from "react"; +import moment from "moment" +import { observer } from "mobx-react"; +import { Link } from "react-router-dom"; +import { Trans } from "@lingui/macro"; +import { DrawerItem, DrawerTitle } from "../../drawer"; +import { Badge } from "../../badge"; +import { KubeEventDetails } from "../../+events/kube-event-details"; +import { KubeObjectDetailsProps } from "../../kube-object"; +import { Certificate, certificatesApi } from "../../../api/endpoints/cert-manager.api"; +import { cssNames } from "../../../utils"; +import { apiManager } from "../../../api/api-manager"; +import { KubeObjectMeta } from "../../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class CertificateDetails extends React.Component { + render() { + const { object: cert, className } = this.props; + if (!cert) return; + const { spec, status } = cert; + const { acme, isCA, commonName, secretName, dnsNames, duration, ipAddresses, keyAlgorithm, keySize, organization, renewBefore } = spec; + const { lastFailureTime, notAfter } = status; + return ( +
+ + + Issuer}> + + {cert.getIssuerName()} + + + + Secret Name}> + + {secretName} + + + + + {isCA ? Yes : No} + + + {commonName && ( + Common Name}> + {commonName} + + )} + {dnsNames && ( + DNS names} labelsOnly> + {dnsNames.map(name => )} + + )} + {ipAddresses && ( + IP addresses}> + {ipAddresses.join(", ")} + + )} + {organization && ( + Organization}> + {organization.join(", ")} + + )} + {duration && ( + Duration}> + {duration} + + )} + {renewBefore && ( + Renew Before}> + {renewBefore} + + )} + {keySize && ( + Key Size}> + {keySize} + + )} + {keyAlgorithm && ( + Key Algorithm}> + {keyAlgorithm} + + )} + + Not After}> + {moment(notAfter).format("LLL")} + + + {lastFailureTime && ( + Last Failure Time}> + {lastFailureTime} + + )} + Status} labelsOnly> + {cert.getConditions().map(({ type, tooltip, isReady }) => { + return ( + + ) + })} + + + {acme && ( + <> + + {acme.config.map(({ domains, http01, dns01 }, index) => { + return ( +
+ Domains} labelsOnly> + {domains.map(domain => )} + + Http01}> + {Object.entries(http01).map(([key, val]) => `${key}: ${val}`)[0]} + + {dns01 && ( + DNS Provider} labelsOnly> + {dns01.provider} + + )} +
+ ) + })} + + )} + + +
+ ); + } +} + +apiManager.registerViews(certificatesApi, { + Details: CertificateDetails +}) \ No newline at end of file diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificates.scss b/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificates.scss new file mode 100644 index 0000000000..a5c817b2e8 --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificates.scss @@ -0,0 +1,26 @@ +@import "cert-manager.mixins"; + +.Certificates { + .TableCell { + &.name { + flex: 1.2; + } + + &.type { + flex: .5; + } + + &.status { + flex: .5; + @include table-cell-labels-offsets; + + .Badge { + @include cert-status-bgc; + } + } + + &.age { + flex: .5; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificates.tsx b/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificates.tsx new file mode 100644 index 0000000000..f198c8fd62 --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/certificates.tsx @@ -0,0 +1,105 @@ +import "./certificates.scss" + +import * as React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../../kube-object/kube-object-menu"; +import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../../kube-object"; +import { Certificate, certificatesApi } from "../../../api/endpoints/cert-manager.api"; +import { cssNames, stopPropagation } from "../../../utils"; +import { Link } from "react-router-dom"; +import { Badge } from "../../badge"; +import { apiManager } from "../../../api/api-manager"; +import { Spinner } from "../../spinner"; + +enum sortBy { + name = "name", + namespace = "namespace", + age = "age", + commonName = "common-name", + secretName = "secret", + issuer = "issuer", + type = "type", +} + +@observer +export class Certificates extends React.Component { + render() { + const { store = apiManager.getStore(certificatesApi), ...layoutProps } = this.props; + if (!store) { + return + } + return ( + item.getName(), + [sortBy.namespace]: (item: Certificate) => item.getNs(), + [sortBy.secretName]: (item: Certificate) => item.getSecretName(), + [sortBy.commonName]: (item: Certificate) => item.getCommonName(), + [sortBy.issuer]: (item: Certificate) => item.getIssuerName(), + [sortBy.type]: (item: Certificate) => item.getType(), + }} + searchFilters={[ + (item: Certificate) => item.getSearchFields(), + (item: Certificate) => item.getSecretName(), + (item: Certificate) => item.getCommonName(), + (item: Certificate) => item.getIssuerName(), + (item: Certificate) => item.getType(), + ]} + renderHeaderTitle={Certificates} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Common Name, className: "common-name", sortBy: sortBy.type }, + { title: Type, className: "type", sortBy: sortBy.type }, + { title: Issuer, className: "issuer", sortBy: sortBy.issuer }, + { title: Secret, className: "secret", sortBy: sortBy.secretName }, + { title: Age, className: "age", sortBy: sortBy.age }, + { title: Status, className: "status" }, + ]} + renderTableContents={(cert: Certificate) => { + return [ + cert.getName(), + cert.getNs(), + cert.getCommonName(), + cert.getType(), + + {cert.getIssuerName()} + , + + {cert.getSecretName()} + , + cert.getAge(), + cert.getConditions().map(({ type, tooltip, isReady }) => { + return ( + + ) + }) + ] + }} + renderItemMenu={(item: Certificate) => { + return + }} + /> + ); + } +} + +export function CertificateMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews(certificatesApi, { + List: Certificates, + Menu: CertificateMenu, +}) \ No newline at end of file diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/index.ts b/dashboard/client/components/+custom-resources/certmanager.k8s.io/index.ts new file mode 100644 index 0000000000..0c64f84126 --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/index.ts @@ -0,0 +1,4 @@ +export * from "./certificates" +export * from "./certificate-details" +export * from "./issuers" +export * from "./issuer-details" diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuer-details.scss b/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuer-details.scss new file mode 100644 index 0000000000..91df4d202a --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuer-details.scss @@ -0,0 +1,7 @@ +@import "cert-manager.mixins"; + +.IssuerDetails { + .Badge { + @include cert-status-bgc; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx b/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx new file mode 100644 index 0000000000..4bec178c7a --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx @@ -0,0 +1,175 @@ +import "./issuer-details.scss" + +import React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { Link } from "react-router-dom"; +import { DrawerItem, DrawerTitle } from "../../drawer"; +import { Badge } from "../../badge"; +import { KubeEventDetails } from "../../+events/kube-event-details"; +import { KubeObjectDetailsProps } from "../../kube-object"; +import { clusterIssuersApi, Issuer, issuersApi } from "../../../api/endpoints/cert-manager.api"; +import { autobind, cssNames } from "../../../utils"; +import { getDetailsUrl } from "../../../navigation"; +import { secretsApi } from "../../../api/endpoints"; +import { apiManager } from "../../../api/api-manager"; +import { KubeObjectMeta } from "../../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class IssuerDetails extends React.Component { + @autobind() + renderSecretLink(secretName: string) { + const namespace = this.props.object.getNs(); + if (!namespace) { + return secretName; + } + const secretDetailsUrl = getDetailsUrl(secretsApi.getUrl({ + namespace: namespace, + name: secretName, + })); + return ( + + {secretName} + + ) + } + + render() { + const { object: issuer, className } = this.props; + if (!issuer) return; + const { renderSecretLink } = this; + const { spec: { acme, ca, vault, venafi }, status } = issuer; + return ( +
+ + + Type}> + {issuer.getType()} + + + Status} labelsOnly> + {issuer.getConditions().map(({ type, tooltip, isReady }) => { + return ( + + ) + })} + + + {acme && (() => { + const { email, server, skipTLSVerify, privateKeySecretRef, solvers } = acme; + return ( + <> + + E-mail}> + {email} + + Server}> + {server} + + {status.acme && ( + Status URI}> + {status.acme.uri} + + )} + Private Key Secret}> + {renderSecretLink(privateKeySecretRef.name)} + + Skip TLS Verify}> + {skipTLSVerify ? Yes : No} + + + ) + })()} + + {ca && (() => { + const { secretName } = ca; + return ( + <> + + Secret Name}> + {renderSecretLink(secretName)} + + + ) + })()} + + {vault && (() => { + const { auth, caBundle, path, server } = vault; + const { path: authPath, roleId, secretRef } = auth.appRole; + return ( + <> + + Server}> + {server} + + Path}> + {path} + + CA Bundle} labelsOnly> + + + + Auth App Role}/> + Path}> + {authPath} + + Role ID}> + {roleId} + + {secretRef && ( + Secret}> + {renderSecretLink(secretRef.name)} + + )} + + ) + })()} + + {venafi && (() => { + const { zone, cloud, tpp } = venafi; + return ( + <> + + Zone}> + {zone} + + {cloud && ( + Cloud API Token Secret}> + {renderSecretLink(cloud.apiTokenSecretRef.name)} + + )} + {tpp && ( + <> + + URL}> + {tpp.url} + + CA Bundle} labelsOnly> + + + Credentials Ref}> + {renderSecretLink(tpp.credentialsRef.name)} + + + )} + + ) + })()} + + +
+ ); + } +} + +apiManager.registerViews([issuersApi, clusterIssuersApi], { + Details: IssuerDetails +}) diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuers.scss b/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuers.scss new file mode 100644 index 0000000000..21d0d81472 --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuers.scss @@ -0,0 +1,27 @@ +@import "cert-manager.mixins"; + +.Issuers { + .TableCell { + &.name { + flex: 1.2; + } + + &.labels { + flex: 2; + @include table-cell-labels-offsets; + } + + &.status { + flex: .5; + @include table-cell-labels-offsets; + + .Badge { + @include cert-status-bgc; + } + } + + &.age { + flex: .5; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuers.tsx b/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuers.tsx new file mode 100644 index 0000000000..75f256037a --- /dev/null +++ b/dashboard/client/components/+custom-resources/certmanager.k8s.io/issuers.tsx @@ -0,0 +1,103 @@ +import "./issuers.scss" + +import * as React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../../kube-object/kube-object-menu"; +import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../../kube-object"; +import { clusterIssuersApi, Issuer, issuersApi } from "../../../api/endpoints/cert-manager.api"; +import { cssNames } from "../../../utils"; +import { Badge } from "../../badge"; +import { Spinner } from "../../spinner"; +import { apiManager } from "../../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + type = "type", + labels = "labels", + age = "age", +} + +@observer +export class ClusterIssuers extends React.Component { + render() { + const store = apiManager.getStore(clusterIssuersApi); + return ( + Cluster Issuers} + /> + ) + } +} + +@observer +export class Issuers extends React.Component { + render() { + const { store = apiManager.getStore(issuersApi), ...layoutProps } = this.props; + if (!store) { + return + } + return ( + Issuers} + {...layoutProps} + className="Issuers" + sortingCallbacks={{ + [sortBy.name]: (item: Issuer) => item.getName(), + [sortBy.namespace]: (item: Issuer) => item.getNs(), + [sortBy.type]: (item: Issuer) => item.getType(), + [sortBy.labels]: (item: Issuer) => item.getLabels(), + [sortBy.age]: (item: Issuer) => item.getAge(false), + }} + searchFilters={[ + (item: Issuer) => item.getSearchFields(), + (item: Issuer) => item.getType(), + ]} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Labels, className: "labels", sortBy: sortBy.labels }, + { title: Type, className: "type", sortBy: sortBy.type }, + { title: Age, className: "age", sortBy: sortBy.age }, + { title: Status, className: "status" }, + ]} + renderTableContents={(issuer: Issuer) => [ + issuer.getName(), + issuer.getNs(), + issuer.getLabels().map(label => ), + issuer.getType(), + issuer.getAge(), + issuer.getConditions().map(({ type, tooltip, isReady }) => { + return ( + + ) + }) + ]} + renderItemMenu={(item: Issuer) => { + return + }} + /> + ); + } +} + +export function IssuerMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews([issuersApi, clusterIssuersApi], { + List: Issuers, + Menu: IssuerMenu, +}) diff --git a/dashboard/client/components/+custom-resources/crd-details.scss b/dashboard/client/components/+custom-resources/crd-details.scss new file mode 100644 index 0000000000..11b1f6e4f3 --- /dev/null +++ b/dashboard/client/components/+custom-resources/crd-details.scss @@ -0,0 +1,45 @@ +@import "crd.mixins"; + +.CRDDetails { + .conditions { + .Badge { + @include crd-condition-bgc; + } + } + + .Table { + margin-left: -$margin * 2; + margin-right: -$margin * 2; + + .TableRow { + cursor: default; + } + + &.printer-columns { + .type { + flex: 0.5; + } + + .description { + flex: 1.5; + + .Badge { + background: transparent; + } + } + + .json-path { + flex: 3; + + .Badge { + white-space: pre-line; + text-overflow: initial; + } + } + } + } + + .validation { + height: 400px; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+custom-resources/crd-details.tsx b/dashboard/client/components/+custom-resources/crd-details.tsx new file mode 100644 index 0000000000..5200e032b9 --- /dev/null +++ b/dashboard/client/components/+custom-resources/crd-details.tsx @@ -0,0 +1,138 @@ +import "./crd-details.scss"; + +import * as React from "react"; +import { Trans } from "@lingui/macro"; +import { Link } from "react-router-dom"; +import { observer } from "mobx-react"; +import { apiManager } from "../../api/api-manager"; +import { crdApi, CustomResourceDefinition } from "../../api/endpoints/crd.api"; +import { cssNames } from "../../utils"; +import { AceEditor } from "../ace-editor"; +import { Badge } from "../badge"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { Table, TableCell, TableHead, TableRow } from "../table"; +import { Input } from "../input"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class CRDDetails extends React.Component { + render() { + const { object: crd } = this.props; + if (!crd) return null; + const { plural, singular, kind, listKind } = crd.getNames(); + const printerColumns = crd.getPrinterColumns(); + const validation = crd.getValidation(); + return ( +
+ + + Group}> + {crd.getGroup()} + + Version}> + {crd.getVersion()} + + Stored versions}> + {crd.getStoredVersions()} + + Scope}> + {crd.getScope()} + + Resource}> + + {crd.getResourceTitle()} + + + Conversion} className="flex gaps align-flex-start"> + + + Conditions} className="conditions" labelsOnly> + { + crd.getConditions().map(condition => { + const { type, message, lastTransitionTime, status } = condition + return ( + +

{message}

+

Last transition time: {lastTransitionTime}

+ + )} + /> + ); + }) + } +
+ Names}/> + + + plural + singular + kind + listKind + + + {plural} + {singular} + {kind} + {listKind} + +
+ {printerColumns.length > 0 && + <> + Additional Printer Columns}/> + + + Name + Type + JSON Path + + { + printerColumns.map((column, index) => { + const { name, type, JSONPath } = column; + return ( + + {name} + {type} + + + + + ) + }) + } +
+ + } + {validation && + <> + Validation}/> + + + } +
+ ) + } +} + +apiManager.registerViews(crdApi, { + Details: CRDDetails +}) \ No newline at end of file diff --git a/dashboard/client/components/+custom-resources/crd-list.scss b/dashboard/client/components/+custom-resources/crd-list.scss new file mode 100644 index 0000000000..07221c7c58 --- /dev/null +++ b/dashboard/client/components/+custom-resources/crd-list.scss @@ -0,0 +1,24 @@ +@import "crd.mixins"; + +.CrdList { + .TableCell { + &.kind { + flex: 1.5; + } + + &.group { + flex: 2; + } + } + + .group-select { + .Select { + &__placeholder { + width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+custom-resources/crd-list.tsx b/dashboard/client/components/+custom-resources/crd-list.tsx new file mode 100644 index 0000000000..5707f27c9d --- /dev/null +++ b/dashboard/client/components/+custom-resources/crd-list.tsx @@ -0,0 +1,122 @@ +import "./crd-list.scss" + +import React from "react"; +import { Trans } from "@lingui/macro"; +import { computed } from "mobx"; +import { observer } from "mobx-react"; +import { Link } from "react-router-dom"; +import { stopPropagation } from "../../utils"; +import { KubeObjectListLayout } from "../kube-object"; +import { crdStore } from "./crd.store"; +import { apiManager } from "../../api/api-manager"; +import { crdApi, CustomResourceDefinition } from "../../api/endpoints/crd.api"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { Select, SelectOption } from "../select"; +import { navigation, setQueryParams } from "../../navigation"; +import { Icon } from "../icon"; + +enum sortBy { + kind = "kind", + group = "group", + version = "version", + scope = "scope", + age = "age", +} + +@observer +export class CrdList extends React.Component { + @computed get groups() { + return navigation.searchParams.getAsArray("groups") + } + + onGroupChange(group: string) { + const groups = [...this.groups]; + const index = groups.findIndex(item => item == group); + if (index !== -1) groups.splice(index, 1); + else groups.push(group); + setQueryParams({ groups }) + } + + render() { + const selectedGroups = this.groups; + const sortingCallbacks = { + [sortBy.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(), + [sortBy.group]: (crd: CustomResourceDefinition) => crd.getGroup(), + [sortBy.version]: (crd: CustomResourceDefinition) => crd.getVersion(), + [sortBy.scope]: (crd: CustomResourceDefinition) => crd.getScope(), + }; + return ( + { + return selectedGroups.length ? items.filter(item => selectedGroups.includes(item.getGroup())) : items + } + ]} + renderHeaderTitle={Custom Resources} + customizeHeader={() => { + let placeholder = All groups; + if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]} + if (selectedGroups.length >= 2) placeholder = <>Groups: {selectedGroups.join(", ")} + return { + // fixme: move to global filters + filters: ( + this.namespace = v.toLowerCase()} + /> + + + + ) + } +} diff --git a/dashboard/client/components/+namespaces/index.ts b/dashboard/client/components/+namespaces/index.ts new file mode 100644 index 0000000000..d0b9647dc7 --- /dev/null +++ b/dashboard/client/components/+namespaces/index.ts @@ -0,0 +1,4 @@ +export * from "./namespaces.route" +export * from "./namespaces" +export * from "./namespace-details" +export * from "./add-namespace-dialog" diff --git a/dashboard/client/components/+namespaces/namespace-details.scss b/dashboard/client/components/+namespaces/namespace-details.scss new file mode 100644 index 0000000000..09220bc8dd --- /dev/null +++ b/dashboard/client/components/+namespaces/namespace-details.scss @@ -0,0 +1,11 @@ +.NamespaceDetails { + .status { + @include namespaceStatus; + } + + .quotas { + .value a { + margin-right: $margin; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+namespaces/namespace-details.tsx b/dashboard/client/components/+namespaces/namespace-details.tsx new file mode 100644 index 0000000000..0b46c6bbca --- /dev/null +++ b/dashboard/client/components/+namespaces/namespace-details.tsx @@ -0,0 +1,61 @@ +import "./namespace-details.scss"; + +import * as React from "react"; +import { computed } from "mobx"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { DrawerItem } from "../drawer"; +import { cssNames } from "../../utils"; +import { Namespace, namespacesApi } from "../../api/endpoints"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { Link } from "react-router-dom"; +import { getDetailsUrl } from "../../navigation"; +import { Spinner } from "../spinner"; +import { resourceQuotaStore } from "../+config-resource-quotas/resource-quotas.store"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class NamespaceDetails extends React.Component { + @computed get quotas() { + const namespace = this.props.object.getName(); + return resourceQuotaStore.getAllByNs(namespace); + } + + componentDidMount() { + resourceQuotaStore.loadAll(); + } + + render() { + const { object: namespace } = this.props; + if (!namespace) return; + const status = namespace.getStatus(); + return ( +
+ + + Status}> + {status} + + + Resource Quotas} className="quotas flex align-center"> + {!this.quotas && resourceQuotaStore.isLoading && } + {this.quotas.map(quota => { + return ( + + {quota.getName()} + + ); + })} + +
+ ); + } +} + +apiManager.registerViews(namespacesApi, { + Details: NamespaceDetails +}); \ No newline at end of file diff --git a/dashboard/client/components/+namespaces/namespace-select.scss b/dashboard/client/components/+namespaces/namespace-select.scss new file mode 100644 index 0000000000..f56b8005cc --- /dev/null +++ b/dashboard/client/components/+namespaces/namespace-select.scss @@ -0,0 +1,22 @@ +@mixin namespaceSelectCommon { + .Icon { + margin-right: $margin / 2; + } +} + +.NamespaceSelect { + @include namespaceSelectCommon; + + .Select { + &__placeholder { + width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + } +} + +.NamespaceSelectMenu { + @include namespaceSelectCommon; +} \ No newline at end of file diff --git a/dashboard/client/components/+namespaces/namespace-select.tsx b/dashboard/client/components/+namespaces/namespace-select.tsx new file mode 100644 index 0000000000..30ac5ca081 --- /dev/null +++ b/dashboard/client/components/+namespaces/namespace-select.tsx @@ -0,0 +1,105 @@ +import "./namespace-select.scss" + +import React from "react"; +import { computed } from "mobx"; +import { observer } from "mobx-react"; +import { t, Trans } from "@lingui/macro"; +import { Select, SelectOption, SelectProps } from "../select"; +import { cssNames, noop } from "../../utils"; +import { Icon } from "../icon"; +import { namespaceStore } from "./namespace.store"; +import { _i18n } from "../../i18n"; +import { FilterIcon } from "../item-object-list/filter-icon"; +import { FilterType } from "../item-object-list/page-filters.store"; + +interface Props extends SelectProps { + showIcons?: boolean; + showClusterOption?: boolean; // show cluster option on the top (default: false) + clusterOptionLabel?: React.ReactNode; // label for cluster option (default: "Cluster") + customizeOptions?(nsOptions: SelectOption[]): SelectOption[]; +} + +const defaultProps: Partial = { + showIcons: true, + showClusterOption: false, + get clusterOptionLabel() { + return _i18n._(t`Cluster`); + }, +}; + +@observer +export class NamespaceSelect extends React.Component { + static defaultProps = defaultProps as object; + private unsubscribe = noop; + + async componentDidMount() { + if (!namespaceStore.isLoaded) await namespaceStore.loadAll(); + this.unsubscribe = namespaceStore.subscribe(); + } + + componentWillUnmount() { + this.unsubscribe(); + } + + @computed get options(): SelectOption[] { + const { customizeOptions, showClusterOption, clusterOptionLabel } = this.props; + let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); + options = customizeOptions ? customizeOptions(options) : options; + if (showClusterOption) { + options.unshift({ value: null, label: clusterOptionLabel }); + } + return options; + } + + formatOptionLabel = (option: SelectOption) => { + const { showIcons } = this.props; + const { value, label } = option; + return label || ( + <> + {showIcons && } + {value} + + ); + } + + render() { + const { className, showIcons, showClusterOption, clusterOptionLabel, customizeOptions, ...selectProps } = this.props; + return ( + this.selectedRoleId = value} + /> + { + !this.isEditing && ( + <> + Use same name for RoleBinding} + value={this.useRoleForBindingName} + onChange={v => this.useRoleForBindingName = v} + /> + { + !this.useRoleForBindingName && ( + this.bindingName = v} + /> + ) + } + + ) + } + + Binding targets}/> + this.roleName = v} + /> + + + + ) + } +} diff --git a/dashboard/client/components/+user-management-roles/index.ts b/dashboard/client/components/+user-management-roles/index.ts new file mode 100644 index 0000000000..dd5752f4be --- /dev/null +++ b/dashboard/client/components/+user-management-roles/index.ts @@ -0,0 +1,3 @@ +export * from "./roles" +export * from "./role-details" +export * from "./add-role-dialog" diff --git a/dashboard/client/components/+user-management-roles/role-details.scss b/dashboard/client/components/+user-management-roles/role-details.scss new file mode 100644 index 0000000000..b7e3367dd2 --- /dev/null +++ b/dashboard/client/components/+user-management-roles/role-details.scss @@ -0,0 +1,21 @@ +.RoleDetails { + .rule { + display: grid; + grid-template-columns: min-content auto; + gap: $margin; + + border: 1px solid $borderColor; + border-radius: $radius; + padding: $padding * 1.5; + + > .name { + color: $textColorSecondary; + text-align: right; + white-space: nowrap; + } + + &:not(:last-child) { + margin-bottom: $margin * 2; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+user-management-roles/role-details.tsx b/dashboard/client/components/+user-management-roles/role-details.tsx new file mode 100644 index 0000000000..ac13d538a9 --- /dev/null +++ b/dashboard/client/components/+user-management-roles/role-details.tsx @@ -0,0 +1,71 @@ +import "./role-details.scss" + +import React from "react"; +import { Trans } from "@lingui/macro"; +import { DrawerTitle } from "../drawer"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { observer } from "mobx-react"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { clusterRoleApi, Role, roleApi } from "../../api/endpoints"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class RoleDetails extends React.Component { + render() { + const { object: role } = this.props; + if (!role) return; + const rules = role.getRules(); + return ( +
+ + + Rules}/> + {rules.map(({ resourceNames, apiGroups, resources, verbs }, index) => { + return ( +
+ {resources && ( + <> +
Resources
+
{resources.join(", ")}
+ + )} + {verbs && ( + <> +
Verbs
+
{verbs.join(", ")}
+ + )} + {apiGroups && ( + <> +
Api Groups
+
+ {apiGroups + .map(apiGroup => apiGroup === "" ? `'${apiGroup}'` : apiGroup) + .join(", ") + } +
+ + )} + {resourceNames && ( + <> +
Resource Names
+
{resourceNames.join(", ")}
+ + )} +
+ ) + })} + + +
+ ) + } +} + +apiManager.registerViews([roleApi, clusterRoleApi], { + Details: RoleDetails, +}) \ No newline at end of file diff --git a/dashboard/client/components/+user-management-roles/roles.scss b/dashboard/client/components/+user-management-roles/roles.scss new file mode 100644 index 0000000000..1d895d01d5 --- /dev/null +++ b/dashboard/client/components/+user-management-roles/roles.scss @@ -0,0 +1,5 @@ +.Roles { + .help-icon { + margin-left: $margin / 2; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+user-management-roles/roles.store.ts b/dashboard/client/components/+user-management-roles/roles.store.ts new file mode 100644 index 0000000000..90658b8339 --- /dev/null +++ b/dashboard/client/components/+user-management-roles/roles.store.ts @@ -0,0 +1,51 @@ +import { clusterRoleApi, Role, roleApi } from "../../api/endpoints"; +import { autobind } from "../../utils"; +import { KubeObjectStore } from "../../kube-object.store"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class RolesStore extends KubeObjectStore { + api = clusterRoleApi + + subscribe() { + return super.subscribe([roleApi, clusterRoleApi]) + } + + protected sortItems(items: Role[]) { + return super.sortItems(items, [ + role => role.kind, + role => role.getName(), + ]) + } + + protected loadItem(params: { name: string; namespace?: string }) { + if (params.namespace) return roleApi.get(params) + return clusterRoleApi.get(params) + } + + protected loadItems(namespaces?: string[]): Promise { + if (namespaces) { + return Promise.all( + namespaces.map(namespace => roleApi.list({ namespace })) + ).then(items => items.flat()) + } + else { + return Promise.all([clusterRoleApi.list(), roleApi.list()]) + .then(items => items.flat()) + } + } + + protected async createItem(params: { name: string; namespace?: string }, data?: Partial) { + if (params.namespace) { + return roleApi.create(params, data) + } + else { + return clusterRoleApi.create(params, data) + } + } +} + +export const rolesStore = new RolesStore(); + +apiManager.registerStore(roleApi, rolesStore); +apiManager.registerStore(clusterRoleApi, rolesStore); diff --git a/dashboard/client/components/+user-management-roles/roles.tsx b/dashboard/client/components/+user-management-roles/roles.tsx new file mode 100644 index 0000000000..318dce6ef5 --- /dev/null +++ b/dashboard/client/components/+user-management-roles/roles.tsx @@ -0,0 +1,91 @@ +import "./roles.scss" + +import * as React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { RouteComponentProps } from "react-router"; +import { IRolesRouteParams } from "../+user-management/user-management.routes"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { rolesStore } from "./roles.store"; +import { clusterRoleApi, Role, roleApi } from "../../api/endpoints"; +import { KubeObjectListLayout } from "../kube-object"; +import { AddRoleDialog } from "./add-role-dialog"; +import { Icon } from "../icon"; +import { KubeObject } from "../../api/kube-object"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class Roles extends React.Component { + render() { + return ( + <> + role.getName(), + [sortBy.namespace]: (role: Role) => role.getNs(), + [sortBy.age]: (role: Role) => role.getAge(false), + }} + searchFilters={[ + (role: Role) => role.getSearchFields(), + ]} + filterItems={[ + (items: Role[]) => items.filter(KubeObject.isNonSystem), + ]} + renderHeaderTitle={Roles} + customizeHeader={({ info }) => ({ + info: ( + <> + {info} + Excluded items with "system:" prefix} + /> + + ) + })} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Age, className: "age", sortBy: sortBy.age }, + ]} + renderTableContents={(role: Role) => [ + role.getName(), + role.getNs() || "-", + role.getAge(), + ]} + renderItemMenu={(item: Role) => { + return + }} + addRemoveButtons={{ + onAdd: () => AddRoleDialog.open(), + addTooltip: Create new Role, + }} + /> + + + ) + } +} + +export function RoleMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews([roleApi, clusterRoleApi], { + Menu: RoleMenu, +}); diff --git a/dashboard/client/components/+user-management-service-accounts/create-service-account-dialog.scss b/dashboard/client/components/+user-management-service-accounts/create-service-account-dialog.scss new file mode 100644 index 0000000000..8eea40cfd1 --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/create-service-account-dialog.scss @@ -0,0 +1,2 @@ +.CreateServiceAccountDialog { +} \ No newline at end of file diff --git a/dashboard/client/components/+user-management-service-accounts/create-service-account-dialog.tsx b/dashboard/client/components/+user-management-service-accounts/create-service-account-dialog.tsx new file mode 100644 index 0000000000..13fe372e6b --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/create-service-account-dialog.tsx @@ -0,0 +1,83 @@ +import "./create-service-account-dialog.scss"; + +import * as React from "react"; +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 { SubTitle } from "../layout/sub-title"; +import { serviceAccountsStore } from "./service-accounts.store"; +import { Input } from "../input"; +import { systemName } from "../input/input.validators"; +import { NamespaceSelect } from "../+namespaces/namespace-select"; +import { Notifications } from "../notifications"; +import { showDetails } from "../../navigation"; + +interface Props extends Partial { +} + +@observer +export class CreateServiceAccountDialog extends React.Component { + @observable static isOpen = false; + + @observable name = "" + @observable namespace = "default" + + static open() { + CreateServiceAccountDialog.isOpen = true; + } + + static close() { + CreateServiceAccountDialog.isOpen = false; + } + + close = () => { + CreateServiceAccountDialog.close(); + } + + createAccount = async () => { + const { name, namespace } = this; + try { + const serviceAccount = await serviceAccountsStore.create({ namespace, name }); + this.name = ""; + showDetails(serviceAccount.selfLink); + this.close(); + } catch (err) { + Notifications.error(err); + } + } + + render() { + const { ...dialogProps } = this.props; + const { name, namespace } = this; + const header =
Create Service Account
+ return ( + + + Create} next={this.createAccount}> + Account Name}/> + this.name = v.toLowerCase()} + /> + Namespace}/> + this.namespace = value} + /> + + + + ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+user-management-service-accounts/index.ts b/dashboard/client/components/+user-management-service-accounts/index.ts new file mode 100644 index 0000000000..fb40e7e15e --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/index.ts @@ -0,0 +1,3 @@ +export * from "./service-accounts" +export * from "./service-accounts-details" +export * from "./create-service-account-dialog" \ No newline at end of file diff --git a/dashboard/client/components/+user-management-service-accounts/service-accounts-details.scss b/dashboard/client/components/+user-management-service-accounts/service-accounts-details.scss new file mode 100644 index 0000000000..61b5ecbf8d --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/service-accounts-details.scss @@ -0,0 +1,7 @@ +.ServiceAccountsDetails { + .links { + a { + margin-right: $margin; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+user-management-service-accounts/service-accounts-details.tsx b/dashboard/client/components/+user-management-service-accounts/service-accounts-details.tsx new file mode 100644 index 0000000000..abe3abbc8d --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/service-accounts-details.tsx @@ -0,0 +1,103 @@ +import "./service-accounts-details.scss"; + +import * as React from "react"; +import { autorun, observable } from "mobx"; +import { Trans } from "@lingui/macro"; +import { Spinner } from "../spinner"; +import { ServiceAccountsSecret } from "./service-accounts-secret"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { secretsStore } from "../+config-secrets/secrets.store"; +import { Link } from "react-router-dom"; +import { Secret, ServiceAccount, serviceAccountsApi } from "../../api/endpoints"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { getDetailsUrl } from "../../navigation"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class ServiceAccountsDetails extends React.Component { + @observable secrets: Secret[]; + + @disposeOnUnmount + loadSecrets = autorun(async () => { + this.secrets = null; + const { object: serviceAccount } = this.props; + if (!serviceAccount) { + return; + } + const namespace = serviceAccount.getNs(); + const secrets = serviceAccount.getSecrets().map(({ name }) => { + const secret = secretsStore.getByName(name, namespace); + if (!secret) return secretsStore.load({ name, namespace }); + return secret; + }); + this.secrets = await Promise.all(secrets); + }) + + renderSecrets() { + const { secrets } = this; + if (!secrets) { + return + } + return secrets.map(secret => + + ) + } + + renderSecretLinks(secrets: Secret[]) { + return secrets.map(secret => { + return ( + + {secret.getName()} + + ) + } + ) + } + + render() { + const { object: serviceAccount } = this.props; + if (!serviceAccount) { + return null; + } + const tokens = secretsStore.items.filter(secret => + secret.getNs() == serviceAccount.getNs() && + secret.getAnnotations().some(annot => annot == `kubernetes.io/service-account.name: ${serviceAccount.getName()}`) + ) + const imagePullSecrets = serviceAccount.getImagePullSecrets().map(({ name }) => + secretsStore.getByName(name, serviceAccount.getNs()) + ) + return ( +
+ + + {tokens.length > 0 && + Tokens} className="links"> + {this.renderSecretLinks(tokens)} + + } + {imagePullSecrets.length > 0 && + ImagePullSecrets} className="links"> + {this.renderSecretLinks(imagePullSecrets)} + + } + + Mountable secrets}/> +
+ {this.renderSecrets()} +
+ + +
+ ) + } +} + +apiManager.registerViews(serviceAccountsApi, { + Details: ServiceAccountsDetails +}) \ No newline at end of file diff --git a/dashboard/client/components/+user-management-service-accounts/service-accounts-secret.scss b/dashboard/client/components/+user-management-service-accounts/service-accounts-secret.scss new file mode 100644 index 0000000000..89d42bc362 --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/service-accounts-secret.scss @@ -0,0 +1,40 @@ +.ServiceAccountsSecret { + margin-bottom: $margin * 3; + + &:nth-child(even) { + margin-left: -$margin * 3; + margin-right: -$margin * 3; + padding: $padding $padding * 3; + } + + .secret-row { + display: flex; + border-bottom: 1px solid $borderFaintColor; + padding: $padding 0; + + &:first-child { + padding-top: 0 + } + } + + .name { + flex-basis: 23%; + color: $drawerItemNameColor; + } + + .value { + flex-basis: 76%; + color: $drawerItemValueColor; + word-break: break-all; + + &:empty:after { + content: '—' + } + } + + .asterisks { + font-size: large; + margin-right: $margin / 2; + line-height: 90%; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+user-management-service-accounts/service-accounts-secret.tsx b/dashboard/client/components/+user-management-service-accounts/service-accounts-secret.tsx new file mode 100644 index 0000000000..0e63c80936 --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/service-accounts-secret.tsx @@ -0,0 +1,70 @@ +import "./service-accounts-secret.scss" + +import * as React from "react"; +import * as moment from "moment"; +import { Trans } from "@lingui/macro"; +import { Icon } from "../icon"; +import { Secret } from "../../api/endpoints/secret.api"; +import { prevDefault } from "../../utils"; + +interface Props { + secret: Secret; +} + +interface State { + showToken: boolean; +} + +export class ServiceAccountsSecret extends React.Component { + public state: State = { + showToken: false, + } + + renderSecretValue() { + const { secret } = this.props + const { showToken } = this.state + return ( + <> + {!showToken && ( + <> + {Array(16).fill("•").join("")} + Show value} + onClick={prevDefault(() => this.setState({ showToken: true }))} + /> + + )} + {showToken && ( + {secret.getToken()} + )} + + ) + } + + render() { + const { metadata: { name, creationTimestamp }, type } = this.props.secret; + return ( +
+
+ Name: + {name} +
+
+ Value: + {this.renderSecretValue()} +
+
+ Created at: + + {moment(creationTimestamp).format("LLL")} + +
+
+ Type: + {type} +
+
+ ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+user-management-service-accounts/service-accounts.scss b/dashboard/client/components/+user-management-service-accounts/service-accounts.scss new file mode 100644 index 0000000000..26ce52022e --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/service-accounts.scss @@ -0,0 +1,2 @@ +.ServiceAccounts { +} \ No newline at end of file diff --git a/dashboard/client/components/+user-management-service-accounts/service-accounts.store.ts b/dashboard/client/components/+user-management-service-accounts/service-accounts.store.ts new file mode 100644 index 0000000000..a0b387508f --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/service-accounts.store.ts @@ -0,0 +1,17 @@ +import { autobind } from "../../utils"; +import { ServiceAccount, serviceAccountsApi } from "../../api/endpoints"; +import { KubeObjectStore } from "../../kube-object.store"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class ServiceAccountsStore extends KubeObjectStore { + api = serviceAccountsApi + + protected async createItem(params: { name: string; namespace?: string }) { + await super.createItem(params); + return this.api.get(params); // hackfix: load freshly created account, cause it doesn't have "secrets" field yet + } +} + +export const serviceAccountsStore = new ServiceAccountsStore(); +apiManager.registerStore(serviceAccountsApi, serviceAccountsStore); diff --git a/dashboard/client/components/+user-management-service-accounts/service-accounts.tsx b/dashboard/client/components/+user-management-service-accounts/service-accounts.tsx new file mode 100644 index 0000000000..a7a8d07069 --- /dev/null +++ b/dashboard/client/components/+user-management-service-accounts/service-accounts.tsx @@ -0,0 +1,81 @@ +import "./service-accounts.scss"; + +import * as React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { ServiceAccount, serviceAccountsApi } from "../../api/endpoints/service-accounts.api"; +import { RouteComponentProps } from "react-router"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { MenuItem } from "../menu"; +import { openServiceAccountKubeConfig } from "../kubeconfig-dialog"; +import { Icon } from "../icon"; +import { KubeObjectListLayout } from "../kube-object"; +import { IServiceAccountsRouteParams } from "../+user-management"; +import { serviceAccountsStore } from "./service-accounts.store"; +import { CreateServiceAccountDialog } from "./create-service-account-dialog"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class ServiceAccounts extends React.Component { + render() { + return ( + <> + account.getName(), + [sortBy.namespace]: (account: ServiceAccount) => account.getNs(), + [sortBy.age]: (account: ServiceAccount) => account.getAge(false), + }} + searchFilters={[ + (account: ServiceAccount) => account.getSearchFields(), + ]} + renderHeaderTitle={Service Accounts} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Age, className: "age", sortBy: sortBy.age }, + ]} + renderTableContents={(account: ServiceAccount) => [ + account.getName(), + account.getNs(), + account.getAge(), + ]} + renderItemMenu={(item: ServiceAccount) => { + return + }} + addRemoveButtons={{ + onAdd: () => CreateServiceAccountDialog.open(), + addTooltip: Create new Service Account, + }} + /> + + + ) + } +} + +export function ServiceAccountMenu(props: KubeObjectMenuProps) { + const { object, toolbar } = props; + return ( + + openServiceAccountKubeConfig(object)}> + + Kubeconfig + + + ) +} + +apiManager.registerViews(serviceAccountsApi, { + Menu: ServiceAccountMenu, +}) \ No newline at end of file diff --git a/dashboard/client/components/+user-management/index.ts b/dashboard/client/components/+user-management/index.ts new file mode 100644 index 0000000000..f9b243aa50 --- /dev/null +++ b/dashboard/client/components/+user-management/index.ts @@ -0,0 +1,2 @@ +export * from "./user-management" +export * from "./user-management.routes" \ No newline at end of file diff --git a/dashboard/client/components/+user-management/user-management.routes.ts b/dashboard/client/components/+user-management/user-management.routes.ts new file mode 100644 index 0000000000..77493e46d8 --- /dev/null +++ b/dashboard/client/components/+user-management/user-management.routes.ts @@ -0,0 +1,38 @@ +import { RouteProps } from "react-router"; +import { UserManagement } from "./user-management" +import { buildURL, IURLParams } from "../../navigation"; + +export const usersManagementRoute: RouteProps = { + get path() { + return UserManagement.tabRoutes.map(({ path }) => path).flat() + } +} + +// Routes +export const serviceAccountsRoute: RouteProps = { + path: "/service-accounts" +} +export const rolesRoute: RouteProps = { + path: "/roles" +} +export const roleBindingsRoute: RouteProps = { + path: "/role-bindings" +} + +// Route params +export interface IServiceAccountsRouteParams { +} + +export interface IRoleBindingsRouteParams { +} + +export interface IRolesRouteParams { +} + +// URL-builders +export const serviceAccountsURL = buildURL(serviceAccountsRoute.path) +export const roleBindingsURL = buildURL(roleBindingsRoute.path) +export const rolesURL = buildURL(rolesRoute.path) +export const usersManagementURL = (params?: IURLParams) => { + return serviceAccountsURL(params); +}; diff --git a/dashboard/client/components/+user-management/user-management.scss b/dashboard/client/components/+user-management/user-management.scss new file mode 100644 index 0000000000..11ee9aae07 --- /dev/null +++ b/dashboard/client/components/+user-management/user-management.scss @@ -0,0 +1,2 @@ +.UserManagement { +} \ No newline at end of file diff --git a/dashboard/client/components/+user-management/user-management.tsx b/dashboard/client/components/+user-management/user-management.tsx new file mode 100644 index 0000000000..93b812fc2c --- /dev/null +++ b/dashboard/client/components/+user-management/user-management.tsx @@ -0,0 +1,68 @@ +import "./user-management.scss" + +import React from "react"; +import { observer } from "mobx-react"; +import { Redirect, Route, Switch } from "react-router"; +import { RouteComponentProps } from "react-router-dom"; +import { Trans } from "@lingui/macro"; +import { MainLayout, TabRoute } from "../layout/main-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, usersManagementURL } from "./user-management.routes"; +import { namespaceStore } from "../+namespaces/namespace.store"; +import { configStore } from "../../config.store"; +import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies"; + +interface Props extends RouteComponentProps<{}> { +} + +@observer +export class UserManagement extends React.Component { + static get tabRoutes() { + const tabRoutes: TabRoute[] = []; + const { isClusterAdmin } = configStore; + const query = namespaceStore.getContextParams() + tabRoutes.push( + { + title: Service Accounts, + component: ServiceAccounts, + url: serviceAccountsURL({ query }), + path: serviceAccountsRoute.path, + }, + { + title: Role Bindings, + component: RoleBindings, + url: roleBindingsURL({ query }), + path: roleBindingsRoute.path, + }, + { + title: Roles, + component: Roles, + url: rolesURL({ query }), + path: rolesRoute.path, + }, + ) + if (isClusterAdmin) { + tabRoutes.push({ + title: Pod Security Policies, + component: PodSecurityPolicies, + url: podSecurityPoliciesURL(), + path: podSecurityPoliciesRoute.path, + }) + } + return tabRoutes; + } + + render() { + const tabRoutes = UserManagement.tabRoutes; + return ( + + + {tabRoutes.map((route, index) => )} + + + + ) + } +} diff --git a/dashboard/client/components/+workloads-cronjobs/cronjob-details.scss b/dashboard/client/components/+workloads-cronjobs/cronjob-details.scss new file mode 100644 index 0000000000..1f05a631d7 --- /dev/null +++ b/dashboard/client/components/+workloads-cronjobs/cronjob-details.scss @@ -0,0 +1,19 @@ + +.CronJobDetails { + .job { + .title { + margin-top: $margin * 2; + margin-bottom: $margin; + + a { + color: $colorInfo; + } + } + } + + .conditions { + .Badge { + @include job-condition-bgs; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-cronjobs/cronjob-details.tsx b/dashboard/client/components/+workloads-cronjobs/cronjob-details.tsx new file mode 100644 index 0000000000..254189090b --- /dev/null +++ b/dashboard/client/components/+workloads-cronjobs/cronjob-details.tsx @@ -0,0 +1,92 @@ +import "./cronjob-details.scss"; + +import React from "react"; +import kebabCase from "lodash/kebabCase"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { Badge } from "../badge/badge"; +import { jobStore } from "../+workloads-jobs/job.store"; +import { Link } from "react-router-dom"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { cronJobStore } from "./cronjob.store"; +import { getDetailsUrl } from "../../navigation"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { CronJob, cronJobApi, Job } from "../../api/endpoints"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class CronJobDetails extends React.Component { + async componentDidMount() { + if (!jobStore.isLoaded) { + jobStore.loadAll(); + } + } + + render() { + const { object: cronJob } = this.props; + if (!cronJob) return null; + const childJobs = jobStore.getJobsByOwner(cronJob) + return ( +
+ + Schedule}> + {cronJob.isNeverRun() ? ( + <> + never ({cronJob.getSchedule()}) + + ) : cronJob.getSchedule()} + + Active}> + {cronJobStore.getActiveJobsNum(cronJob)} + + Suspend}> + {cronJob.getSuspendFlag()} + + Last schedule}> + {cronJob.getLastScheduleTime()} + + {childJobs.length > 0 && + <> + Jobs}/> + {childJobs.map((job: Job) => { + const selectors = job.getSelectors() + const condition = job.getCondition() + return ( +
+
+ + {job.getName()} + +
+ Condition} className="conditions" labelsOnly> + {condition && ( + + )} + + Selector} labelsOnly> + { + selectors.map(label => ) + } + +
+ )}) + } + + } + +
+ ) + } +} + +apiManager.registerViews(cronJobApi, { + Details: CronJobDetails, +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-cronjobs/cronjob.store.ts b/dashboard/client/components/+workloads-cronjobs/cronjob.store.ts new file mode 100644 index 0000000000..9e75c25121 --- /dev/null +++ b/dashboard/client/components/+workloads-cronjobs/cronjob.store.ts @@ -0,0 +1,33 @@ +import { KubeObjectStore } from "../../kube-object.store"; +import { autobind } from "../../utils"; +import { CronJob, cronJobApi } from "../../api/endpoints/cron-job.api"; +import { jobStore } from "../+workloads-jobs/job.store"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class CronJobStore extends KubeObjectStore { + api = cronJobApi + + getStatuses(cronJobs?: CronJob[]) { + const status = { failed: 0, running: 0 } + cronJobs.forEach(cronJob => { + if (cronJob.spec.suspend) { + status.failed++ + } + else { + status.running++ + } + }) + return status + } + + getActiveJobsNum(cronJob: CronJob) { + // Active jobs are jobs without any condition 'Complete' nor 'Failed' + const jobs = jobStore.getJobsByOwner(cronJob); + if (!jobs.length) return 0; + return jobs.filter(job => !job.getCondition()).length; + } +} + +export const cronJobStore = new CronJobStore(); +apiManager.registerStore(cronJobApi, cronJobStore); diff --git a/dashboard/client/components/+workloads-cronjobs/cronjobs.scss b/dashboard/client/components/+workloads-cronjobs/cronjobs.scss new file mode 100644 index 0000000000..ba5e600adf --- /dev/null +++ b/dashboard/client/components/+workloads-cronjobs/cronjobs.scss @@ -0,0 +1,7 @@ +.CronJobs { + .TableCell { + &.warning { + @include table-cell-warning; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-cronjobs/cronjobs.tsx b/dashboard/client/components/+workloads-cronjobs/cronjobs.tsx new file mode 100644 index 0000000000..90d2304b7e --- /dev/null +++ b/dashboard/client/components/+workloads-cronjobs/cronjobs.tsx @@ -0,0 +1,89 @@ +import "./cronjobs.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { RouteComponentProps } from "react-router"; +import { Trans } from "@lingui/macro"; +import { CronJob, cronJobApi } from "../../api/endpoints/cron-job.api"; +import { cronJobStore } from "./cronjob.store"; +import { jobStore } from "../+workloads-jobs/job.store"; +import { eventStore } from "../+events/event.store"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { ICronJobsRouteParams } from "../+workloads"; +import { KubeObjectListLayout } from "../kube-object"; +import { KubeEventIcon } from "../+events/kube-event-icon"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + suspend = "suspend", + active = "active", + lastSchedule = "schedule", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class CronJobs extends React.Component { + render() { + return ( + cronJob.getName(), + [sortBy.namespace]: (cronJob: CronJob) => cronJob.getNs(), + [sortBy.suspend]: (cronJob: CronJob) => cronJob.getSuspendFlag(), + [sortBy.active]: (cronJob: CronJob) => cronJobStore.getActiveJobsNum(cronJob), + [sortBy.lastSchedule]: (cronJob: CronJob) => cronJob.getLastScheduleTime(), + [sortBy.age]: (cronJob: CronJob) => cronJob.getAge(false), + }} + searchFilters={[ + (cronJob: CronJob) => cronJob.getSearchFields(), + (cronJob: CronJob) => cronJob.getSchedule(), + ]} + renderHeaderTitle={Cron Jobs} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { className: "warning" }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Schedule, className: "schedule" }, + { title: Suspend, className: "suspend", sortBy: sortBy.suspend }, + { title: Active, className: "active", sortBy: sortBy.active }, + { title: Last schedule, className: "last-schedule", sortBy: sortBy.lastSchedule }, + { title: Age, className: "age", sortBy: sortBy.age }, + ]} + renderTableContents={(cronJob: CronJob) => [ + cronJob.getName(), + { + if (!cronJob.isNeverRun()) return events; + return events.filter(event => event.reason != "FailedNeedsStart"); + } + }/>, + cronJob.getNs(), + cronJob.isNeverRun() ? never : cronJob.getSchedule(), + cronJob.getSuspendFlag(), + cronJobStore.getActiveJobsNum(cronJob), + cronJob.getLastScheduleTime(), + cronJob.getAge(), + ]} + renderItemMenu={(item: CronJob) => { + return + }} + /> + ) + } +} + +export function CronJobMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews(cronJobApi, { + Menu: CronJobMenu, +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-cronjobs/index.ts b/dashboard/client/components/+workloads-cronjobs/index.ts new file mode 100644 index 0000000000..71c59dcfee --- /dev/null +++ b/dashboard/client/components/+workloads-cronjobs/index.ts @@ -0,0 +1,2 @@ +export * from "./cronjobs" +export * from "./cronjob-details" diff --git a/dashboard/client/components/+workloads-daemonsets/daemonset-details.scss b/dashboard/client/components/+workloads-daemonsets/daemonset-details.scss new file mode 100644 index 0000000000..07235040b2 --- /dev/null +++ b/dashboard/client/components/+workloads-daemonsets/daemonset-details.scss @@ -0,0 +1,2 @@ +.DaemonSetDetails { +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-daemonsets/daemonset-details.tsx b/dashboard/client/components/+workloads-daemonsets/daemonset-details.tsx new file mode 100644 index 0000000000..69c4ed4d69 --- /dev/null +++ b/dashboard/client/components/+workloads-daemonsets/daemonset-details.tsx @@ -0,0 +1,102 @@ +import "./daemonset-details.scss"; + +import React from "react"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { DrawerItem } from "../drawer"; +import { Badge } from "../badge"; +import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; +import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; +import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { daemonSetStore } from "./daemonsets.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { DaemonSet, daemonSetApi } from "../../api/endpoints"; +import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; +import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; +import { reaction } from "mobx"; +import { PodDetailsList } from "../+workloads-pods/pod-details-list"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class DaemonSetDetails extends React.Component { + @disposeOnUnmount + clean = reaction(() => this.props.object, () => { + daemonSetStore.reset(); + }); + + componentDidMount() { + if (!podsStore.isLoaded) { + podsStore.loadAll(); + } + } + + componentWillUnmount() { + daemonSetStore.reset(); + } + + render() { + const { object: daemonSet } = this.props; + if (!daemonSet) return null; + const { spec } = daemonSet + const selectors = daemonSet.getSelectors(); + const images = daemonSet.getImages() + const nodeSelector = daemonSet.getNodeSelectors() + const childPods = daemonSetStore.getChildPods(daemonSet) + const metrics = daemonSetStore.metrics + return ( +
+ {podsStore.isLoaded && ( + daemonSetStore.loadMetrics(daemonSet)} + tabs={podMetricTabs} object={daemonSet} params={{ metrics }} + > + + + )} + + {selectors.length > 0 && + Selector} labelsOnly> + { + selectors.map(label => ) + } + + } + {nodeSelector.length > 0 && + Node Selector} labelsOnly> + { + nodeSelector.map(label => ()) + } + + } + {images.length > 0 && + Images}> + { + images.map(image =>

{image}

) + } +
+ } + Strategy Type}> + {spec.updateStrategy.type} + + + + Pod Status} className="pod-status"> + + + + + +
+ ) + } +} + +apiManager.registerViews(daemonSetApi, { + Details: DaemonSetDetails, +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-daemonsets/daemonsets.scss b/dashboard/client/components/+workloads-daemonsets/daemonsets.scss new file mode 100644 index 0000000000..4945ecc255 --- /dev/null +++ b/dashboard/client/components/+workloads-daemonsets/daemonsets.scss @@ -0,0 +1,20 @@ +.DaemonSets { + .TableCell { + &.name { + flex-grow: 1.6; + } + + &.pods { + flex-grow: 0.3; + } + + &.warning { + @include table-cell-warning; + } + + &.labels { + flex-grow: 1.5; + @include table-cell-labels-offsets; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-daemonsets/daemonsets.store.ts b/dashboard/client/components/+workloads-daemonsets/daemonsets.store.ts new file mode 100644 index 0000000000..3e9acdd036 --- /dev/null +++ b/dashboard/client/components/+workloads-daemonsets/daemonsets.store.ts @@ -0,0 +1,48 @@ +import { observable } from "mobx"; +import { KubeObjectStore } from "../../kube-object.store"; +import { autobind } from "../../utils"; +import { DaemonSet, daemonSetApi, IPodMetrics, Pod, podsApi, PodStatus } from "../../api/endpoints"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class DaemonSetStore extends KubeObjectStore { + api = daemonSetApi + + @observable metrics: IPodMetrics = null; + + loadMetrics(daemonSet: DaemonSet) { + const pods = this.getChildPods(daemonSet); + return podsApi.getMetrics(pods, daemonSet.getNs(), "").then(metrics => + this.metrics = metrics + ); + } + + getChildPods(daemonSet: DaemonSet): Pod[] { + return podsStore.getPodsByOwner(daemonSet) + } + + getStatuses(daemonSets?: DaemonSet[]) { + const status = { failed: 0, pending: 0, running: 0 } + daemonSets.forEach(daemonSet => { + const pods = this.getChildPods(daemonSet) + 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 + } + + reset() { + this.metrics = null; + } +} + +export const daemonSetStore = new DaemonSetStore(); +apiManager.registerStore(daemonSetApi, daemonSetStore); diff --git a/dashboard/client/components/+workloads-daemonsets/daemonsets.tsx b/dashboard/client/components/+workloads-daemonsets/daemonsets.tsx new file mode 100644 index 0000000000..be48e877df --- /dev/null +++ b/dashboard/client/components/+workloads-daemonsets/daemonsets.tsx @@ -0,0 +1,89 @@ +import "./daemonsets.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { RouteComponentProps } from "react-router"; +import { DaemonSet, daemonSetApi } from "../../api/endpoints"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { eventStore } from "../+events/event.store"; +import { daemonSetStore } from "./daemonsets.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { nodesStore } from "../+nodes/nodes.store"; +import { KubeObjectListLayout } from "../kube-object"; +import { IDaemonSetsRouteParams } from "../+workloads"; +import { Trans } from "@lingui/macro"; +import { Badge } from "../badge"; +import { KubeEventIcon } from "../+events/kube-event-icon"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + pods = "pods", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class DaemonSets extends React.Component { + getPodsLength(daemonSet: DaemonSet) { + return daemonSetStore.getChildPods(daemonSet).length; + } + + renderNodeSelector(daemonSet: DaemonSet) { + return daemonSet.getNodeSelectors().map(selector => ( + + )) + } + + render() { + return ( + daemonSet.getName(), + [sortBy.namespace]: (daemonSet: DaemonSet) => daemonSet.getNs(), + [sortBy.pods]: (daemonSet: DaemonSet) => this.getPodsLength(daemonSet), + [sortBy.age]: (daemonSet: DaemonSet) => daemonSet.getAge(false), + }} + searchFilters={[ + (daemonSet: DaemonSet) => daemonSet.getSearchFields(), + (daemonSet: DaemonSet) => daemonSet.getLabels(), + ]} + renderHeaderTitle={Daemon Sets} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Pods, className: "pods", sortBy: sortBy.pods }, + { className: "warning" }, + { title: Node Selector, className: "labels" }, + { title: Age, className: "age", sortBy: sortBy.age }, + ]} + renderTableContents={(daemonSet: DaemonSet) => [ + daemonSet.getName(), + daemonSet.getNs(), + this.getPodsLength(daemonSet), + , + this.renderNodeSelector(daemonSet), + daemonSet.getAge(), + ]} + renderItemMenu={(item: DaemonSet) => { + return + }} + /> + ) + } +} + +export function DaemonSetMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews(daemonSetApi, { + Menu: DaemonSetMenu, +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-daemonsets/index.ts b/dashboard/client/components/+workloads-daemonsets/index.ts new file mode 100644 index 0000000000..7f32ffdf2f --- /dev/null +++ b/dashboard/client/components/+workloads-daemonsets/index.ts @@ -0,0 +1,2 @@ +export * from "./daemonsets" +export * from "./daemonset-details" diff --git a/dashboard/client/components/+workloads-deployments/deployment-details.scss b/dashboard/client/components/+workloads-deployments/deployment-details.scss new file mode 100644 index 0000000000..b8476d4ddd --- /dev/null +++ b/dashboard/client/components/+workloads-deployments/deployment-details.scss @@ -0,0 +1,20 @@ +.DeploymentDetails { + .conditions { + .Badge { + &.available { + color: white; + background-color: $deployment-available; + } + + &.progressing { + color: white; + background-color: $deployment-progressing; + } + + &.replica-failure { + color: white; + background-color: $deployment-replicafailure; + } + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-deployments/deployment-details.tsx b/dashboard/client/components/+workloads-deployments/deployment-details.tsx new file mode 100644 index 0000000000..27fa952eda --- /dev/null +++ b/dashboard/client/components/+workloads-deployments/deployment-details.tsx @@ -0,0 +1,127 @@ +import "./deployment-details.scss"; + +import React from "react"; +import kebabCase from "lodash/kebabCase"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { t, Trans } from "@lingui/macro"; +import { DrawerItem } from "../drawer"; +import { Badge } from "../badge"; +import { Deployment, deploymentApi } from "../../api/endpoints"; +import { cssNames } from "../../utils"; +import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; +import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { _i18n } from "../../i18n"; +import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; +import { deploymentStore } from "./deployments.store"; +import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; +import { reaction } from "mobx"; +import { PodDetailsList } from "../+workloads-pods/pod-details-list"; +import { ReplicaSets } from "../+workloads-replicasets"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class DeploymentDetails extends React.Component { + @disposeOnUnmount + clean = reaction(() => this.props.object, () => { + deploymentStore.reset(); + }); + + componentDidMount() { + if (!podsStore.isLoaded) { + podsStore.loadAll(); + } + if (!replicaSetStore.isLoaded) { + replicaSetStore.loadAll(); + } + } + + componentWillUnmount() { + deploymentStore.reset(); + } + + render() { + const { object: deployment } = this.props; + if (!deployment) return null + const { status, spec } = deployment + const nodeSelector = deployment.getNodeSelectors() + const selectors = deployment.getSelectors(); + const childPods = deploymentStore.getChildPods(deployment) + const replicaSets = replicaSetStore.getReplicaSetsByOwner(deployment) + const metrics = deploymentStore.metrics + return ( +
+ {podsStore.isLoaded && ( + deploymentStore.loadMetrics(deployment)} + tabs={podMetricTabs} object={deployment} params={{ metrics }} + > + + + )} + + Replicas}> + {_i18n._(t`${spec.replicas} desired, ${status.updatedReplicas || 0} updated`)},{" "} + {_i18n._(t`${status.replicas || 0} total, ${status.availableReplicas || 0} available`)},{" "} + {_i18n._(t`${status.unavailableReplicas || 0} unavailable`)} + + {selectors.length > 0 && + Selector} labelsOnly> + { + selectors.map(label => ) + } + + } + {nodeSelector.length > 0 && + Node Selector}> + { + nodeSelector.map(label => ( + + )) + } + + } + Strategy Type}> + {spec.strategy.type} + + Conditions} className="conditions" labelsOnly> + { + deployment.getConditions().map(condition => { + const { type, message, lastTransitionTime, status } = condition + return ( + +

{message}

+

Last transition time: {lastTransitionTime}

+ + )} + /> + ); + }) + } +
+ + + + + + +
+ ) + } +} + +apiManager.registerViews(deploymentApi, { + Details: DeploymentDetails +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.scss b/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.scss new file mode 100644 index 0000000000..e36ad9583e --- /dev/null +++ b/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.scss @@ -0,0 +1,42 @@ +.DeploymentScaleDialog { + .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 0; + } + + .slider-container { + flex: 1.3 0; + } + + .warning { + color: $colorSoftError; + font-size: small; + display: flex; + align-items: center; + + .Icon { + margin: 0; + margin-right: $margin; + } + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.tsx b/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.tsx new file mode 100644 index 0000000000..8fbc47cd78 --- /dev/null +++ b/dashboard/client/components/+workloads-deployments/deployment-scale-dialog.tsx @@ -0,0 +1,142 @@ +import "./deployment-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 { Deployment, deploymentApi } from "../../api/endpoints"; +import { Icon } from "../icon"; +import { Slider } from "../slider"; +import { Notifications } from "../notifications"; +import { cssNames } from "../../utils"; + +interface Props extends Partial { +} + +@observer +export class DeploymentScaleDialog extends Component { + @observable static isOpen = false; + @observable static data: Deployment = null; + + @observable ready = false; + @observable currentReplicas = 0; + @observable desiredReplicas = 0; + + static open(deployment: Deployment) { + DeploymentScaleDialog.isOpen = true; + DeploymentScaleDialog.data = deployment; + } + + static close() { + DeploymentScaleDialog.isOpen = false; + } + + get deployment() { + return DeploymentScaleDialog.data; + } + + close = () => { + DeploymentScaleDialog.close(); + } + + @computed get scaleMax() { + const { currentReplicas } = this; + const defaultMax = 50; + return currentReplicas <= defaultMax + ? defaultMax * 2 + : currentReplicas * 2; + } + + onOpen = async () => { + const { deployment } = this; + this.currentReplicas = await deploymentApi.getReplicas({ + namespace: deployment.getNs(), + name: deployment.getName(), + }); + this.desiredReplicas = this.currentReplicas; + this.ready = true; + } + + onClose = () => { + this.ready = false; + } + + onChange = (evt: React.ChangeEvent, value: number) => { + this.desiredReplicas = value; + } + + scale = async () => { + const { deployment } = this; + const { currentReplicas, desiredReplicas, close } = this; + try { + if (currentReplicas !== desiredReplicas) { + await deploymentApi.scale({ + name: deployment.getName(), + namespace: deployment.getNs(), + }, desiredReplicas); + } + close(); + } catch (err) { + Notifications.error(err); + } + } + + renderContents() { + const { currentReplicas, desiredReplicas, onChange, scaleMax } = this; + const warning = currentReplicas < 10 && desiredReplicas > 90; + return ( + <> +
+ Current replica scale: {currentReplicas} +
+
+
+ Desired number of replicas: {desiredReplicas} +
+
+ +
+
+ {warning && +
+ + High number of replicas may cause cluster performance issues +
+ } + + ) + } + + render() { + const { className, ...dialogProps } = this.props; + const deploymentName = this.deployment ? this.deployment.getName() : ""; + const header = ( +
+ Scale Deployment {deploymentName} +
+ ); + return ( + + + Scale} + disabledNext={!this.ready} + > + {this.renderContents()} + + + + ); + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-deployments/deployments.scss b/dashboard/client/components/+workloads-deployments/deployments.scss new file mode 100644 index 0000000000..a345c6e741 --- /dev/null +++ b/dashboard/client/components/+workloads-deployments/deployments.scss @@ -0,0 +1,41 @@ +@import "../+workloads/workloads-mixins"; + +.Deployments { + .TableCell { + &.name { + flex-grow: 1.5; + } + + &.namespace { + flex-grow: 1.5; + } + + &.pods { + flex-grow: 1; + } + + &.warning { + @include table-cell-warning; + } + + &.conditions { + flex-grow: 1.5; + + .condition { + margin-right: $margin; + + &.available { + color: $deployment-available; + } + + &.progressing { + color: $deployment-progressing; + } + + &.replica-failure { + color: $deployment-replicafailure; + } + } + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-deployments/deployments.store.ts b/dashboard/client/components/+workloads-deployments/deployments.store.ts new file mode 100644 index 0000000000..59acd6c100 --- /dev/null +++ b/dashboard/client/components/+workloads-deployments/deployments.store.ts @@ -0,0 +1,55 @@ +import { observable } from "mobx"; +import { Deployment, deploymentApi, IPodMetrics, podsApi, PodStatus } from "../../api/endpoints"; +import { KubeObjectStore } from "../../kube-object.store"; +import { autobind } from "../../utils"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class DeploymentStore extends KubeObjectStore { + api = deploymentApi + @observable metrics: IPodMetrics = null; + + protected sortItems(items: Deployment[]) { + return super.sortItems(items, [ + item => item.getReplicas(), + ], "desc"); + } + + loadMetrics(deployment: Deployment) { + const pods = this.getChildPods(deployment); + return podsApi.getMetrics(pods, deployment.getNs(), "").then(metrics => + this.metrics = metrics + ); + } + + getStatuses(deployments?: Deployment[]) { + const status = { failed: 0, pending: 0, running: 0 } + deployments.forEach(deployment => { + const pods = this.getChildPods(deployment); + 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 + } + + getChildPods(deployment: Deployment) { + return podsStore + .getByLabel(deployment.getTemplateLabels()) + .filter(pod => pod.getNs() === deployment.getNs()) + } + + reset() { + this.metrics = null; + } +} + +export const deploymentStore = new DeploymentStore(); +apiManager.registerStore(deploymentApi, deploymentStore); diff --git a/dashboard/client/components/+workloads-deployments/deployments.tsx b/dashboard/client/components/+workloads-deployments/deployments.tsx new file mode 100644 index 0000000000..bdef93ec12 --- /dev/null +++ b/dashboard/client/components/+workloads-deployments/deployments.tsx @@ -0,0 +1,110 @@ +import "./deployments.scss" + +import React from "react"; +import { observer } from "mobx-react"; +import { RouteComponentProps } from "react-router"; +import { t, Trans } from "@lingui/macro"; +import { Deployment, deploymentApi } from "../../api/endpoints"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { MenuItem } from "../menu"; +import { Icon } from "../icon"; +import { DeploymentScaleDialog } from "./deployment-scale-dialog"; +import { deploymentStore } from "./deployments.store"; +import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { nodesStore } from "../+nodes/nodes.store"; +import { eventStore } from "../+events/event.store"; +import { KubeObjectListLayout } from "../kube-object"; +import { IDeploymentsRouteParams } from "../+workloads"; +import { _i18n } from "../../i18n"; +import { cssNames } from "../../utils"; +import kebabCase from "lodash/kebabCase"; +import orderBy from "lodash/orderBy"; +import { KubeEventIcon } from "../+events/kube-event-icon"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + replicas = "replicas", + age = "age", + condition = "condition", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class Deployments extends React.Component { + renderPods(deployment: Deployment) { + const { replicas, availableReplicas } = deployment.status + return `${availableReplicas || 0}/${replicas || 0}` + } + + renderConditions(deployment: Deployment) { + const conditions = orderBy(deployment.getConditions(true), "type", "asc") + return conditions.map(({ type, message }) => ( + + {type} + + )) + } + + render() { + return ( + deployment.getName(), + [sortBy.namespace]: (deployment: Deployment) => deployment.getNs(), + [sortBy.replicas]: (deployment: Deployment) => deployment.getReplicas(), + [sortBy.age]: (deployment: Deployment) => deployment.getAge(false), + [sortBy.condition]: (deployment: Deployment) => deployment.getConditionsText(), + }} + searchFilters={[ + (deployment: Deployment) => deployment.getSearchFields(), + (deployment: Deployment) => deployment.getConditionsText(), + ]} + renderHeaderTitle={Deployments} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Pods, className: "pods" }, + { title: Replicas, className: "replicas", sortBy: sortBy.replicas }, + { className: "warning" }, + { title: Age, className: "age", sortBy: sortBy.age }, + { title: Conditions, className: "conditions", sortBy: sortBy.condition }, + ]} + renderTableContents={(deployment: Deployment) => [ + deployment.getName(), + deployment.getNs(), + this.renderPods(deployment), + deployment.getReplicas(), + , + deployment.getAge(), + this.renderConditions(deployment), + ]} + renderItemMenu={(item: Deployment) => { + return + }} + /> + ) + } +} + +export function DeploymentMenu(props: KubeObjectMenuProps) { + const { object, toolbar } = props; + return ( + + DeploymentScaleDialog.open(object)}> + + Scale + + + ) +} + +apiManager.registerViews(deploymentApi, { + Menu: DeploymentMenu, +}); \ No newline at end of file diff --git a/dashboard/client/components/+workloads-deployments/index.ts b/dashboard/client/components/+workloads-deployments/index.ts new file mode 100644 index 0000000000..1a8a81abb5 --- /dev/null +++ b/dashboard/client/components/+workloads-deployments/index.ts @@ -0,0 +1,2 @@ +export * from "./deployments" +export * from "./deployment-details" diff --git a/dashboard/client/components/+workloads-jobs/index.ts b/dashboard/client/components/+workloads-jobs/index.ts new file mode 100644 index 0000000000..8389bf51cd --- /dev/null +++ b/dashboard/client/components/+workloads-jobs/index.ts @@ -0,0 +1,2 @@ +export * from "./jobs" +export * from "./job-details" diff --git a/dashboard/client/components/+workloads-jobs/job-details.scss b/dashboard/client/components/+workloads-jobs/job-details.scss new file mode 100644 index 0000000000..d92e863085 --- /dev/null +++ b/dashboard/client/components/+workloads-jobs/job-details.scss @@ -0,0 +1,8 @@ + +.JobDetails { + .conditions { + .Badge { + @include job-condition-bgs; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-jobs/job-details.tsx b/dashboard/client/components/+workloads-jobs/job-details.tsx new file mode 100644 index 0000000000..a7f683df8a --- /dev/null +++ b/dashboard/client/components/+workloads-jobs/job-details.tsx @@ -0,0 +1,112 @@ +import "./job-details.scss"; + +import React from "react"; +import kebabCase from "lodash/kebabCase"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { DrawerItem } from "../drawer"; +import { Badge } from "../badge"; +import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; +import { Link } from "react-router-dom"; +import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; +import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { jobStore } from "./job.store"; +import { getDetailsUrl } from "../../navigation"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { Job, jobApi } from "../../api/endpoints"; +import { PodDetailsList } from "../+workloads-pods/pod-details-list"; +import { lookupApiLink } from "../../api/kube-api"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class JobDetails extends React.Component { + async componentDidMount() { + if (!podsStore.isLoaded) { + podsStore.loadAll(); + } + } + + render() { + const { object: job } = this.props; + if (!job) return null; + const selectors = job.getSelectors() + const nodeSelector = job.getNodeSelectors() + const images = job.getImages() + const childPods = jobStore.getChildPods(job) + const ownerRefs = job.getOwnerRefs() + const condition = job.getCondition() + return ( +
+ + Selector} labelsOnly> + { + Object.keys(selectors).map(label => ) + } + + {nodeSelector.length > 0 && + Node Selector} labelsOnly> + { + nodeSelector.map(label => ( + + )) + } + + } + {images.length > 0 && + Images}> + { + images.map(image =>

{image}

) + } +
+ } + {ownerRefs.length > 0 && + Controlled by}> + { + ownerRefs.map(ref => { + const { name, kind } = ref; + const detailsUrl = getDetailsUrl(lookupApiLink(ref, job)) + return ( +

+ {kind} {name} +

+ ); + }) + } +
+ } + Conditions} className="conditions" labelsOnly> + {condition && ( + + )} + + Completions}> + {job.getDesiredCompletions()} + + Parallelism}> + {job.getParallelism()} + + + + Pod Status} className="pod-status"> + + + + +
+ ) + } +} + +apiManager.registerViews(jobApi, { + Details: JobDetails +}); \ No newline at end of file diff --git a/dashboard/client/components/+workloads-jobs/job.store.ts b/dashboard/client/components/+workloads-jobs/job.store.ts new file mode 100644 index 0000000000..592347062c --- /dev/null +++ b/dashboard/client/components/+workloads-jobs/job.store.ts @@ -0,0 +1,45 @@ +import { KubeObjectStore } from "../../kube-object.store"; +import { autobind } from "../../utils"; +import { Job, jobApi } from "../../api/endpoints/job.api"; +import { CronJob, Pod, PodStatus } from "../../api/endpoints"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class JobStore extends KubeObjectStore { + api = jobApi + + getChildPods(job: Job): Pod[] { + return podsStore.getPodsByOwner(job) + } + + getJobsByOwner(cronJob: CronJob) { + return this.items.filter(job => + job.getNs() == cronJob.getNs() && + job.getOwnerRefs().find(ref => ref.name === cronJob.getName() && ref.kind === cronJob.kind) + ) + } + + getStatuses(jobs?: Job[]) { + const status = { failed: 0, pending: 0, running: 0, succeeded: 0 } + jobs.forEach(job => { + const pods = this.getChildPods(job) + if (pods.some(pod => pod.getStatus() === PodStatus.FAILED)) { + status.failed++ + } + else if (pods.some(pod => pod.getStatus() === PodStatus.PENDING)) { + status.pending++ + } + else if (pods.some(pod => pod.getStatus() === PodStatus.RUNNING)) { + status.running++ + } + else { + status.succeeded++ + } + }) + return status + } +} + +export const jobStore = new JobStore(); +apiManager.registerStore(jobApi, jobStore); diff --git a/dashboard/client/components/+workloads-jobs/jobs.scss b/dashboard/client/components/+workloads-jobs/jobs.scss new file mode 100644 index 0000000000..2f1089ffd2 --- /dev/null +++ b/dashboard/client/components/+workloads-jobs/jobs.scss @@ -0,0 +1,17 @@ +@import "../+workloads/workloads-mixins"; + +.Jobs { + .TableCell { + &.name { + flex-grow: 2; + } + + &.warning { + @include table-cell-warning; + } + + &.conditions { + @include job-condition-colors; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-jobs/jobs.tsx b/dashboard/client/components/+workloads-jobs/jobs.tsx new file mode 100644 index 0000000000..4eff407997 --- /dev/null +++ b/dashboard/client/components/+workloads-jobs/jobs.tsx @@ -0,0 +1,83 @@ +import "./jobs.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { RouteComponentProps } from "react-router"; +import { Trans } from "@lingui/macro"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { jobStore } from "./job.store"; +import { eventStore } from "../+events/event.store"; +import { Job, jobApi } from "../../api/endpoints/job.api"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { KubeObjectListLayout } from "../kube-object"; +import { IJobsRouteParams } from "../+workloads"; +import { KubeEventIcon } from "../+events/kube-event-icon"; +import kebabCase from "lodash/kebabCase"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + conditions = "conditions", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class Jobs extends React.Component { + render() { + return ( + job.getName(), + [sortBy.namespace]: (job: Job) => job.getNs(), + [sortBy.conditions]: (job: Job) => job.getCondition().type, + [sortBy.age]: (job: Job) => job.getAge(false), + }} + searchFilters={[ + (job: Job) => job.getSearchFields(), + ]} + renderHeaderTitle={Jobs} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Completions, className: "completions" }, + { className: "warning" }, + { title: Age, className: "age", sortBy: sortBy.age }, + { title: Conditions, className: "conditions", sortBy: sortBy.conditions }, + ]} + renderTableContents={(job: Job) => { + const condition = job.getCondition(); + return [ + job.getName(), + job.getNs(), + `${job.getCompletions()} / ${job.getDesiredCompletions()}`, + , + job.getAge(), + condition && { + title: condition.type, + className: kebabCase(condition.type), + } + ] + }} + renderItemMenu={(item: Job) => { + return + }} + /> + ) + } +} + +export function JobMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews(jobApi, { + Menu: JobMenu, +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-overview/overview-statuses.scss b/dashboard/client/components/+workloads-overview/overview-statuses.scss new file mode 100644 index 0000000000..7160d67821 --- /dev/null +++ b/dashboard/client/components/+workloads-overview/overview-statuses.scss @@ -0,0 +1,35 @@ +.OverviewStatuses { + position: relative; + width: 100%; + min-width: $unit * 75; + background: $contentColor; + + > .header { + position: relative; + padding: $padding * 2; + + h5 { + color: $textColorPrimary; + } + } + + .workloads { + display: grid; + grid-template-columns: repeat(auto-fit, 155px); + justify-content: space-between; + grid-gap: $margin; + padding: $padding * 2; + + .workload { + margin-bottom: $margin * 2; + + > .title { + text-align: center; + + a { + color: $colorInfo; + } + } + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-overview/overview-statuses.tsx b/dashboard/client/components/+workloads-overview/overview-statuses.tsx new file mode 100644 index 0000000000..3c0a8cac0d --- /dev/null +++ b/dashboard/client/components/+workloads-overview/overview-statuses.tsx @@ -0,0 +1,65 @@ +import "./overview-statuses.scss" + +import React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { OverviewWorkloadStatus } from "./overview-workload-status"; +import { Link } from "react-router-dom"; +import { cronJobsURL, daemonSetsURL, deploymentsURL, jobsURL, podsURL, statefulSetsURL } from "../+workloads"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { deploymentStore } from "../+workloads-deployments/deployments.store"; +import { daemonSetStore } from "../+workloads-daemonsets/daemonsets.store"; +import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store"; +import { jobStore } from "../+workloads-jobs/job.store"; +import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; +import { namespaceStore } from "../+namespaces/namespace.store"; +import { PageFiltersList } from "../item-object-list/page-filters-list"; +import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; + +@observer +export class OverviewStatuses extends React.Component { + render() { + const { contextNs } = namespaceStore; + const pods = podsStore.getAllByNs(contextNs); + const deployments = deploymentStore.getAllByNs(contextNs); + const statefulSets = statefulSetStore.getAllByNs(contextNs); + const daemonSets = daemonSetStore.getAllByNs(contextNs); + const jobs = jobStore.getAllByNs(contextNs); + const cronJobs = cronJobStore.getAllByNs(contextNs); + return ( +
+
+
Overview
+ +
+ +
+
+
Pods ({pods.length})
+ +
+
+
Deployments ({deployments.length})
+ +
+
+
StatefulSets ({statefulSets.length})
+ +
+
+
DaemonSets ({daemonSets.length})
+ +
+
+
Jobs ({jobs.length})
+ +
+
+
CronJobs ({cronJobs.length})
+ +
+
+
+ ) + } +} diff --git a/dashboard/client/components/+workloads-overview/overview-workload-status.scss b/dashboard/client/components/+workloads-overview/overview-workload-status.scss new file mode 100644 index 0000000000..c06ed5b4c8 --- /dev/null +++ b/dashboard/client/components/+workloads-overview/overview-workload-status.scss @@ -0,0 +1,15 @@ +.OverviewWorkloadStatus { + --workload-status-running: #{$pod-status-running-color}; + --workload-status-pending: #{$pod-status-pending-color}; + --workload-status-evicted: #{$pod-status-evicted-color}; + --workload-status-succeeded: #{$pod-status-succeeded-color}; + --workload-status-failed: #{$pod-status-failed-color}; + --workload-status-terminated: #{$pod-status-terminated-color}; + --workload-status-unknown: #{$pod-status-unknown-color}; + + .PieChart { + .chart-container { + width: 110px + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-overview/overview-workload-status.tsx b/dashboard/client/components/+workloads-overview/overview-workload-status.tsx new file mode 100644 index 0000000000..dcd85d7ec9 --- /dev/null +++ b/dashboard/client/components/+workloads-overview/overview-workload-status.tsx @@ -0,0 +1,79 @@ +import "./overview-workload-status.scss"; + +import React from "react"; +import capitalize from "lodash/capitalize"; +import { findDOMNode } from 'react-dom'; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { PieChart } from "../chart"; +import { cssVar } from "../../utils"; +import { ChartData } from "chart.js"; +import { themeStore } from "../../theme.store"; + +interface Props { + status: { + [key: string]: number; + }; +} + +@observer +export class OverviewWorkloadStatus extends React.Component { + @observable elem: HTMLElement + + componentDidMount() { + this.elem = findDOMNode(this) as HTMLElement + } + + getStatusColor(status: string) { + return cssVar(this.elem).get(`--workload-status-${status.toLowerCase()}`).toString(); + } + + renderChart() { + if (!this.elem) return null + const { status } = this.props + const statuses = Object.entries(status) + const chartData: Partial = { + labels: [] as string[], + datasets: [{ + data: [1], + backgroundColor: [themeStore.activeTheme.colors.pieChartDefaultColor], + label: "Empty" + }] + } + if (statuses.some(([key, val]) => val > 0)) { + const dataset: any = { + data: [], + backgroundColor: [], + label: "Status", + } + statuses.forEach(([key, val]) => { + if (val !== 0) { + dataset.data.push(val) + dataset.backgroundColor.push(this.getStatusColor(key)) + chartData.labels.push(capitalize(key) + ": " + val) + } + }) + chartData.datasets[0] = dataset + } + const options = { + elements: { + arc: { + borderWidth: 0, + }, + }, + } + return ( + + ) + } + + render() { + return ( +
+
+ {this.renderChart()} +
+
+ ) + } +} diff --git a/dashboard/client/components/+workloads-overview/overview.scss b/dashboard/client/components/+workloads-overview/overview.scss new file mode 100644 index 0000000000..48fe554268 --- /dev/null +++ b/dashboard/client/components/+workloads-overview/overview.scss @@ -0,0 +1,4 @@ +.WorkloadsOverview { + --flex-gap: #{$padding * 2}; + min-height: 100%; +} diff --git a/dashboard/client/components/+workloads-overview/overview.tsx b/dashboard/client/components/+workloads-overview/overview.tsx new file mode 100644 index 0000000000..f47fd7ae19 --- /dev/null +++ b/dashboard/client/components/+workloads-overview/overview.tsx @@ -0,0 +1,74 @@ +import "./overview.scss" + +import React from "react"; +import { observable, when } from "mobx"; +import { observer } from "mobx-react"; +import { OverviewStatuses } from "./overview-statuses"; +import { RouteComponentProps } from "react-router"; +import { IWorkloadsOverviewRouteParams } from "../+workloads"; +import { eventStore } from "../+events/event.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { deploymentStore } from "../+workloads-deployments/deployments.store"; +import { daemonSetStore } from "../+workloads-daemonsets/daemonsets.store"; +import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store"; +import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; +import { jobStore } from "../+workloads-jobs/job.store"; +import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; +import { Spinner } from "../spinner"; +import { Events } from "../+events"; + +interface Props extends RouteComponentProps { +} + +@observer +export class WorkloadsOverview extends React.Component { + @observable isReady = false; + @observable isUnmounting = false; + + async componentDidMount() { + const stores = [ + podsStore, + deploymentStore, + daemonSetStore, + statefulSetStore, + replicaSetStore, + jobStore, + cronJobStore, + eventStore, + ]; + this.isReady = stores.every(store => store.isLoaded); + await Promise.all(stores.map(store => store.loadAll())); + this.isReady = true; + const unsubscribeList = stores.map(store => store.subscribe()); + await when(() => this.isUnmounting); + unsubscribeList.forEach(dispose => dispose()); + } + + componentWillUnmount() { + this.isUnmounting = true; + } + + renderContents() { + if (!this.isReady) { + return + } + return ( + <> + + + + ) + } + + render() { + return ( +
+ {this.renderContents()} +
+ ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/container-charts.tsx b/dashboard/client/components/+workloads-pods/container-charts.tsx new file mode 100644 index 0000000000..138b659403 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/container-charts.tsx @@ -0,0 +1,103 @@ +import React, { useContext } from "react"; +import { t } from "@lingui/macro"; +import { IPodMetrics } from "../../api/endpoints"; +import { BarChart, cpuOptions, memoryOptions } from "../chart"; +import { isMetricsEmpty, normalizeMetrics } from "../../api/endpoints/metrics.api"; +import { NoMetrics } from "../resource-metrics/no-metrics"; +import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; +import { _i18n } from "../../i18n"; +import { themeStore } from "../../theme.store"; + +type IContext = IResourceMetricsValue; + +export const ContainerCharts = () => { + const { params: { metrics }, tabId } = useContext(ResourceMetricsContext); + const { chartCapacityColor } = themeStore.activeTheme.colors; + + if (!metrics) return null; + if (isMetricsEmpty(metrics)) return ; + + const values = Object.values(metrics).map(metric => + normalizeMetrics(metric).data.result[0].values + ); + const [ + cpuUsage, + cpuRequests, + cpuLimits, + memoryUsage, + memoryRequests, + memoryLimits, + fsUsage + ] = values; + + const datasets = [ + // CPU + [ + { + id: "cpuUsage", + label: _i18n._(t`Usage`), + tooltip: _i18n._(t`CPU cores usage`), + borderColor: "#3D90CE", + data: cpuUsage.map(([x, y]) => ({ x, y })) + }, + { + id: "cpuRequests", + label: _i18n._(t`Requests`), + tooltip: _i18n._(t`CPU requests`), + borderColor: "#30b24d", + data: cpuRequests.map(([x, y]) => ({ x, y })) + }, + { + id: "cpuLimits", + label: _i18n._(t`Limits`), + tooltip: _i18n._(t`CPU limits`), + borderColor: chartCapacityColor, + data: cpuLimits.map(([x, y]) => ({ x, y })) + } + ], + // Memory + [ + { + id: "memoryUsage", + label: _i18n._(t`Usage`), + tooltip: _i18n._(t`Memory usage`), + borderColor: "#c93dce", + data: memoryUsage.map(([x, y]) => ({ x, y })) + }, + { + id: "memoryRequests", + label: _i18n._(t`Requests`), + tooltip: _i18n._(t`Memory requests`), + borderColor: "#30b24d", + data: memoryRequests.map(([x, y]) => ({ x, y })) + }, + { + id: "memoryLimits", + label: _i18n._(t`Limits`), + tooltip: _i18n._(t`Memory limits`), + borderColor: chartCapacityColor, + data: memoryLimits.map(([x, y]) => ({ x, y })) + } + ], + // Filesystem + [ + { + id: "fsUsage", + label: _i18n._(t`Usage`), + tooltip: _i18n._(t`Bytes consumed on this filesystem`), + borderColor: "#ffc63d", + data: fsUsage.map(([x, y]) => ({ x, y })) + } + ] + ]; + + const options = tabId == 0 ? cpuOptions : memoryOptions; + + return ( + + ); +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/index.ts b/dashboard/client/components/+workloads-pods/index.ts new file mode 100644 index 0000000000..3b9241ecca --- /dev/null +++ b/dashboard/client/components/+workloads-pods/index.ts @@ -0,0 +1,2 @@ +export * from "./pods" +export * from "./pod-details" \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-charts.tsx b/dashboard/client/components/+workloads-pods/pod-charts.tsx new file mode 100644 index 0000000000..b7a1571460 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-charts.tsx @@ -0,0 +1,131 @@ +import React, { useContext } from "react"; +import { t, Trans } from "@lingui/macro"; +import { observer } from "mobx-react"; +import { IPodMetrics } from "../../api/endpoints"; +import { BarChart, cpuOptions, memoryOptions } from "../chart"; +import { isMetricsEmpty, normalizeMetrics } from "../../api/endpoints/metrics.api"; +import { NoMetrics } from "../resource-metrics/no-metrics"; +import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; +import { _i18n } from "../../i18n"; +import { WorkloadKubeObject } from "../../api/workload-kube-object"; +import { themeStore } from "../../theme.store"; + +export const podMetricTabs = [ + CPU, + Memory, + Network, + Filesystem, +]; + +type IContext = IResourceMetricsValue; + +export const PodCharts = observer(() => { + const { params: { metrics }, tabId, object } = useContext(ResourceMetricsContext); + const { chartCapacityColor } = themeStore.activeTheme.colors; + const id = object.getId(); + + if (!metrics) return null; + if (isMetricsEmpty(metrics)) return ; + + const options = tabId == 0 ? cpuOptions : memoryOptions; + const values = Object.values(metrics).map(metric => + normalizeMetrics(metric).data.result[0].values + ); + const [ + cpuUsage, + cpuRequests, + cpuLimits, + memoryUsage, + memoryRequests, + memoryLimits, + fsUsage, + networkReceive, + networkTransit + ] = values; + + const datasets = [ + // CPU + [ + { + id: `${id}-cpuUsage`, + label: _i18n._(t`Usage`), + tooltip: _i18n._(t`Container CPU cores usage`), + borderColor: "#3D90CE", + data: cpuUsage.map(([x, y]) => ({ x, y })) + }, + { + id: `${id}-cpuRequests`, + label: _i18n._(t`Requests`), + tooltip: _i18n._(t`Container CPU requests`), + borderColor: "#30b24d", + data: cpuRequests.map(([x, y]) => ({ x, y })) + }, + { + id: `${id}-cpuLimits`, + label: _i18n._(t`Limits`), + tooltip: _i18n._(t`CPU limits`), + borderColor: chartCapacityColor, + data: cpuLimits.map(([x, y]) => ({ x, y })) + } + ], + // Memory + [ + { + id: `${id}-memoryUsage`, + label: _i18n._(t`Usage`), + tooltip: _i18n._(t`Container memory usage`), + borderColor: "#c93dce", + data: memoryUsage.map(([x, y]) => ({ x, y })) + }, + { + id: `${id}-memoryRequests`, + label: _i18n._(t`Requests`), + tooltip: _i18n._(t`Container memory requests`), + borderColor: "#30b24d", + data: memoryRequests.map(([x, y]) => ({ x, y })) + }, + { + id: `${id}-memoryLimits`, + label: _i18n._(t`Limits`), + tooltip: _i18n._(t`Container memory limits`), + borderColor: chartCapacityColor, + data: memoryLimits.map(([x, y]) => ({ x, y })) + } + ], + // Network + [ + { + id: `${id}-networkReceive`, + label: _i18n._(t`Receive`), + tooltip: _i18n._(t`Bytes received by all containers`), + borderColor: "#64c5d6", + data: networkReceive.map(([x, y]) => ({ x, y })) + }, + { + id: `${id}-networkTransit`, + label: _i18n._(t`Transit`), + tooltip: _i18n._(t`Bytes transmitted from all containers`), + borderColor: "#46cd9e", + data: networkTransit.map(([x, y]) => ({ x, y })) + } + ], + // Filesystem + [ + { + id: `${id}-fsUsage`, + label: _i18n._(t`Usage`), + tooltip: _i18n._(t`Bytes consumed on this filesystem`), + borderColor: "#ffc63d", + data: fsUsage.map(([x, y]) => ({ x, y })) + } + ] + ]; + + return ( + + ); +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-container-env.scss b/dashboard/client/components/+workloads-pods/pod-container-env.scss new file mode 100644 index 0000000000..90e767c6b9 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-container-env.scss @@ -0,0 +1,20 @@ +.ContainerEnvironment { + .secret-button { + &.loading { + opacity: 0.5; + pointer-events: none; + } + } + + .variable { + padding-bottom: $padding; + + &:last-child { + padding-bottom: 0; + } + + .var-name { + color: $textColorPrimary + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-container-env.tsx b/dashboard/client/components/+workloads-pods/pod-container-env.tsx new file mode 100644 index 0000000000..2896a06215 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-container-env.tsx @@ -0,0 +1,137 @@ +import "./pod-container-env.scss"; + +import React, { useEffect, useState } from "react"; +import flatten from "lodash/flatten"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { IPodContainer, Secret } from "../../api/endpoints"; +import { DrawerItem } from "../drawer"; +import { autorun } from "mobx"; +import { secretsStore } from "../+config-secrets/secrets.store"; +import { configMapsStore } from "../+config-maps/config-maps.store"; +import { Icon } from "../icon"; +import { base64, cssNames } from "../../utils"; + +interface Props { + container: IPodContainer; + namespace: string; +} + +export const ContainerEnvironment = observer((props: Props) => { + const { container: { env, envFrom }, namespace } = props + + useEffect( + () => + autorun(() => { + env && env.forEach(variable => { + const { valueFrom } = variable + if (valueFrom && valueFrom.configMapKeyRef) { + configMapsStore.load({ name: valueFrom.configMapKeyRef.name, namespace }) + } + }) + envFrom && envFrom.forEach(item => { + const { configMapRef } = item + if (configMapRef && configMapRef.name) { + configMapsStore.load({ name: configMapRef.name, namespace }) + } + }) + }), + [] + ) + + const renderEnv = () => { + return env.map(variable => { + const { name, value, valueFrom } = variable + let secretValue = null + + if (value) { + secretValue = value + } + if (valueFrom) { + const { fieldRef, secretKeyRef, configMapKeyRef } = valueFrom + if (fieldRef) { + const { apiVersion, fieldPath } = fieldRef + secretValue = `fieldRef(${apiVersion}:${fieldPath})` + } + if (secretKeyRef) { + secretValue = ( + + ) + } + if (configMapKeyRef) { + const { name, key } = configMapKeyRef + const configMap = configMapsStore.getByName(name, namespace) + secretValue = configMap ? + configMap.data[key] : + `configMapKeyRef(${name}${key})` + } + } + + return ( +
+ {name}: {secretValue} +
+ ) + }) + } + + 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} +
+ )) + }) + return flatten(envVars) + } + + return ( + Environment} className="ContainerEnvironment"> + {env && renderEnv()} + {envFrom && renderEnvFrom()} + + ) +}) + +interface SecretKeyProps { + reference: { + name: string; + key: string; + }; + namespace: string; +} + +const SecretKey = (props: SecretKeyProps) => { + const { reference: { name, key }, namespace } = props + const [loading, setLoading] = useState(false) + const [secret, setSecret] = useState() + + const showKey = async () => { + setLoading(true) + const secret = await secretsStore.load({ name, namespace }); + setLoading(false) + setSecret(secret) + } + + if (!secret) { + return ( + <> + secretKeyRef({name}.{key})  + Show} + onClick={showKey} + /> + + ) + } + return <>{base64.decode(secret.data[key])} +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-affinities.scss b/dashboard/client/components/+workloads-pods/pod-details-affinities.scss new file mode 100644 index 0000000000..65ed61e0a9 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-affinities.scss @@ -0,0 +1,5 @@ +.PodDetailsAffinities { + .ace-container { + height: 200px + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-affinities.tsx b/dashboard/client/components/+workloads-pods/pod-details-affinities.tsx new file mode 100644 index 0000000000..06c992a0b8 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-affinities.tsx @@ -0,0 +1,34 @@ +import "./pod-details-affinities.scss"; +import * as React from "react"; +import jsYaml from "js-yaml"; +import { Trans } from "@lingui/macro"; +import { AceEditor } from "../ace-editor"; +import { DrawerParamToggler, DrawerItem } from "../drawer"; +import { Pod, Deployment, DaemonSet, StatefulSet, ReplicaSet, Job } from "../../api/endpoints"; + +interface Props { + workload: Pod | Deployment | DaemonSet | StatefulSet | ReplicaSet | Job; +} + +export class PodDetailsAffinities extends React.Component { + render() { + const { workload } = this.props + const affinitiesNum = workload.getAffinityNumber() + const affinities = workload.getAffinity() + if (!affinitiesNum) return null + return ( + Affinities} className="PodDetailsAffinities"> + +
+ +
+
+
+ ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-container.scss b/dashboard/client/components/+workloads-pods/pod-details-container.scss new file mode 100644 index 0000000000..4016f33722 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-container.scss @@ -0,0 +1,44 @@ +.PodDetailsContainer { + margin: $margin * 2 0; + + .mount-path { + &:first-child { + margin-top: 0; + } + + display: block; + font-family: $font-monospace; + font-size: 90%; + background: $colorVague; + color: $textColorSecondary; + border-radius: $radius; + padding: .2em .4em; + margin-top: $margin; + } + + .pod-container-title { + font-weight: bold; + margin-bottom: $margin; + + .StatusBrick { + background: $colorTerminated; + margin-right: $margin; + + @include pod-status-bgs; + + &.running:not(.ready) { + background-color: $pod-status-pending-color; + } + } + } + + .status { + color: $colorTerminated; + + @include pod-status-colors; + } + + .Badge { + white-space: normal; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-container.tsx b/dashboard/client/components/+workloads-pods/pod-details-container.tsx new file mode 100644 index 0000000000..d66ace0621 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-container.tsx @@ -0,0 +1,127 @@ +import "./pod-details-container.scss" + +import * as React from "react"; +import { t, Trans } from "@lingui/macro"; +import { IPodContainer, Pod } from "../../api/endpoints"; +import { DrawerItem } from "../drawer"; +import { cssNames } from "../../utils"; +import { StatusBrick } from "../status-brick"; +import { Badge } from "../badge"; +import { ContainerEnvironment } from "./pod-container-env"; +import { ResourceMetrics } from "../resource-metrics"; +import { IMetrics } from "../../api/endpoints/metrics.api"; +import { ContainerCharts } from "./container-charts"; +import { _i18n } from "../../i18n"; + +interface Props { + pod: Pod; + container: IPodContainer; + metrics?: { [key: string]: IMetrics }; +} + +export class PodDetailsContainer extends React.Component { + render() { + const { pod, container, metrics } = this.props + if (!pod || !container) return null + const { name, image, imagePullPolicy, ports, volumeMounts, command, args } = container + const status = pod.getContainerStatuses().find(status => status.name === container.name) + const state = status ? Object.keys(status.state)[0] : "" + const ready = status ? status.ready : "" + const liveness = pod.getLivenessProbe(container) + const readiness = pod.getReadinessProbe(container) + const isInitContainer = !!pod.getInitContainers().find(c => c.name == name); + const metricTabs = [ + CPU, + Memory, + Filesystem, + ]; + return ( +
+
+ {name} +
+ {!isInitContainer && + + + + } + {status && + Status}> + + {state}{ready ? `, ${_i18n._(t`ready`)}` : ""} + {state === 'terminated' ? ` - ${status.state.terminated.reason} (${_i18n._(t`exit code`)}: ${status.state.terminated.exitCode})` : ''} + + + } + Image}> + {image} + + {imagePullPolicy && imagePullPolicy !== "IfNotPresent" && + ImagePullPolicy}> + {imagePullPolicy} + + } + {ports && ports.length > 0 && + Ports}> + { + ports.map(port => { + const { name, containerPort, protocol } = port; + const key = `${container.name}-port-${containerPort}-${protocol}` + return ( +
+ {name ? name + ': ' : ''}{containerPort}/{protocol} +
+ ) + }) + } +
+ } + {} + {volumeMounts && volumeMounts.length > 0 && + Mounts}> + { + volumeMounts.map(mount => { + const { name, mountPath, readOnly } = mount; + return ( + + {mountPath} + from {name} ({readOnly ? 'ro' : 'rw'}) + + ) + }) + } + + } + {liveness.length > 0 && + Liveness} labelsOnly> + { + liveness.map((value, index) => ( + + )) + } + + } + {readiness.length > 0 && + Readiness} labelsOnly> + { + readiness.map((value, index) => ( + + )) + } + + } + {command && + Command}> + {command} + + } + + {args && + Arguments}> + {args.join(' ')} + + } +
+ ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-list.scss b/dashboard/client/components/+workloads-pods/pod-details-list.scss new file mode 100644 index 0000000000..f248cf376d --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-list.scss @@ -0,0 +1,53 @@ +.PodDetailsList { + position: relative; + + .Table { + margin: 0 (-$margin * 3); + + &.virtual { + height: 500px; + } + } + + .TableCell { + &:first-child { + margin-left: $margin; + } + + &:last-child { + margin-right: $margin; + } + + &.name { + flex-grow: 2; + } + + &.namespace { + flex-grow: 1.2; + } + + &.cpu { + align-self: center; + + .LineProgress { + color: $kontenaBlue; + } + } + + &.memory { + align-self: center; + + .LineProgress { + color: $kontenaMagenta; + } + } + + &.warning { + @include table-cell-warning; + } + + &.status { + @include pod-status-colors; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-list.tsx b/dashboard/client/components/+workloads-pods/pod-details-list.tsx new file mode 100644 index 0000000000..313c505565 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-list.tsx @@ -0,0 +1,155 @@ +import "./pod-details-list.scss"; + +import React from "react"; +import kebabCase from "lodash/kebabCase"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { podsStore } from "./pods.store"; +import { Pod } from "../../api/endpoints"; +import { autobind, bytesToUnits, cssNames, interval, prevDefault } from "../../utils"; +import { KubeEventIcon } from "../+events/kube-event-icon"; +import { LineProgress } from "../line-progress"; +import { KubeObject } from "../../api/kube-object"; +import { Table, TableCell, TableHead, TableRow } from "../table"; +import { showDetails } from "../../navigation"; +import { reaction } from "mobx"; +import { Spinner } from "../spinner"; +import { DrawerTitle } from "../drawer"; + +enum sortBy { + name = "name", + namespace = "namespace", + cpu = "cpu", + memory = "memory", +} + +interface Props extends OptionalProps { + pods: Pod[]; + owner: KubeObject; +} + +interface OptionalProps { + maxCpu?: number; + maxMemory?: number; + showTitle?: boolean; +} + +@observer +export class PodDetailsList extends React.Component { + static defaultProps: OptionalProps = { + showTitle: true + } + + private metricsWatcher = interval(120, () => { + podsStore.loadKubeMetrics(this.props.owner.getNs()); + }); + + private sortingCallbacks = { + [sortBy.name]: (pod: Pod) => pod.getName(), + [sortBy.namespace]: (pod: Pod) => pod.getNs(), + [sortBy.cpu]: (pod: Pod) => podsStore.getPodKubeMetrics(pod).cpu, + [sortBy.memory]: (pod: Pod) => podsStore.getPodKubeMetrics(pod).memory, + } + + componentDidMount() { + this.metricsWatcher.start(true); + disposeOnUnmount(this, [ + reaction(() => this.props.owner, () => this.metricsWatcher.restart(true)) + ]) + } + + componentWillUnmount() { + this.metricsWatcher.stop(); + } + + renderCpuUsage(id: string, usage: number) { + const { maxCpu } = this.props; + const value = usage.toFixed(3); + const tooltip = ( +

CPU: {Math.ceil(usage * 100) / maxCpu}%
{usage.toFixed(3)}

+ ); + if (!maxCpu) { + if (parseFloat(value) === 0) return 0; + return value; + } + return ( + + ); + } + + renderMemoryUsage(id: string, usage: number) { + const { maxMemory } = this.props; + const tooltip = ( +

Memory: {Math.ceil(usage * 100 / maxMemory)}%
{bytesToUnits(usage, 3)}

+ ); + if (!maxMemory) return usage ? bytesToUnits(usage) : 0; + return ( + + ); + } + + @autobind() + getTableRow(uid: string) { + const { pods } = this.props; + const pod = pods.find(pod => pod.getId() == uid); + const metrics = podsStore.getPodKubeMetrics(pod); + return ( + showDetails(pod.selfLink, false))} + > + {pod.getName()} + {pod.hasIssues() && } + {pod.getNs()} + {this.renderCpuUsage(`cpu-${pod.getId()}`, metrics.cpu)} + {this.renderMemoryUsage(`memory-${pod.getId()}`, metrics.memory)} + {pod.getStatusMessage()} + + ); + } + + render() { + const { pods, showTitle } = this.props; + const virtual = pods.length > 100; + if (!pods.length && !podsStore.isLoaded) return ( +
+ ); + if (!pods.length) return null; + return ( +
+ {showTitle && Pods}/>} + + + Name + + Namespace + CPU + Memory + Status + + { + !virtual && pods.map(pod => this.getTableRow(pod.getId())) + } +
+
+ ); + } +} diff --git a/dashboard/client/components/+workloads-pods/pod-details-secrets.scss b/dashboard/client/components/+workloads-pods/pod-details-secrets.scss new file mode 100644 index 0000000000..d1ee08c51b --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-secrets.scss @@ -0,0 +1,10 @@ +.PodDetailsSecrets { + a { + display: block; + margin-bottom: $margin; + + &:last-child { + margin-bottom: 0; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-secrets.tsx b/dashboard/client/components/+workloads-pods/pod-details-secrets.tsx new file mode 100644 index 0000000000..3b940e6855 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-secrets.tsx @@ -0,0 +1,44 @@ +import "./pod-details-secrets.scss"; + +import React, { Component } from "react"; +import { Link } from "react-router-dom"; +import { autorun, observable } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { Pod, Secret, secretsApi } from "../../api/endpoints"; +import { getDetailsUrl } from "../../navigation"; + +interface Props { + pod: Pod; +} + +@observer +export class PodDetailsSecrets extends Component { + @observable secrets: Secret[] = []; + + @disposeOnUnmount + secretsLoader = autorun(async () => { + const { pod } = this.props; + this.secrets = await Promise.all( + pod.getSecrets().map(secretName => secretsApi.get({ + name: secretName, + namespace: pod.getNs(), + })) + ); + }); + + render() { + return ( +
+ { + this.secrets.map(secret => { + return ( + + {secret.getName()} + + ); + }) + } +
+ ); + } +} diff --git a/dashboard/client/components/+workloads-pods/pod-details-statuses.scss b/dashboard/client/components/+workloads-pods/pod-details-statuses.scss new file mode 100644 index 0000000000..fd436a3cd2 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-statuses.scss @@ -0,0 +1,18 @@ +.PodDetailsStatuses { + span { + padding-right: $margin; + + &.running { + color: $pod-status-running-color; + } + &.pending { + color: $pod-status-pending-color; + } + &.succeeded { + color: $pod-status-succeeded-color; + } + &.failed { + color: $pod-status-failed-color; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-statuses.tsx b/dashboard/client/components/+workloads-pods/pod-details-statuses.tsx new file mode 100644 index 0000000000..49dc634bf6 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-statuses.tsx @@ -0,0 +1,28 @@ +import "./pod-details-statuses.scss"; +import * as React from "react"; +import countBy from "lodash/countBy"; +import kebabCase from "lodash/kebabCase"; +import { Pod } from "../../api/endpoints"; + +interface Props { + pods: Pod[]; +} + +export class PodDetailsStatuses extends React.Component { + render() { + const { pods } = this.props + if (!pods.length) return null + const statuses = countBy(pods.map(pod => pod.getStatus())) + return ( +
+ { + Object.keys(statuses).map(key => ( + + {key}: {statuses[key]} + + )) + } +
+ ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-tolerations.scss b/dashboard/client/components/+workloads-pods/pod-details-tolerations.scss new file mode 100644 index 0000000000..0aa68fa1d6 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-tolerations.scss @@ -0,0 +1,5 @@ +.PodDetailsTolerations { + .toleration { + margin-bottom: $margin; + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details-tolerations.tsx b/dashboard/client/components/+workloads-pods/pod-details-tolerations.tsx new file mode 100644 index 0000000000..20afcf7329 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details-tolerations.tsx @@ -0,0 +1,36 @@ +import "./pod-details-tolerations.scss"; +import * as React from "react"; +import { Trans } from "@lingui/macro"; +import { Pod, Deployment, DaemonSet, StatefulSet, ReplicaSet, Job } from "../../api/endpoints"; +import { DrawerParamToggler, DrawerItem } from "../drawer"; + +interface Props { + workload: Pod | Deployment | DaemonSet | StatefulSet | ReplicaSet | Job; +} + +export class PodDetailsTolerations extends React.Component { + render() { + const { workload } = this.props + const tolerations = workload.getTolerations() + if (!tolerations.length) return null + return ( + Tolerations} className="PodDetailsTolerations"> + + { + tolerations.map((toleration, index) => { + const { key, operator, effect, tolerationSeconds } = toleration + return ( +
+ Key}>{key} + {operator && Operator}>{operator}} + {effect && Effect}>{effect}} + {!!tolerationSeconds && Effect}>{tolerationSeconds}} +
+ ) + }) + } +
+
+ ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details.scss b/dashboard/client/components/+workloads-pods/pod-details.scss new file mode 100644 index 0000000000..4e45e0f0bd --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details.scss @@ -0,0 +1,13 @@ +.PodDetails { + .status { + @include pod-status-colors; + } + + .volume { + .title { + margin-top: $margin * 2; + margin-bottom: $margin; + font-weight: bold; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-details.tsx b/dashboard/client/components/+workloads-pods/pod-details.tsx new file mode 100644 index 0000000000..a4b66df672 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-details.tsx @@ -0,0 +1,212 @@ +import "./pod-details.scss" + +import * as React from "react"; +import kebabCase from "lodash/kebabCase"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { Link } from "react-router-dom"; +import { autorun, observable, reaction, toJS } from "mobx"; +import { Trans } from "@lingui/macro"; +import { IPodMetrics, nodesApi, Pod, podsApi, pvcApi } from "../../api/endpoints"; +import { DrawerItem, DrawerTitle } from "../drawer"; +import { Badge } from "../badge"; +import { autobind, cssNames, interval } from "../../utils"; +import { PodDetailsContainer } from "./pod-details-container"; +import { PodDetailsAffinities } from "./pod-details-affinities"; +import { PodDetailsTolerations } from "./pod-details-tolerations"; +import { Icon } from "../icon"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { PodDetailsSecrets } from "./pod-details-secrets"; +import { ResourceMetrics } from "../resource-metrics"; +import { podsStore } from "./pods.store"; +import { getDetailsUrl } from "../../navigation"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { getItemMetrics } from "../../api/endpoints/metrics.api"; +import { PodCharts, podMetricTabs } from "./pod-charts"; +import { lookupApiLink } from "../../api/kube-api"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class PodDetails extends React.Component { + @observable containerMetrics: IPodMetrics; + + private watcher = interval(60, () => this.loadMetrics()); + + componentDidMount() { + disposeOnUnmount(this, [ + autorun(() => { + this.containerMetrics = null; + this.loadMetrics(); + }), + reaction(() => this.props.object, () => { + podsStore.reset(); + }) + ]); + this.watcher.start(); + } + + componentWillUnmount() { + podsStore.reset(); + } + + @autobind() + async loadMetrics() { + const { object: pod } = this.props; + this.containerMetrics = await podsStore.loadContainerMetrics(pod); + } + + render() { + const { object: pod } = this.props; + if (!pod) return null; + const { status, spec } = pod; + const { conditions, podIP } = status; + const { nodeName } = spec; + const ownerRefs = pod.getOwnerRefs(); + const nodeSelector = pod.getNodeSelectors(); + const volumes = pod.getVolumes(); + const labels = pod.getLabels(); + const metrics = podsStore.metrics; + return ( +
+ podsStore.loadMetrics(pod)} + tabs={podMetricTabs} object={pod} params={{ metrics }} + > + + + + Status}> + {pod.getStatusMessage()} + + Node}> + {nodeName && ( + + {nodeName} + + )} + + Pod IP}> + {podIP} + + Priority Class}> + {pod.getPriorityClassName()} + + QoS Class}> + {pod.getQosClass()} + + {conditions && + Conditions} className="conditions" labelsOnly> + { + conditions.map(condition => { + const { type, status, lastTransitionTime } = condition; + return ( + Last transition time: {lastTransitionTime}} + /> + ) + }) + } + + } + {nodeSelector.length > 0 && + Node Selector}> + { + nodeSelector.map(label => ( + + )) + } + + } + {ownerRefs.length > 0 && + Controlled By}> + { + ownerRefs.map(ref => { + const { name, kind } = ref; + const ownerDetailsUrl = getDetailsUrl(lookupApiLink(ref, pod)); + return ( +

+ {kind} {name} +

+ ); + }) + } +
+ } + + + + {pod.getSecrets().length > 0 && ( + Secrets}> + + + )} + + {pod.getInitContainers() && pod.getInitContainers().length > 0 && + Init Containers}/> + } + { + pod.getInitContainers() && pod.getInitContainers().map(container => { + return + }) + } + Containers}/> + { + pod.getContainers().map(container => { + const { name } = container; + const metrics = getItemMetrics(toJS(this.containerMetrics), name); + return ( + + ) + }) + } + + {volumes.length > 0 && ( + <> + Volumes}/> + {volumes.map(volume => { + const claimName = volume.persistentVolumeClaim ? volume.persistentVolumeClaim.claimName : null; + return ( +
+
+ + {volume.name} +
+ Type}> + {Object.keys(volume)[1]} + + {claimName && ( + Claim Name}> + {claimName} + + + )} +
+ ) + })} + + )} + +
+ ) + } +} + +apiManager.registerViews(podsApi, { + Details: PodDetails +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-logs-dialog.scss b/dashboard/client/components/+workloads-pods/pod-logs-dialog.scss new file mode 100644 index 0000000000..0c8845c78f --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-logs-dialog.scss @@ -0,0 +1,110 @@ +.PodLogsDialog { + --log-line-height: 16px; + + .Wizard { + width: 90vw; + max-height: none; + + .WizardStep { + & > .step-content.scrollable { + max-height: none; + } + + & > :last-child { + padding: $padding * 2; + } + } + } + + .log-controls { + padding-bottom: $padding * 2; + + .time-range { + flex-grow: 2; + text-align: center; + } + + .controls { + width: 100%; + } + + .control-buttons { + margin-right: 0; + white-space: nowrap; + + .Icon { + border-radius: $radius; + padding: 3px; + + &:hover { + color: $textColorPrimary; + background: #f4f4f4; + } + + &.active { + color: $primary; + background: #f4f4f4; + } + } + } + + @include media("<=desktop") { + flex-direction: column; + align-items: start; + + .container { + width: 100%; + } + + .controls { + margin-top: $margin * 2; + + .time-range { + text-align: left; + } + } + } + } + + .logs-area { + position: relative; + @include custom-scrollbar; + + // fix for `this.logsArea.scrollTop = this.logsArea.scrollHeight` + // `overflow: overlay` don't allow scroll to the last line + overflow: auto; + + color: #C5C8C6; + background: #1D1F21; + line-height: var(--log-line-height); + border-radius: 2px; + height: 45vh; + padding: $padding / 4 $padding; + font-family: $font-monospace; + font-size: smaller; + white-space: pre; + + .no-logs { + text-align: center; + } + } + + .new-logs-sep { + position: relative; + display: block; + height: 0; + border-top: 1px solid $primary; + margin: $margin * 2; + + &:after { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + content: 'new'; + background: $primary; + color: white; + padding: $padding / 3 $padding /2; + border-radius: $radius; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-logs-dialog.tsx b/dashboard/client/components/+workloads-pods/pod-logs-dialog.tsx new file mode 100644 index 0000000000..ec5de390a1 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-logs-dialog.tsx @@ -0,0 +1,304 @@ +import "./pod-logs-dialog.scss"; + +import * as React from "react"; +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 { IPodContainer, Pod, podsApi } from "../../api/endpoints"; +import { Icon } from "../icon"; +import { Select, SelectOption } from "../select"; +import { Spinner } from "../spinner"; +import { cssNames, downloadFile, interval } from "../../utils"; + +interface IPodLogsDialogData { + pod: Pod; + container?: IPodContainer; +} + +interface Props extends Partial { +} + +@observer +export class PodLogsDialog extends React.Component { + @observable static isOpen = false; + @observable static data: IPodLogsDialogData = null; + + static open(pod: Pod, container?: IPodContainer) { + PodLogsDialog.isOpen = true; + PodLogsDialog.data = { pod, container }; + } + + static close() { + PodLogsDialog.isOpen = false; + } + + get data() { + return PodLogsDialog.data; + } + + private logsArea: HTMLDivElement; + private refresher = interval(5, () => this.load()); + private containers: IPodContainer[] = [] + private initContainers: IPodContainer[] = [] + private lastLineIsShown = true; // used for proper auto-scroll content after refresh + + @observable logs = ""; // latest downloaded logs for pod + @observable newLogs = ""; // new logs since dialog is open + @observable logsReady = false; + @observable selectedContainer: IPodContainer; + @observable showTimestamps = true; + @observable tailLines = 1000; + + lineOptions = [ + { label: _i18n._(t`All logs`), value: Number.MAX_SAFE_INTEGER }, + { label: 1000, value: 1000 }, + { label: 10000, value: 10000 }, + { label: 100000, value: 100000 }, + ] + + onOpen = async () => { + const { pod, container } = this.data; + this.containers = pod.getContainers(); + this.initContainers = pod.getInitContainers(); + this.selectedContainer = container || this.containers[0]; + await this.load(); + this.refresher.start(); + } + + onClose = () => { + this.resetLogs(); + this.refresher.stop(); + } + + close = () => { + PodLogsDialog.close(); + } + + load = async () => { + if (!this.data) return; + const { pod } = this.data; + try { + // if logs already loaded, check the latest timestamp for getting updates only from this point + const logsTimestamps = this.getTimestamps(this.newLogs || this.logs); + let lastLogDate = new Date(0) + if (logsTimestamps) { + lastLogDate = new Date(logsTimestamps.slice(-1)[0]); + lastLogDate.setSeconds(lastLogDate.getSeconds() + 1); // avoid duplicates from last second + } + const namespace = pod.getNs(); + const name = pod.getName(); + const logs = await podsApi.getLogs({ namespace, name }, { + container: this.selectedContainer.name, + timestamps: true, + tailLines: this.tailLines ? this.tailLines : undefined, + sinceTime: lastLogDate.toISOString(), + }); + if (!this.logs) { + this.logs = logs; + } + else if (logs) { + this.newLogs = `${this.newLogs}\n${logs}`.trim(); + } + } catch (error) { + this.logs = [ + _i18n._(t`Failed to load logs: ${error.message}`), + _i18n._(t`Reason: ${error.reason} (${error.code})`), + ].join("\n") + } + this.logsReady = true; + } + + reload = async () => { + this.resetLogs(); + this.refresher.stop(); + await this.load(); + this.refresher.start(); + } + + componentDidUpdate() { + // scroll logs only when it's already in the end, + // otherwise it can interrupt reading by jumping after loading new logs update + if (this.logsArea && this.lastLineIsShown) { + this.logsArea.scrollTop = this.logsArea.scrollHeight; + } + } + + onScroll = (evt: React.UIEvent) => { + const logsArea = evt.currentTarget; + const { scrollHeight, clientHeight, scrollTop } = logsArea; + this.lastLineIsShown = clientHeight + scrollTop === scrollHeight; + }; + + getLogs() { + const { logs, newLogs, showTimestamps } = this; + return { + logs: showTimestamps ? logs : this.removeTimestamps(logs), + newLogs: showTimestamps ? newLogs : this.removeTimestamps(newLogs), + } + } + + getTimestamps(logs: string) { + return logs.match(/^\d+\S+/gm); + } + + removeTimestamps(logs: string) { + return logs.replace(/^\d+.*?\s/gm, ""); + } + + resetLogs() { + this.logs = ""; + this.newLogs = ""; + this.lastLineIsShown = true; + this.logsReady = false; + } + + onContainerChange = (option: SelectOption) => { + this.selectedContainer = this.containers + .concat(this.initContainers) + .find(container => container.name === option.value); + this.reload(); + } + + onTailLineChange = (option: SelectOption) => { + this.tailLines = option.value; + this.reload(); + } + + formatOptionLabel = (option: SelectOption) => { + const { value, label } = option; + return label || <> {value}; + } + + toggleTimestamps = () => { + this.showTimestamps = !this.showTimestamps; + } + + downloadLogs = () => { + const { logs, newLogs } = this.getLogs(); + const fileName = this.selectedContainer.name + ".log"; + const fileContents = logs + newLogs; + downloadFile(fileName, fileContents, "text/plain"); + } + + get containerSelectOptions() { + return [ + { + label: _i18n._(t`Containers`), + options: this.containers.map(container => { + return { value: container.name } + }), + }, + { + label: _i18n._(t`Init Containers`), + options: this.initContainers.map(container => { + return { value: container.name } + }), + } + ]; + } + + renderControlsPanel() { + const { logsReady, showTimestamps } = this; + if (!logsReady) return; + const timestamps = this.getTimestamps(this.logs + this.newLogs); + let from = ""; + let to = ""; + if (timestamps) { + from = new Date(timestamps[0]).toLocaleString(); + to = new Date(timestamps[timestamps.length - 1]).toLocaleString(); + } + return ( +
+
+ {timestamps && From {from} to {to}} +
+
+ + +
+
+ ) + } + + renderLogs() { + if (!this.logsReady) { + return + } + const { logs, newLogs } = this.getLogs(); + if (!logs && !newLogs) { + return

There are no logs available for container.

+ } + return ( + <> + {logs} + {newLogs && ( + <> +

+ {newLogs} + + )} + + ); + } + + render() { + const { ...dialogProps } = this.props; + const { selectedContainer, tailLines } = this; + const podName = this.data ? this.data.pod.getName() : ""; + const header =

{podName} Logs
; + return ( + + + Close}> +
+
+ Container + {selectedContainer && ( + +
+ {this.renderControlsPanel()} +
+
this.logsArea = e}> + {this.renderLogs()} +
+
+
+
+ ) + } +} diff --git a/dashboard/client/components/+workloads-pods/pod-menu.scss b/dashboard/client/components/+workloads-pods/pod-menu.scss new file mode 100644 index 0000000000..99cedad45f --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-menu.scss @@ -0,0 +1,10 @@ +.PodMenu { + .StatusBrick { + margin-right: $margin; + @include pod-status-bgs; + + &.running:not(.ready) { + background-color: $pod-status-pending-color; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pod-menu.tsx b/dashboard/client/components/+workloads-pods/pod-menu.tsx new file mode 100644 index 0000000000..6d42a9e2e0 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pod-menu.tsx @@ -0,0 +1,111 @@ +import "./pod-menu.scss"; + +import * as React from "react"; +import { t, Trans } from "@lingui/macro"; +import { MenuItem, SubMenu } from "../menu"; +import { IPodContainer, Pod } from "../../api/endpoints"; +import { Icon } from "../icon"; +import { StatusBrick } from "../status-brick"; +import { PodLogsDialog } from "./pod-logs-dialog"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { cssNames, prevDefault } from "../../utils"; +import { terminalStore } from "../dock/terminal.store"; +import { _i18n } from "../../i18n"; +import { hideDetails } from "../../navigation"; + +interface Props extends KubeObjectMenuProps { +} + +export class PodMenu extends React.Component { + execShell(container?: string) { + hideDetails(); + const { object: pod } = this.props + const containerParam = container ? `-c ${container}` : "" + const command = `kubectl exec -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam} "--" sh -c "((clear && bash) || (clear && ash) || (clear && sh))"` + terminalStore.sendCommand(command, { + enter: true, + newTab: true, + }); + } + + showLogs(container: IPodContainer) { + PodLogsDialog.open(this.props.object, container); + } + + renderShellMenu() { + const { object: pod, toolbar } = this.props + const containers = pod.getRunningContainers(); + if (!containers.length) return; + return ( + this.execShell(containers[0].name))}> + + Shell + {containers.length > 1 && ( + <> + + + { + containers.map(container => { + const { name } = container; + return ( + this.execShell(name))} className="flex align-center"> + + {name} + + ) + }) + } + + + )} + + ) + } + + renderLogsMenu() { + const { object: pod, toolbar } = this.props + const containers = pod.getAllContainers(); + const statuses = pod.getContainerStatuses(); + if (!containers.length) return; + return ( + this.showLogs(containers[0]))}> + + Logs + {containers.length > 1 && ( + <> + + + { + containers.map(container => { + const { name } = container + const status = statuses.find(status => status.name === name); + const brick = status ? ( + + ) : null + return ( + this.showLogs(container))} className="flex align-center"> + {brick} + {name} + + ) + }) + } + + + )} + + ) + } + + render() { + const { ...menuProps } = this.props; + return ( + + {this.renderShellMenu()} + {this.renderLogsMenu()} + + ) + } +} diff --git a/dashboard/client/components/+workloads-pods/pods.scss b/dashboard/client/components/+workloads-pods/pods.scss new file mode 100644 index 0000000000..7a6b9fe34b --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pods.scss @@ -0,0 +1,27 @@ +@import "../+workloads/workloads-mixins"; + +.Pods { + .TableCell { + &.name { + flex-grow: 2; + } + + &.warning { + @include table-cell-warning; + } + + &.containers { + .StatusBrick { + @include pod-status-bgs; + + &.running:not(.ready) { + background-color: $pod-status-pending-color; + } + } + } + + &.status { + @include pod-status-colors; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-pods/pods.store.ts b/dashboard/client/components/+workloads-pods/pods.store.ts new file mode 100644 index 0000000000..fe95ba4e48 --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pods.store.ts @@ -0,0 +1,79 @@ +import countBy from "lodash/countBy"; +import { action, observable } from "mobx"; +import { KubeObjectStore } from "../../kube-object.store"; +import { autobind, cpuUnitsToNumber, unitsToBytes } from "../../utils"; +import { IPodMetrics, Pod, PodMetrics, podMetricsApi, podsApi } from "../../api/endpoints"; +import { WorkloadKubeObject } from "../../api/workload-kube-object"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class PodsStore extends KubeObjectStore { + api = podsApi; + + @observable metrics: IPodMetrics = null; + @observable kubeMetrics = observable.array([]); + + @action + async loadMetrics(pod: Pod) { + this.metrics = await podsApi.getMetrics([pod], pod.getNs()); + } + + loadContainerMetrics(pod: Pod) { + return podsApi.getMetrics([pod], pod.getNs(), "container, namespace"); + } + + async loadKubeMetrics(namespace?: string) { + const metrics = await podMetricsApi.list({ namespace }); + this.kubeMetrics.replace(metrics); + } + + getPodsByOwner(workload: WorkloadKubeObject): Pod[] { + if (!workload) return []; + return this.items.filter(pod => { + const owners = pod.getOwnerRefs() + if (!owners.length) return + return owners.find(owner => owner.uid === workload.getId()) + }) + } + + getPodsByNode(node: string) { + if (!this.isLoaded) return [] + return this.items.filter(pod => pod.spec.nodeName === node) + } + + getStatuses(pods: Pod[]) { + return countBy(pods.map(pod => pod.getStatus())) + } + + getPodKubeMetrics(pod: Pod) { + const containers = pod.getContainers(); + const empty = { cpu: 0, memory: 0 }; + const metrics = this.kubeMetrics.find(metric => { + return [ + metric.getName() === pod.getName(), + metric.getNs() === pod.getNs() + ].every(v => v); + }); + if (!metrics) return empty; + return containers.reduce((total, container) => { + const metric = metrics.containers.find(item => item.name == container.name); + let cpu = "0" + let memory = "0" + if (metric && metric.usage) { + cpu = metric.usage.cpu || "0" + memory = metric.usage.memory || "0" + } + return { + cpu: total.cpu + cpuUnitsToNumber(cpu), + memory: total.memory + unitsToBytes(memory) + } + }, empty); + } + + reset() { + this.metrics = null; + } +} + +export const podsStore = new PodsStore(); +apiManager.registerStore(podsApi, podsStore); diff --git a/dashboard/client/components/+workloads-pods/pods.tsx b/dashboard/client/components/+workloads-pods/pods.tsx new file mode 100644 index 0000000000..47161b445b --- /dev/null +++ b/dashboard/client/components/+workloads-pods/pods.tsx @@ -0,0 +1,130 @@ +import "./pods.scss" + +import React, { Fragment } from "react"; +import { observer } from "mobx-react"; +import { Link } from "react-router-dom"; +import { Trans } from "@lingui/macro"; +import { podsStore } from "./pods.store"; +import { RouteComponentProps } from "react-router"; +import { volumeClaimStore } from "../+storage-volume-claims/volume-claim.store"; +import { IPodsRouteParams } from "../+workloads"; +import { eventStore } from "../+events/event.store"; +import { KubeObjectListLayout } from "../kube-object"; +import { Pod, podsApi } from "../../api/endpoints"; +import { PodMenu } from "./pod-menu"; +import { StatusBrick } from "../status-brick"; +import { cssNames, stopPropagation } from "../../utils"; +import { TooltipContent } from "../tooltip"; +import { KubeEventIcon } from "../+events/kube-event-icon"; +import { getDetailsUrl } from "../../navigation"; +import toPairs from "lodash/toPairs"; +import startCase from "lodash/startCase"; +import kebabCase from "lodash/kebabCase"; +import { lookupApiLink } from "../../api/kube-api"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + containers = "containers", + restarts = "restarts", + age = "age", + qos = "qos", + owners = "owners", + status = "status", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class Pods extends React.Component { + renderContainersStatus(pod: Pod) { + return pod.getContainerStatuses().map(containerStatus => { + const { name, state, ready } = containerStatus; + const tooltip = ( + + {Object.keys(state).map(status => ( + +
+ {name} ({status}{ready ? ", ready" : ""}) +
+ {toPairs(state[status]).map(([name, value]) => ( +
+
{startCase(name)}
+
{value}
+
+ ))} +
+ ))} +
+ ); + return ( + + + + ) + }); + } + + render() { + return ( + pod.getName(), + [sortBy.namespace]: (pod: Pod) => pod.getNs(), + [sortBy.containers]: (pod: Pod) => pod.getContainers().length, + [sortBy.restarts]: (pod: Pod) => pod.getRestartsCount(), + [sortBy.owners]: (pod: Pod) => pod.getOwnerRefs().map(ref => ref.kind), + [sortBy.qos]: (pod: Pod) => pod.getQosClass(), + [sortBy.age]: (pod: Pod) => pod.getAge(false), + [sortBy.status]: (pod: Pod) => pod.getStatusMessage(), + }} + searchFilters={[ + (pod: Pod) => pod.getSearchFields(), + (pod: Pod) => pod.getStatusMessage(), + ]} + renderHeaderTitle={Pods} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { className: "warning" }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Containers, className: "containers", sortBy: sortBy.containers }, + { title: Restarts, className: "restarts", sortBy: sortBy.restarts }, + { title: Controlled By, className: "owners", sortBy: sortBy.owners }, + { title: QoS, className: "qos", sortBy: sortBy.qos }, + { title: Age, className: "age", sortBy: sortBy.age }, + { title: Status, className: "status", sortBy: sortBy.status }, + ]} + renderTableContents={(pod: Pod) => [ + pod.getName(), + pod.hasIssues() && , + pod.getNs(), + this.renderContainersStatus(pod), + pod.getRestartsCount(), + pod.getOwnerRefs().map(ref => { + const { kind, name } = ref; + const detailsLink = getDetailsUrl(lookupApiLink(ref, pod)); + return ( + + {kind} + + ) + }), + pod.getQosClass(), + pod.getAge(), + { title: pod.getStatusMessage(), className: kebabCase(pod.getStatusMessage()) } + ]} + renderItemMenu={(item: Pod) => { + return + }} + /> + ) + } +} + +apiManager.registerViews(podsApi, { + Menu: PodMenu, +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-replicasets/index.ts b/dashboard/client/components/+workloads-replicasets/index.ts new file mode 100644 index 0000000000..4053a1b9aa --- /dev/null +++ b/dashboard/client/components/+workloads-replicasets/index.ts @@ -0,0 +1,2 @@ +export * from "./replicasets" +export * from "./replicaset-details" diff --git a/dashboard/client/components/+workloads-replicasets/replicaset-details.scss b/dashboard/client/components/+workloads-replicasets/replicaset-details.scss new file mode 100644 index 0000000000..c580fe4e75 --- /dev/null +++ b/dashboard/client/components/+workloads-replicasets/replicaset-details.scss @@ -0,0 +1,2 @@ +.ReplicaSetDetails { +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-replicasets/replicaset-details.tsx b/dashboard/client/components/+workloads-replicasets/replicaset-details.tsx new file mode 100644 index 0000000000..5f40995bec --- /dev/null +++ b/dashboard/client/components/+workloads-replicasets/replicaset-details.tsx @@ -0,0 +1,102 @@ +import "./replicaset-details.scss"; +import React from "react"; +import { Trans } from "@lingui/macro"; +import { reaction } from "mobx"; +import { DrawerItem } from "../drawer"; +import { Badge } from "../badge"; +import { replicaSetStore } from "./replicasets.store"; +import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; +import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; +import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { ReplicaSet, replicaSetApi } from "../../api/endpoints"; +import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; +import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; +import { PodDetailsList } from "../+workloads-pods/pod-details-list"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class ReplicaSetDetails extends React.Component { + @disposeOnUnmount + clean = reaction(() => this.props.object, () => { + replicaSetStore.reset(); + }); + + async componentDidMount() { + if (!podsStore.isLoaded) { + podsStore.loadAll(); + } + } + + componentWillUnmount() { + replicaSetStore.reset(); + } + + render() { + const { object: replicaSet } = this.props + if (!replicaSet) return null + const { metrics } = replicaSetStore + const { status } = replicaSet + const { availableReplicas, replicas } = status + const selectors = replicaSet.getSelectors() + const nodeSelector = replicaSet.getNodeSelectors() + const images = replicaSet.getImages() + const childPods = replicaSetStore.getChildPods(replicaSet) + return ( +
+ {podsStore.isLoaded && ( + replicaSetStore.loadMetrics(replicaSet)} + tabs={podMetricTabs} object={replicaSet} params={{ metrics }} + > + + + )} + + {selectors.length > 0 && + Selector} labelsOnly> + { + selectors.map(label => ) + } + + } + {nodeSelector.length > 0 && + Node Selector} labelsOnly> + { + nodeSelector.map(label => ) + } + + } + {images.length > 0 && + Images}> + { + images.map(image =>

{image}

) + } +
+ } + Replicas}> + {`${availableReplicas || 0} current / ${replicas || 0} desired`} + + + + Pod Status} className="pod-status"> + + + + + +
+ ) + } +} + +apiManager.registerViews(replicaSetApi, { + Details: ReplicaSetDetails, +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-replicasets/replicasets.scss b/dashboard/client/components/+workloads-replicasets/replicasets.scss new file mode 100644 index 0000000000..04ea84dca3 --- /dev/null +++ b/dashboard/client/components/+workloads-replicasets/replicasets.scss @@ -0,0 +1,30 @@ +.ReplicaSets { + position: relative; + min-height: 80px; + + .Table { + margin: 0 (-$margin * 3); + } + + .TableCell { + &:first-child { + margin-left: $margin; + } + + &:last-child { + margin-right: $margin; + } + + &.name { + flex-grow: 2; + } + + &.namespace { + flex-grow: 1.2; + } + + &.actions { + @include table-cell-action; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-replicasets/replicasets.store.ts b/dashboard/client/components/+workloads-replicasets/replicasets.store.ts new file mode 100644 index 0000000000..cf658e36f7 --- /dev/null +++ b/dashboard/client/components/+workloads-replicasets/replicasets.store.ts @@ -0,0 +1,36 @@ +import { observable } from "mobx"; +import { autobind } from "../../utils"; +import { KubeObjectStore } from "../../kube-object.store"; +import { Deployment, IPodMetrics, podsApi, ReplicaSet, replicaSetApi } from "../../api/endpoints"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class ReplicaSetStore extends KubeObjectStore { + api = replicaSetApi + @observable metrics: IPodMetrics = null; + + loadMetrics(replicaSet: ReplicaSet) { + const pods = this.getChildPods(replicaSet); + return podsApi.getMetrics(pods, replicaSet.getNs(), "").then(metrics => + this.metrics = metrics + ); + } + + getChildPods(replicaSet: ReplicaSet) { + return podsStore.getPodsByOwner(replicaSet); + } + + getReplicaSetsByOwner(deployment: Deployment) { + return this.items.filter(replicaSet => + !!replicaSet.getOwnerRefs().find(owner => owner.uid === deployment.getId()) + ) + } + + reset() { + this.metrics = null; + } +} + +export const replicaSetStore = new ReplicaSetStore(); +apiManager.registerStore(replicaSetApi, replicaSetStore); diff --git a/dashboard/client/components/+workloads-replicasets/replicasets.tsx b/dashboard/client/components/+workloads-replicasets/replicasets.tsx new file mode 100644 index 0000000000..cea39cc666 --- /dev/null +++ b/dashboard/client/components/+workloads-replicasets/replicasets.tsx @@ -0,0 +1,98 @@ +import "./replicasets.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { ReplicaSet, replicaSetApi } from "../../api/endpoints"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +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 { showDetails } from "../../navigation"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + pods = "pods", + age = "age", +} + +interface Props { + replicaSets: ReplicaSet[]; +} + +@observer +export class ReplicaSets extends React.Component { + private sortingCallbacks = { + [sortBy.name]: (replicaSet: ReplicaSet) => replicaSet.getName(), + [sortBy.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(), + [sortBy.age]: (replicaSet: ReplicaSet) => replicaSet.getAge(false), + [sortBy.pods]: (replicaSet: ReplicaSet) => this.getPodsLength(replicaSet), + } + + getPodsLength(replicaSet: ReplicaSet) { + return replicaSetStore.getChildPods(replicaSet).length; + } + + render() { + const { replicaSets } = this.props; + if (!replicaSets.length && !replicaSetStore.isLoaded) return ( +
+ ); + if (!replicaSets.length) return null; + return ( +
+ Deploy Revisions}/> + + + Name + Namespace + Pods + Age + + + { + replicaSets.map(replica => { + return ( + showDetails(replica.selfLink, false))} + > + {replica.getName()} + {replica.getNs()} + {this.getPodsLength(replica)} + {replica.getAge()} + + + + + ) + }) + } +
+
+ ); + } +} + +export function ReplicaSetMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews(replicaSetApi, { + Menu: ReplicaSetMenu, +}); \ No newline at end of file diff --git a/dashboard/client/components/+workloads-statefulsets/index.ts b/dashboard/client/components/+workloads-statefulsets/index.ts new file mode 100644 index 0000000000..725851d22e --- /dev/null +++ b/dashboard/client/components/+workloads-statefulsets/index.ts @@ -0,0 +1,2 @@ +export * from "./statefulsets" +export * from "./statefulset-details" \ No newline at end of file diff --git a/dashboard/client/components/+workloads-statefulsets/statefulset-details.scss b/dashboard/client/components/+workloads-statefulsets/statefulset-details.scss new file mode 100644 index 0000000000..84e67c5a16 --- /dev/null +++ b/dashboard/client/components/+workloads-statefulsets/statefulset-details.scss @@ -0,0 +1,2 @@ +.StatefulSetDetails { +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-statefulsets/statefulset-details.tsx b/dashboard/client/components/+workloads-statefulsets/statefulset-details.tsx new file mode 100644 index 0000000000..83fd4f6210 --- /dev/null +++ b/dashboard/client/components/+workloads-statefulsets/statefulset-details.tsx @@ -0,0 +1,100 @@ +import "./statefulset-details.scss"; + +import React from "react"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { reaction } from "mobx"; +import { Trans } from "@lingui/macro"; +import { Badge } from "../badge"; +import { DrawerItem } from "../drawer"; +import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; +import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; +import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; +import { KubeEventDetails } from "../+events/kube-event-details"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { statefulSetStore } from "./statefulset.store"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { StatefulSet, statefulSetApi } from "../../api/endpoints"; +import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; +import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; +import { PodDetailsList } from "../+workloads-pods/pod-details-list"; +import { apiManager } from "../../api/api-manager"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; + +interface Props extends KubeObjectDetailsProps { +} + +@observer +export class StatefulSetDetails extends React.Component { + @disposeOnUnmount + clean = reaction(() => this.props.object, () => { + statefulSetStore.reset(); + }); + + componentDidMount() { + if (!podsStore.isLoaded) { + podsStore.loadAll(); + } + } + + componentWillUnmount() { + statefulSetStore.reset(); + } + + render() { + const { object: statefulSet } = this.props; + if (!statefulSet) return null + const images = statefulSet.getImages() + const selectors = statefulSet.getSelectors() + const nodeSelector = statefulSet.getNodeSelectors() + const childPods = statefulSetStore.getChildPods(statefulSet) + const metrics = statefulSetStore.metrics + return ( +
+ {podsStore.isLoaded && ( + statefulSetStore.loadMetrics(statefulSet)} + tabs={podMetricTabs} object={statefulSet} params={{ metrics }} + > + + + )} + + {selectors.length && + Selector} labelsOnly> + { + selectors.map(label => ) + } + + } + {nodeSelector.length > 0 && + Node Selector} labelsOnly> + { + nodeSelector.map(label => ( + + )) + } + + } + {images.length > 0 && + Images}> + { + images.map(image =>

{image}

) + } +
+ } + + + Pod Status} className="pod-status"> + + + + + +
+ ) + } +} + +apiManager.registerViews(statefulSetApi, { + Details: StatefulSetDetails +}) \ No newline at end of file diff --git a/dashboard/client/components/+workloads-statefulsets/statefulset.store.ts b/dashboard/client/components/+workloads-statefulsets/statefulset.store.ts new file mode 100644 index 0000000000..5119ce6e00 --- /dev/null +++ b/dashboard/client/components/+workloads-statefulsets/statefulset.store.ts @@ -0,0 +1,47 @@ +import { observable } from "mobx"; +import { autobind } from "../../utils"; +import { KubeObjectStore } from "../../kube-object.store"; +import { IPodMetrics, podsApi, PodStatus, StatefulSet, statefulSetApi } from "../../api/endpoints"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { apiManager } from "../../api/api-manager"; + +@autobind() +export class StatefulSetStore extends KubeObjectStore { + api = statefulSetApi + @observable metrics: IPodMetrics = null; + + loadMetrics(statefulSet: StatefulSet) { + const pods = this.getChildPods(statefulSet); + return podsApi.getMetrics(pods, statefulSet.getNs(), "").then(metrics => + this.metrics = metrics + ); + } + + getChildPods(statefulSet: StatefulSet) { + return podsStore.getPodsByOwner(statefulSet) + } + + getStatuses(statefulSets: StatefulSet[]) { + const status = { failed: 0, pending: 0, running: 0 } + statefulSets.forEach(statefulSet => { + const pods = this.getChildPods(statefulSet) + 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 + } + + reset() { + this.metrics = null; + } +} + +export const statefulSetStore = new StatefulSetStore(); +apiManager.registerStore(statefulSetApi, statefulSetStore); diff --git a/dashboard/client/components/+workloads-statefulsets/statefulsets.scss b/dashboard/client/components/+workloads-statefulsets/statefulsets.scss new file mode 100644 index 0000000000..ec39b5d53f --- /dev/null +++ b/dashboard/client/components/+workloads-statefulsets/statefulsets.scss @@ -0,0 +1,15 @@ +.StatefulSets { + .TableCell { + &.name { + flex-grow: 2; + } + + &.pods { + flex-grow: 0.3; + } + + &.warning { + @include table-cell-warning; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads-statefulsets/statefulsets.tsx b/dashboard/client/components/+workloads-statefulsets/statefulsets.tsx new file mode 100644 index 0000000000..c5ad6b5807 --- /dev/null +++ b/dashboard/client/components/+workloads-statefulsets/statefulsets.tsx @@ -0,0 +1,79 @@ +import "./statefulsets.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { RouteComponentProps } from "react-router"; +import { Trans } from "@lingui/macro"; +import { StatefulSet, statefulSetApi } from "../../api/endpoints"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { statefulSetStore } from "./statefulset.store"; +import { nodesStore } from "../+nodes/nodes.store"; +import { eventStore } from "../+events/event.store"; +import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; +import { KubeObjectListLayout } from "../kube-object"; +import { IStatefulSetsRouteParams } from "../+workloads"; +import { KubeEventIcon } from "../+events/kube-event-icon"; +import { apiManager } from "../../api/api-manager"; + +enum sortBy { + name = "name", + namespace = "namespace", + pods = "pods", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class StatefulSets extends React.Component { + getPodsLength(statefulSet: StatefulSet) { + return statefulSetStore.getChildPods(statefulSet).length; + } + + render() { + return ( + statefulSet.getName(), + [sortBy.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(), + [sortBy.age]: (statefulSet: StatefulSet) => statefulSet.getAge(false), + [sortBy.pods]: (statefulSet: StatefulSet) => this.getPodsLength(statefulSet), + }} + searchFilters={[ + (statefulSet: StatefulSet) => statefulSet.getSearchFields(), + ]} + renderHeaderTitle={Stateful Sets} + renderTableHeader={[ + { title: Name, className: "name", sortBy: sortBy.name }, + { title: Namespace, className: "namespace", sortBy: sortBy.namespace }, + { title: Pods, className: "pods", sortBy: sortBy.pods }, + { className: "warning" }, + { title: Age, className: "age", sortBy: sortBy.age }, + ]} + renderTableContents={(statefulSet: StatefulSet) => [ + statefulSet.getName(), + statefulSet.getNs(), + this.getPodsLength(statefulSet), + , + statefulSet.getAge(), + ]} + renderItemMenu={(item: StatefulSet) => { + return + }} + /> + ) + } +} + +export function StatefulSetMenu(props: KubeObjectMenuProps) { + return ( + + ) +} + +apiManager.registerViews(statefulSetApi, { + Menu: StatefulSetMenu, +}) diff --git a/dashboard/client/components/+workloads/index.ts b/dashboard/client/components/+workloads/index.ts new file mode 100644 index 0000000000..300747c6af --- /dev/null +++ b/dashboard/client/components/+workloads/index.ts @@ -0,0 +1,3 @@ +export * from "./workloads.route" +export * from "./workloads" + diff --git a/dashboard/client/components/+workloads/workloads-mixins.scss b/dashboard/client/components/+workloads/workloads-mixins.scss new file mode 100644 index 0000000000..d3b2a10b97 --- /dev/null +++ b/dashboard/client/components/+workloads/workloads-mixins.scss @@ -0,0 +1,81 @@ +// Pods +$pod-status-running-color: $colorOk; +$pod-status-pending-color: $colorWarning; +$pod-status-evicted-color: $colorError; +$pod-status-succeeded-color: $colorSuccess; +$pod-status-failed-color: $colorError; +$pod-status-terminated-color: $colorTerminated; +$pod-status-unknown-color: $colorVague; +$pod-status-complete-color: $colorSuccess; +$pod-status-crash-loop-color: $colorError; +$pod-scheduled: $colorOk; +$pod-ready: $colorOk; +$pod-initialized: $colorOk; +$pod-unschedulable: $colorError; +$pod-containers-ready: $colorInfo; +$pod-error: $colorError; +$pod-container-creating: $colorInfo; + +// Deployments +$deployment-available: $colorOk; +$deployment-progressing: $colorInfo; +$deployment-replicafailure: $colorError; + +// Jobs +$job-complete: $colorSuccess; +$job-failed: $colorError; + +// Pod Statuses +$pod-status-color-list: ( + running: $pod-status-running-color, + pending: $pod-status-pending-color, + evicted: $pod-status-evicted-color, + waiting: $pod-status-pending-color, + succeeded: $pod-status-succeeded-color, + failed: $pod-status-failed-color, + terminated: $pod-status-terminated-color, + completed: $pod-status-complete-color, + crash-loop-back-off: $pod-status-crash-loop-color, + error: $pod-error, + container-creating: $pod-container-creating, +); + +// Job Conditions +$job-condition-color-list: ( + complete: $job-complete, + failed: $job-failed, +); + +@mixin pod-status-bgs { + @each $status, $color in $pod-status-color-list { + &.#{$status} { + color: white; + background: $color; + } + } +} + +@mixin pod-status-colors { + @each $status, $color in $pod-status-color-list { + &.#{$status} { + color: $color; + } + } +} + +@mixin job-condition-bgs { + @each $condition, $color in $job-condition-color-list { + &.#{$condition} { + color: white; + background: $color; + } + } +} + +@mixin job-condition-colors { + @each $condition, $color in $job-condition-color-list { + &.#{$condition} { + color: $color; + } + } +} diff --git a/dashboard/client/components/+workloads/workloads.route.ts b/dashboard/client/components/+workloads/workloads.route.ts new file mode 100644 index 0000000000..94a7afd6ba --- /dev/null +++ b/dashboard/client/components/+workloads/workloads.route.ts @@ -0,0 +1,64 @@ +import { RouteProps } from "react-router" +import { Workloads } from "./workloads"; +import { buildURL, IURLParams } from "../../navigation"; + +export const workloadsRoute: RouteProps = { + get path() { + return Workloads.tabRoutes.map(({ path }) => path).flat() + } +} + +// Routes +export const overviewRoute: RouteProps = { + path: "/workloads" +} +export const podsRoute: RouteProps = { + path: "/pods" +} +export const deploymentsRoute: RouteProps = { + path: "/deployments" +} +export const daemonSetsRoute: RouteProps = { + path: "/daemonsets" +} +export const statefulSetsRoute: RouteProps = { + path: "/statefulsets" +} +export const jobsRoute: RouteProps = { + path: "/jobs" +} +export const cronJobsRoute: RouteProps = { + path: "/cronjobs" +} + +// Route params +export interface IWorkloadsOverviewRouteParams { +} + +export interface IPodsRouteParams { +} + +export interface IDeploymentsRouteParams { +} + +export interface IDaemonSetsRouteParams { +} + +export interface IStatefulSetsRouteParams { +} + +export interface IJobsRouteParams { +} + +export interface ICronJobsRouteParams { +} + +// URL-builders +export const workloadsURL = (params?: IURLParams) => overviewURL(params); +export const overviewURL = buildURL(overviewRoute.path) +export const podsURL = buildURL(podsRoute.path) +export const deploymentsURL = buildURL(deploymentsRoute.path) +export const daemonSetsURL = buildURL(daemonSetsRoute.path) +export const statefulSetsURL = buildURL(statefulSetsRoute.path) +export const jobsURL = buildURL(jobsRoute.path) +export const cronJobsURL = buildURL(cronJobsRoute.path) diff --git a/dashboard/client/components/+workloads/workloads.scss b/dashboard/client/components/+workloads/workloads.scss new file mode 100644 index 0000000000..205d93813e --- /dev/null +++ b/dashboard/client/components/+workloads/workloads.scss @@ -0,0 +1,2 @@ +.Workloads { +} \ No newline at end of file diff --git a/dashboard/client/components/+workloads/workloads.tsx b/dashboard/client/components/+workloads/workloads.tsx new file mode 100644 index 0000000000..855151e72c --- /dev/null +++ b/dashboard/client/components/+workloads/workloads.tsx @@ -0,0 +1,83 @@ +import "./workloads.scss" + +import * as React from "react"; +import { observer } from "mobx-react"; +import { Redirect, Route, Switch } from "react-router"; +import { RouteComponentProps } from "react-router-dom"; +import { Trans } from "@lingui/macro"; +import { MainLayout, TabRoute } from "../layout/main-layout"; +import { WorkloadsOverview } from "../+workloads-overview/overview"; +import { cronJobsRoute, cronJobsURL, daemonSetsRoute, daemonSetsURL, deploymentsRoute, deploymentsURL, jobsRoute, jobsURL, overviewRoute, overviewURL, podsRoute, podsURL, statefulSetsRoute, statefulSetsURL, workloadsURL } from "./workloads.route"; +import { namespaceStore } from "../+namespaces/namespace.store"; +import { Pods } from "../+workloads-pods"; +import { Deployments } from "../+workloads-deployments"; +import { DaemonSets } from "../+workloads-daemonsets"; +import { StatefulSets } from "../+workloads-statefulsets"; +import { Jobs } from "../+workloads-jobs"; +import { CronJobs } from "../+workloads-cronjobs"; + +interface Props extends RouteComponentProps { +} + +@observer +export class Workloads extends React.Component { + static get tabRoutes(): TabRoute[] { + const query = namespaceStore.getContextParams(); + return [ + { + title: Overview, + component: WorkloadsOverview, + url: overviewURL({ query }), + path: overviewRoute.path + }, + { + title: Pods, + component: Pods, + url: podsURL({ query }), + path: podsRoute.path + }, + { + title: Deployments, + component: Deployments, + url: deploymentsURL({ query }), + path: deploymentsRoute.path, + }, + { + title: DaemonSets, + component: DaemonSets, + url: daemonSetsURL({ query }), + path: daemonSetsRoute.path, + }, + { + title: StatefulSets, + component: StatefulSets, + url: statefulSetsURL({ query }), + path: statefulSetsRoute.path, + }, + { + title: Jobs, + component: Jobs, + url: jobsURL({ query }), + path: jobsRoute.path, + }, + { + title: CronJobs, + component: CronJobs, + url: cronJobsURL({ query }), + path: cronJobsRoute.path, + }, + ] + }; + + render() { + const tabRoutes = Workloads.tabRoutes; + return ( + + + {tabRoutes.map((route, index) => )} + + + + ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/ace-editor/ace-editor.scss b/dashboard/client/components/ace-editor/ace-editor.scss new file mode 100644 index 0000000000..3186c2611b --- /dev/null +++ b/dashboard/client/components/ace-editor/ace-editor.scss @@ -0,0 +1,61 @@ +.AceEditor { + position: relative; + width: 100%; + height: 100%; + flex: 1; + z-index: 10; + + &.hidden { + visibility: hidden; + } + + &.light { + .ace_scrollbar { + @include custom-scrollbar($theme: dark); + } + } + + > .editor { + position: absolute; + width: inherit; + height: inherit; + font-size: 90%; + border-radius: $radius; + } + + // --Theme customization + + .ace_gutter { + color: $textColorSecondary; + background-color: $mainBackground; + } + + .ace_active-line, + .ace_gutter-active-line { + background: $mainBackground !important; + } + + .ace_meta.ace_tag { + color: $textColorPrimary; + } + + .ace_constant { + color: $kontenaBlue !important; + } + + .ace_keyword { + color: $textColorAccent; + } + + .ace_string { + color: $colorOk; + } + + .ace_comment { + color: #808080; + } + + .ace_scrollbar { + @include custom-scrollbar; + } +} \ No newline at end of file diff --git a/dashboard/client/components/ace-editor/ace-editor.tsx b/dashboard/client/components/ace-editor/ace-editor.tsx new file mode 100644 index 0000000000..4b96467c11 --- /dev/null +++ b/dashboard/client/components/ace-editor/ace-editor.tsx @@ -0,0 +1,190 @@ +// Ace code editor - https://ace.c9.io +// Playground - https://ace.c9.io/build/kitchen-sink.html +import "./ace-editor.scss" + +import * as React from "react" +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { Ace } from "ace-builds" +import { autobind, cssNames } from "../../utils"; +import { Spinner } from "../spinner"; +import { themeStore } from "../../theme.store"; + +interface Props extends Partial { + className?: string; + autoFocus?: boolean; + hidden?: boolean; + cursorPos?: Ace.Point; + onChange?(value: string, delta: Ace.Delta): void; + onCursorPosChange?(point: Ace.Point): void; +} + +interface State { + ready?: boolean; +} + +const defaultProps: Partial = { + value: "", + mode: "yaml", + tabSize: 2, + showGutter: true, // line-numbers + foldStyle: "manual", + printMargin: false, + useWorker: false, +}; + +@observer +export class AceEditor extends React.Component { + static defaultProps = defaultProps as object; + + private editor: Ace.Editor; + private elem: HTMLElement; + + @observable ready = false; + + async loadEditor() { + return await import( + /* webpackChunkName: "ace" */ + "ace-builds" + ); + } + + loadTheme(theme: string) { + return import( + /* webpackChunkName: "ace/[request]" */ + `ace-builds/src-min-noconflict/theme-${theme}` + ); + } + + loadExtension(ext: string) { + return import( + /* webpackChunkName: "ace/[request]" */ + `ace-builds/src-min-noconflict/ext-${ext}` + ); + } + + loadMode(mode: string) { + return import( + /* webpackChunkName: "ace/[request]" */ + `ace-builds/src-min-noconflict/mode-${mode}` + ) + } + + get theme() { + return themeStore.activeTheme.type == "light" + ? "dreamweaver" : "terminal"; + } + + async componentDidMount() { + const { + mode, autoFocus, className, hidden, cursorPos, + onChange, onCursorPosChange, children, + ...options + } = this.props; + + // load ace-editor, theme and mode + const ace = await this.loadEditor(); + await Promise.all([ + this.loadTheme(this.theme), + this.loadMode(mode) + ]); + + // setup editor + this.editor = ace.edit(this.elem, options); + this.setTheme(this.theme); + this.setMode(mode); + this.setCursorPos(cursorPos); + + // bind events + this.editor.on("change", this.onChange); + this.editor.selection.on("changeCursor", this.onCursorPosChange); + + // load extensions + this.loadExtension("searchbox"); + + if (autoFocus) this.focus(); + this.ready = true; + } + + componentDidUpdate() { + if (!this.editor) return; + const { value, cursorPos } = this.props; + if (value !== this.getValue()) { + this.editor.setValue(value); + this.editor.clearSelection(); + this.setCursorPos(cursorPos || this.editor.getCursorPosition()); + } + } + + componentWillUnmount() { + if (this.editor) { + this.editor.destroy(); + } + } + + resize() { + if (this.editor) { + this.editor.resize(); + } + } + + focus() { + if (this.editor) { + this.editor.focus(); + } + } + + getValue() { + return this.editor.getValue() + } + + setValue(value: string, cursorPos?: number) { + return this.editor.setValue(value, cursorPos); + } + + async setMode(mode: string) { + await this.loadMode(mode); + this.editor.session.setMode(`ace/mode/${mode}`); + } + + async setTheme(theme: string) { + await this.loadTheme(theme); + this.editor.setTheme(`ace/theme/${theme}`); + } + + setCursorPos(pos: Ace.Point) { + if (!pos) return; + const { row, column } = pos; + this.editor.moveCursorToPosition(pos); + requestAnimationFrame(() => { + this.editor.gotoLine(row + 1, column, false); + }); + } + + @autobind() + onCursorPosChange() { + const { onCursorPosChange } = this.props; + if (onCursorPosChange) { + onCursorPosChange(this.editor.getCursorPosition()); + } + } + + @autobind() + onChange(delta: Ace.Delta) { + const { onChange } = this.props; + if (onChange) { + onChange(this.getValue(), delta); + } + } + + render() { + const { className, hidden } = this.props; + const themeType = themeStore.activeTheme.type; + return ( + + )} + tooltip={tooltip} + /> + ) + return ( +
+ {labels && labels.map((label: string, index) => { + const { backgroundColor } = datasets[0] as any + const color = legendColors ? legendColors[index] : backgroundColor[index] + return labelElem(label, color) + })} + {!labels && datasets.map(({ borderColor, label, tooltip }) => + labelElem(label, borderColor as any, tooltip) + )} +
+ ) + } + + renderChart() { + const { type, options, plugins } = this.props + this.memoizeDataProps() + this.chart = new ChartJS(this.canvas.current, { + type, + plugins, + options: { + ...options, + legend: { + display: false + }, + }, + data: this.currentChartData, + }) + } + + render() { + const { width, height, showChart, title, className } = this.props + return ( + <> +
+ {title &&
{title}
} + {showChart && +
+ +
+
+ } + {this.renderLegend()} +
+ + ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/chart/index.ts b/dashboard/client/components/chart/index.ts new file mode 100644 index 0000000000..920ff6c53e --- /dev/null +++ b/dashboard/client/components/chart/index.ts @@ -0,0 +1,3 @@ +export * from "./chart" +export * from "./pie-chart" +export * from "./bar-chart" \ No newline at end of file diff --git a/dashboard/client/components/chart/pie-chart.scss b/dashboard/client/components/chart/pie-chart.scss new file mode 100644 index 0000000000..16abb49933 --- /dev/null +++ b/dashboard/client/components/chart/pie-chart.scss @@ -0,0 +1,18 @@ +.PieChart { + .chart-container { + width: 120px; + } + + .legend { + margin-top: $margin; + flex-direction: column; + + > * { + &.Badge:hover { + background-color: transparent; + } + + margin-bottom: $margin; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/chart/pie-chart.tsx b/dashboard/client/components/chart/pie-chart.tsx new file mode 100644 index 0000000000..7187b1a34d --- /dev/null +++ b/dashboard/client/components/chart/pie-chart.tsx @@ -0,0 +1,63 @@ +import "./pie-chart.scss"; +import * as React from "react"; +import * as ChartJS from "chart.js"; +import { ChartData, ChartOptions } from "chart.js"; +import { Chart, ChartProps } from "./chart"; +import { cssNames } from "../../utils"; +import { themeStore } from "../../theme.store"; + +interface Props extends ChartProps { + data: ChartData; + title?: string; +} + +export class PieChart extends React.Component { + render() { + const { data, className, options, ...settings } = this.props + const { contentColor } = themeStore.activeTheme.colors; + const cutouts = [88, 76, 63] + const opts: ChartOptions = this.props.showChart === false ? {} : { + maintainAspectRatio: false, + tooltips: { + mode: "index", + callbacks: { + title: () => "", + label: (tooltipItem, data) => { + const dataset: any = data["datasets"][tooltipItem.datasetIndex] + const metaData = Object.values<{ total: number }>(dataset["_meta"])[0] + const percent = Math.round((dataset["data"][tooltipItem["index"]] / metaData.total) * 100) + if (isNaN(percent)) return "N/A"; + return percent + "%"; + }, + }, + filter: ({ datasetIndex, index }, { datasets }) => { + const { data } = datasets[datasetIndex]; + if (datasets.length === 1) return true; + return index !== data.length - 1; + }, + position: "cursor", + }, + elements: { + arc: { + borderWidth: 1, + borderColor: contentColor + }, + }, + cutoutPercentage: cutouts[data.datasets.length - 1] || 50, + responsive: true, + ...options + } + return ( + + ) + } +} + +ChartJS.Tooltip.positioners.cursor = function (elements: any, position: { x: number; y: number }) { + return position; +}; \ No newline at end of file diff --git a/dashboard/client/components/chart/useRealTimeMetrics.ts b/dashboard/client/components/chart/useRealTimeMetrics.ts new file mode 100644 index 0000000000..96a4118dfa --- /dev/null +++ b/dashboard/client/components/chart/useRealTimeMetrics.ts @@ -0,0 +1,43 @@ +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; +} \ No newline at end of file diff --git a/dashboard/client/components/chart/zebra-stripes.plugin.ts b/dashboard/client/components/chart/zebra-stripes.plugin.ts new file mode 100644 index 0000000000..b9355b004f --- /dev/null +++ b/dashboard/client/components/chart/zebra-stripes.plugin.ts @@ -0,0 +1,95 @@ +// Plugin for drawing stripe bars on top of any timeseries barchart +// Based on cover DIV element with repeating-linear-gradient style + +import { ChartPoint, default as ChartJS } from "chart.js"; +import moment, { Moment } from "moment"; +import get from "lodash/get"; + +const defaultOptions = { + interval: 61, + stripeMinutes: 10, + stripeColor: "#ffffff08", +} + +export const ZebraStripes = { + updated: null as Moment, // timestamp which all stripe movements based on + options: {}, + + getOptions(chart: ChartJS) { + return get(chart, "options.plugins.ZebraStripes"); + }, + + getLastUpdate(chart: ChartJS) { + const data = chart.data.datasets[0].data[0] as ChartPoint; + return moment.unix(parseInt(data.x as string)); + }, + + getStripesElem(chart: ChartJS) { + return chart.canvas.parentElement.querySelector(".zebra-cover"); + }, + + removeStripesElem(chart: ChartJS) { + const elem = this.getStripesElem(chart); + if (!elem) return; + chart.canvas.parentElement.removeChild(elem); + }, + + renderStripes(chart: ChartJS) { + if (!chart.data.datasets.length) return; + const { interval, stripeMinutes, stripeColor } = this.options; + const { top, left, bottom, right } = chart.chartArea; + const step = (right - left) / interval; + const stripeWidth = step * stripeMinutes; + const cover = document.createElement("div"); + const styles = cover.style; + + if (this.getStripesElem(chart)) return; + + cover.className = "zebra-cover"; + styles.width = right - left + "px"; + styles.left = left + "px"; + styles.top = top + "px"; + styles.height = bottom - top + "px"; + styles.backgroundImage = ` + repeating-linear-gradient(to right, ${stripeColor} 0px, ${stripeColor} ${stripeWidth}px, + transparent ${stripeWidth}px, transparent ${stripeWidth * 2 + step}px) + `; + chart.canvas.parentElement.appendChild(cover); + }, + + afterInit(chart: ChartJS) { + if (!chart.data.datasets.length) return; + this.options = { + ...defaultOptions, + ...this.getOptions(chart) + } + this.updated = this.getLastUpdate(chart); + }, + + afterUpdate(chart: ChartJS) { + this.renderStripes(chart); + }, + + resize(chart: ChartJS) { + this.removeStripesElem(chart); + }, + + afterDatasetUpdate(chart: ChartJS): void { + if (!this.updated) this.updated = this.getLastUpdate(chart); + + const { interval } = this.options; + const { left, right } = chart.chartArea; + const step = (right - left) / interval; + const diff = moment(this.updated).diff(this.getLastUpdate(chart), "minutes"); + const minutes = Math.abs(diff); + + this.removeStripesElem(chart); + this.renderStripes(chart); + + if (minutes > 0) { + // Move position regarding to difference in time + const cover = this.getStripesElem(chart); + cover.style.backgroundPositionX = -step * minutes + "px"; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/checkbox/checkbox.scss b/dashboard/client/components/checkbox/checkbox.scss new file mode 100644 index 0000000000..d0a69a0452 --- /dev/null +++ b/dashboard/client/components/checkbox/checkbox.scss @@ -0,0 +1,86 @@ + +.Checkbox { + --checkbox-color: #{$textColorPrimary}; // tick color [√] + --checkbox-color-active: #{$contentColor}; + --checkbox-bgc-active: #{$kontenaBlue}; + + flex-shrink: 0; + + &.theme { + &-dark { + // default + } + &-light { + --checkbox-color-active: #{$kontenaBlue}; + --checkbox-bgc-active: none; + } + } + + &:hover { + input:not(:checked):not(:disabled) { + ~ .tick:after { + opacity: 1; + } + } + } + + input[type="checkbox"] { + display: none; + &:checked { + ~ .box { + color: var(--checkbox-color-active); + background: var(--checkbox-bgc-active); + border-color: var(--checkbox-bgc-active); + &:after { + opacity: 1; + } + } + } + &:disabled { + ~ .box { + color: var(--checkbox-color); + } + ~ * { + opacity: .5; + pointer-events: none; + } + } + &:not(:disabled) ~ * { + cursor: pointer; + } + } + + .label { + margin-right: $margin; + } + + > .box { + $boxSize: round(1.7 * $unit); + + position: relative; + width: $boxSize; + height: $boxSize; + border-radius: 2px; + color: var(--checkbox-color); + border: 2px solid currentColor; + flex-shrink: 0; + + &:after { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 60%; + border: 2px solid currentColor; + border-top: 0; + border-right: 0; + transform: rotate(-45deg); + opacity: 0; + } + + + * { + margin-left: .5em; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/checkbox/checkbox.tsx b/dashboard/client/components/checkbox/checkbox.tsx new file mode 100644 index 0000000000..505f71b70b --- /dev/null +++ b/dashboard/client/components/checkbox/checkbox.tsx @@ -0,0 +1,51 @@ +import './checkbox.scss' +import React from 'react' +import { autobind, cssNames } from "../../utils"; + +interface Props { + theme?: "dark" | "light"; + className?: string; + label?: React.ReactNode; + inline?: boolean; + disabled?: boolean; + value?: T; + onChange?(value: T, evt: React.ChangeEvent): void; +} + +export class Checkbox extends React.PureComponent { + private input: HTMLInputElement; + + @autobind() + onChange(evt: React.ChangeEvent) { + if (this.props.onChange) { + this.props.onChange(this.input.checked, evt) + } + } + + getValue() { + if (this.props.value !== undefined) return this.props.value; + return this.input.checked; + } + + render() { + const { label, inline, className, value, theme, children, ...inputProps } = this.props; + const componentClass = cssNames('Checkbox flex', className, { + inline: inline, + checked: value, + disabled: this.props.disabled, + ["theme-" + theme]: theme, + }); + return ( + + ); + } +} \ No newline at end of file diff --git a/dashboard/client/components/checkbox/index.ts b/dashboard/client/components/checkbox/index.ts new file mode 100644 index 0000000000..527ce01c6c --- /dev/null +++ b/dashboard/client/components/checkbox/index.ts @@ -0,0 +1 @@ +export * from './checkbox' \ No newline at end of file diff --git a/dashboard/client/components/colors.scss b/dashboard/client/components/colors.scss new file mode 100644 index 0000000000..7464029a53 --- /dev/null +++ b/dashboard/client/components/colors.scss @@ -0,0 +1,300 @@ +//-- Material Design colors + +// Red +$red-50: rgb(255,235,238); +$red-100: rgb(255,205,210); +$red-200: rgb(239,154,154); +$red-300: rgb(229,115,115); +$red-400: rgb(239,83,80); +$red-500: rgb(244,67,54); +$red-600: rgb(229,57,53); +$red-700: rgb(211,47,47); +$red-800: rgb(198,40,40); +$red-900: rgb(183,28,28); +$red-a100: rgb(255,138,128); +$red-a200: rgb(255,82,82); +$red-a400: rgb(255,23,68); +$red-a700: rgb(213,0,0); + +// Pink +$pink-50: rgb(252,228,236); +$pink-100: rgb(248,187,208); +$pink-200: rgb(244,143,177); +$pink-300: rgb(240,98,146); +$pink-400: rgb(236,64,122); +$pink-500: rgb(233,30,99); +$pink-600: rgb(216,27,96); +$pink-700: rgb(194,24,91); +$pink-800: rgb(173,20,87); +$pink-900: rgb(136,14,79); +$pink-a100: rgb(255,128,171); +$pink-a200: rgb(255,64,129); +$pink-a400: rgb(245,0,87); +$pink-a700: rgb(197,17,98); + +// Purple +$purple-50: rgb(243,229,245); +$purple-100: rgb(225,190,231); +$purple-200: rgb(206,147,216); +$purple-300: rgb(186,104,200); +$purple-400: rgb(171,71,188); +$purple-500: rgb(156,39,176); +$purple-600: rgb(142,36,170); +$purple-700: rgb(123,31,162); +$purple-800: rgb(106,27,154); +$purple-900: rgb(74,20,140); +$purple-a100: rgb(234,128,252); +$purple-a200: rgb(224,64,251); +$purple-a400: rgb(213,0,249); +$purple-a700: rgb(170,0,255); + +//Deep Purple +$deep-purple-50: rgb(237,231,246); +$deep-purple-100: rgb(209,196,233); +$deep-purple-200: rgb(179,157,219); +$deep-purple-300: rgb(149,117,205); +$deep-purple-400: rgb(126,87,194); +$deep-purple-500: rgb(103,58,183); +$deep-purple-600: rgb(94,53,177); +$deep-purple-700: rgb(81,45,168); +$deep-purple-800: rgb(69,39,160); +$deep-purple-900: rgb(49,27,146); +$deep-purple-a100: rgb(179,136,255); +$deep-purple-a200: rgb(124,77,255); +$deep-purple-a400: rgb(101,31,255); +$deep-purple-a700: rgb(98,0,234); + +// Indigo +$indigo-50: rgb(232,234,246); +$indigo-100: rgb(197,202,233); +$indigo-200: rgb(159,168,218); +$indigo-300: rgb(121,134,203); +$indigo-400: rgb(92,107,192); +$indigo-500: rgb(63,81,181); +$indigo-600: rgb(57,73,171); +$indigo-700: rgb(48,63,159); +$indigo-800: rgb(40,53,147); +$indigo-900: rgb(26,35,126); +$indigo-a100: rgb(140,158,255); +$indigo-a200: rgb(83,109,254); +$indigo-a400: rgb(61,90,254); +$indigo-a700: rgb(48,79,254); + +// Blue +$blue-50: rgb(227,242,253); +$blue-100: rgb(187,222,251); +$blue-200: rgb(144,202,249); +$blue-300: rgb(100,181,246); +$blue-400: rgb(66,165,245); +$blue-500: rgb(33,150,243); +$blue-600: rgb(30,136,229); +$blue-700: rgb(25,118,210); +$blue-800: rgb(21,101,192); +$blue-900: rgb(13,71,161); +$blue-a100: rgb(130,177,255); +$blue-a200: rgb(68,138,255); +$blue-a400: rgb(41,121,255); +$blue-a700: rgb(41,98,255); + +// Light Blue +$light-blue-50: rgb(225,245,254); +$light-blue-100: rgb(179,229,252); +$light-blue-200: rgb(129,212,250); +$light-blue-300: rgb(79,195,247); +$light-blue-400: rgb(41,182,246); +$light-blue-500: rgb(3,169,244); +$light-blue-600: rgb(3,155,229); +$light-blue-700: rgb(2,136,209); +$light-blue-800: rgb(2,119,189); +$light-blue-900: rgb(1,87,155); +$light-blue-a100: rgb(128,216,255); +$light-blue-a200: rgb(64,196,255); +$light-blue-a400: rgb(0,176,255); +$light-blue-a700: rgb(0,145,234); + +// Cyan +$cyan-50: rgb(224,247,250); +$cyan-100: rgb(178,235,242); +$cyan-200: rgb(128,222,234); +$cyan-300: rgb(77,208,225); +$cyan-400: rgb(38,198,218); +$cyan-500: rgb(0,188,212); +$cyan-600: rgb(0,172,193); +$cyan-700: rgb(0,151,167); +$cyan-800: rgb(0,131,143); +$cyan-900: rgb(0,96,100); +$cyan-a100: rgb(132,255,255); +$cyan-a200: rgb(24,255,255); +$cyan-a400: rgb(0,229,255); +$cyan-a700: rgb(0,184,212); + +// Teal +$teal-50: rgb(224,242,241); +$teal-100: rgb(178,223,219); +$teal-200: rgb(128,203,196); +$teal-300: rgb(77,182,172); +$teal-400: rgb(38,166,154); +$teal-500: rgb(0,150,136); +$teal-600: rgb(0,137,123); +$teal-700: rgb(0,121,107); +$teal-800: rgb(0,105,92); +$teal-900: rgb(0,77,64); +$teal-a100: rgb(167,255,235); +$teal-a200: rgb(100,255,218); +$teal-a400: rgb(29,233,182); +$teal-a700: rgb(0,191,165); + +// Green +$green-50: rgb(232,245,233); +$green-100: rgb(200,230,201); +$green-200: rgb(165,214,167); +$green-300: rgb(129,199,132); +$green-400: rgb(102,187,106); +$green-500: rgb(76,175,80); +$green-600: rgb(67,160,71); +$green-700: rgb(56,142,60); +$green-800: rgb(46,125,50); +$green-900: rgb(27,94,32); +$green-a100: rgb(185,246,202); +$green-a200: rgb(105,240,174); +$green-a400: rgb(0,230,118); +$green-a700: rgb(0,200,83); + +// Green +$light-green-50: rgb(241,248,233); +$light-green-100: rgb(220,237,200); +$light-green-200: rgb(197,225,165); +$light-green-300: rgb(174,213,129); +$light-green-400: rgb(156,204,101); +$light-green-500: rgb(139,195,74); +$light-green-600: rgb(124,179,66); +$light-green-700: rgb(104,159,56); +$light-green-800: rgb(85,139,47); +$light-green-900: rgb(51,105,30); +$light-green-a100: rgb(204,255,144); +$light-green-a200: rgb(178,255,89); +$light-green-a400: rgb(118,255,3); +$light-green-a700: rgb(100,221,23); + +// Lime +$lime-50: rgb(249,251,231); +$lime-100: rgb(240,244,195); +$lime-200: rgb(230,238,156); +$lime-300: rgb(220,231,117); +$lime-400: rgb(212,225,87); +$lime-500: rgb(205,220,57); +$lime-600: rgb(192,202,51); +$lime-700: rgb(175,180,43); +$lime-800: rgb(158,157,36); +$lime-900: rgb(130,119,23); +$lime-a100: rgb(244,255,129); +$lime-a200: rgb(238,255,65); +$lime-a400: rgb(198,255,0); +$lime-a700: rgb(174,234,0); + +// Yellow +$yellow-50: rgb(255,253,231); +$yellow-100: rgb(255,249,196); +$yellow-200: rgb(255,245,157); +$yellow-300: rgb(255,241,118); +$yellow-400: rgb(255,238,88); +$yellow-500: rgb(255,235,59); +$yellow-600: rgb(253,216,53); +$yellow-700: rgb(251,192,45); +$yellow-800: rgb(249,168,37); +$yellow-900: rgb(245,127,23); +$yellow-a100: rgb(255,255,141); +$yellow-a200: rgb(255,255,0); +$yellow-a400: rgb(255,234,0); +$yellow-a700: rgb(255,214,0); + +// Amber +$amber-50: rgb(255,248,225); +$amber-100: rgb(255,236,179); +$amber-200: rgb(255,224,130); +$amber-300: rgb(255,213,79); +$amber-400: rgb(255,202,40); +$amber-500: rgb(255,193,7); +$amber-600: rgb(255,179,0); +$amber-700: rgb(255,160,0); +$amber-800: rgb(255,143,0); +$amber-900: rgb(255,111,0); +$amber-a100: rgb(255,229,127); +$amber-a200: rgb(255,215,64); +$amber-a400: rgb(255,196,0); +$amber-a700: rgb(255,171,0); + +// Orange +$orange-50: rgb(255,243,224); +$orange-100: rgb(255,224,178); +$orange-200: rgb(255,204,128); +$orange-300: rgb(255,183,77); +$orange-400: rgb(255,167,38); +$orange-500: rgb(255,152,0); +$orange-600: rgb(251,140,0); +$orange-700: rgb(245,124,0); +$orange-800: rgb(239,108,0); +$orange-900: rgb(230,81,0); +$orange-a100: rgb(255,209,128); +$orange-a200: rgb(255,171,64); +$orange-a400: rgb(255,145,0); +$orange-a700: rgb(255,109,0); + +// Deep Orange +$deep-orange-50: rgb(251,233,231); +$deep-orange-100: rgb(255,204,188); +$deep-orange-200: rgb(255,171,145); +$deep-orange-300: rgb(255,138,101); +$deep-orange-400: rgb(255,112,67); +$deep-orange-500: rgb(255,87,34); +$deep-orange-600: rgb(244,81,30); +$deep-orange-700: rgb(230,74,25); +$deep-orange-800: rgb(216,67,21); +$deep-orange-900: rgb(191,54,12); +$deep-orange-a100: rgb(255,158,128); +$deep-orange-a200: rgb(255,110,64); +$deep-orange-a400: rgb(255,61,0); +$deep-orange-a700: rgb(221,44,0); + +// Brown +$brown-50: rgb(239,235,233); +$brown-100: rgb(215,204,200); +$brown-200: rgb(188,170,164); +$brown-300: rgb(161,136,127); +$brown-400: rgb(141,110,99); +$brown-500: rgb(121,85,72); +$brown-600: rgb(109,76,65); +$brown-700: rgb(93,64,55); +$brown-800: rgb(78,52,46); +$brown-900: rgb(62,39,35); + +// Grey +$grey-50: rgb(250,250,250); +$grey-100: rgb(245,245,245); +$grey-200: rgb(238,238,238); +$grey-300: rgb(224,224,224); +$grey-400: rgb(189,189,189); +$grey-500: rgb(158,158,158); +$grey-600: rgb(117,117,117); +$grey-700: rgb(97,97,97); +$grey-800: rgb(66,66,66); +$grey-900: rgb(33,33,33); + +// Blue Grey +$blue-grey-50: rgb(236,239,241); +$blue-grey-100: rgb(207,216,220); +$blue-grey-200: rgb(176,190,197); +$blue-grey-300: rgb(144,164,174); +$blue-grey-400: rgb(120,144,156); +$blue-grey-500: rgb(96,125,139); +$blue-grey-600: rgb(84,110,122); +$blue-grey-700: rgb(69,90,100); +$blue-grey-800: rgb(55,71,79); +$blue-grey-900: rgb(38,50,56); + +$color-black: rgb(0,0,0); +$color-white: rgb(255,255,255); + +//-- The two possible colors for overlayed text. +$color-dark-contrast: $color-white !default; +$color-light-contrast: $color-black !default; diff --git a/dashboard/client/components/confirm-dialog/confirm-dialog.scss b/dashboard/client/components/confirm-dialog/confirm-dialog.scss new file mode 100644 index 0000000000..74ade9fbba --- /dev/null +++ b/dashboard/client/components/confirm-dialog/confirm-dialog.scss @@ -0,0 +1,50 @@ + +.ConfirmDialog { + $spacing: $padding * 3; + + &.warning { + .Button.ok { + background: $colorSoftError; + } + } + + > .box { + max-width: 50vw; + min-width: 45 * $unit; + background-color: white; + outline: $unit solid rgba(255, 255, 255, .15); + } + + .confirm-content { + padding: $spacing; + display: flex; + align-items: center; + word-break: break-word; + + > .Icon { + margin-left: inherit; + margin-right: $margin; + color: $colorSoftError; + } + + > :not(.Icon) { + flex: 1; + } + + .warning { + font-size: small; + margin-top: $margin; + } + } + + .confirm-buttons { + background: #f4f4f4; + padding: $spacing; + display: flex; + justify-content: flex-end; + + > * { + margin-left: $margin + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/confirm-dialog/confirm-dialog.tsx b/dashboard/client/components/confirm-dialog/confirm-dialog.tsx new file mode 100644 index 0000000000..a48e9dd5d0 --- /dev/null +++ b/dashboard/client/components/confirm-dialog/confirm-dialog.tsx @@ -0,0 +1,101 @@ +import "./confirm-dialog.scss"; + +import React, { ReactNode } from "react"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { cssNames, noop, prevDefault } from "../../utils"; +import { Button } from "../button"; +import { Dialog, DialogProps } from "../dialog"; +import { Icon } from "../icon"; + +export interface IConfirmDialogParams { + ok?: () => void; + labelOk?: ReactNode; + labelCancel?: ReactNode; + message?: ReactNode; + icon?: string; +} + +interface Props extends Partial { +} + +@observer +export class ConfirmDialog extends React.Component { + @observable static isOpen = false; + @observable.ref static params: IConfirmDialogParams; + + @observable isSaving = false; + + static open(params: IConfirmDialogParams) { + ConfirmDialog.isOpen = true; + ConfirmDialog.params = params; + } + + static close() { + ConfirmDialog.isOpen = false; + } + + public defaultParams: IConfirmDialogParams = { + ok: noop, + labelOk: Ok, + labelCancel: Cancel, + icon: "warning", + }; + + get params(): IConfirmDialogParams { + return Object.assign({}, this.defaultParams, ConfirmDialog.params); + } + + ok = async () => { + try { + this.isSaving = true; + await Promise.resolve(this.params.ok()).catch(noop); + } finally { + this.isSaving = false; + } + this.close(); + } + + onClose = () => { + this.isSaving = false; + } + + close = () => { + ConfirmDialog.close(); + } + + render() { + const { className, ...dialogProps } = this.props; + const { icon, labelOk, labelCancel, message } = this.params; + return ( + +
+ + {message} +
+
+
+
+ ) + } +} diff --git a/dashboard/client/components/confirm-dialog/index.ts b/dashboard/client/components/confirm-dialog/index.ts new file mode 100644 index 0000000000..de21b827c1 --- /dev/null +++ b/dashboard/client/components/confirm-dialog/index.ts @@ -0,0 +1 @@ +export * from './confirm-dialog' \ No newline at end of file diff --git a/dashboard/client/components/dialog/dialog.scss b/dashboard/client/components/dialog/dialog.scss new file mode 100644 index 0000000000..9d480962aa --- /dev/null +++ b/dashboard/client/components/dialog/dialog.scss @@ -0,0 +1,18 @@ + +.Dialog { + @include custom-scrollbar; + + position: fixed; + overflow: auto; + left: 0; + top: 0; + width: 100%; + height: 100%; + padding: $unit * 5; + z-index: $zIndex-dialog; + overscroll-behavior: none; // prevent swiping with touch-pad on MacOSX + + &.modal { + background: transparentize(#222, .5); + } +} \ No newline at end of file diff --git a/dashboard/client/components/dialog/dialog.tsx b/dashboard/client/components/dialog/dialog.tsx new file mode 100644 index 0000000000..10cca096a0 --- /dev/null +++ b/dashboard/client/components/dialog/dialog.tsx @@ -0,0 +1,145 @@ +import "./dialog.scss"; + +import * as React from "react"; +import { createPortal, findDOMNode } from "react-dom"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { reaction } from "mobx"; +import { Animate } from "../animate"; +import { cssNames, noop, stopPropagation } from "../../utils"; +import { navigation } from "../../navigation"; + +export interface DialogProps { + className?: string; + isOpen?: boolean; + open?: () => void; + close?: () => void; + onOpen?: () => void; + onClose?: () => void; + modal?: boolean; + pinned?: boolean; + animated?: boolean; +} + +interface DialogState { + isOpen: boolean; +} + +// fixme: handle animation end props.onClose() (await props.close()?) +@observer +export class Dialog extends React.PureComponent { + private contentElem: HTMLElement; + + static defaultProps: DialogProps = { + isOpen: false, + open: noop, + close: noop, + onOpen: noop, + onClose: noop, + modal: true, + animated: true, + pinned: false, + }; + + @disposeOnUnmount + closeOnNavigate = reaction(() => navigation.getPath(), () => this.close()) + + public state: DialogState = { + isOpen: this.props.isOpen, + } + + get elem() { + return findDOMNode(this) as HTMLElement; + } + + get isOpen() { + return this.state.isOpen; + } + + componentDidMount() { + if (this.isOpen) this.onOpen(); + } + + componentDidUpdate(prevProps: DialogProps) { + const { isOpen } = this.props; + if (isOpen !== prevProps.isOpen) { + this.toggle(isOpen); + } + } + + componentWillUnmount() { + if (this.isOpen) this.onClose(); + } + + toggle(isOpen: boolean) { + if (isOpen) this.open(); + else this.close(); + } + + open() { + requestAnimationFrame(this.onOpen); // wait for render(), bind close-event to this.elem + this.setState({ isOpen: true }); + this.props.open(); + } + + close() { + this.onClose(); // must be first to get access to dialog's content from outside + this.setState({ isOpen: false }); + this.props.close(); + } + + onOpen = () => { + this.props.onOpen(); + if (!this.props.pinned) { + if (this.elem) this.elem.addEventListener('click', this.onClickOutside); + window.addEventListener('keydown', this.onEscapeKey); + } + } + + onClose = () => { + this.props.onClose(); + if (!this.props.pinned) { + if (this.elem) this.elem.removeEventListener('click', this.onClickOutside); + window.removeEventListener('keydown', this.onEscapeKey); + } + } + + onEscapeKey = (evt: KeyboardEvent) => { + const escapeKey = evt.code === "Escape"; + if (escapeKey) { + this.close(); + evt.stopPropagation(); + } + } + + onClickOutside = (evt: MouseEvent) => { + const target = evt.target as HTMLElement; + if (!this.contentElem.contains(target)) { + this.close(); + evt.stopPropagation(); + } + } + + render() { + const { modal, animated, pinned } = this.props; + let { className } = this.props; + className = cssNames("Dialog flex center", className, { modal, pinned }); + let dialog = ( +
+
this.contentElem = e}> + {this.props.children} +
+
+ ); + if (animated) { + dialog = ( + + {dialog} + + ); + } + else if (!this.isOpen) { + return null; + } + return createPortal(dialog, document.body); + } +} diff --git a/dashboard/client/components/dialog/index.ts b/dashboard/client/components/dialog/index.ts new file mode 100644 index 0000000000..20033cb0d2 --- /dev/null +++ b/dashboard/client/components/dialog/index.ts @@ -0,0 +1 @@ +export * from './dialog' diff --git a/dashboard/client/components/dialog/logs-dialog.scss b/dashboard/client/components/dialog/logs-dialog.scss new file mode 100644 index 0000000000..1234417fee --- /dev/null +++ b/dashboard/client/components/dialog/logs-dialog.scss @@ -0,0 +1,15 @@ +.LogsDialog { + .Wizard { + --wizard-width: 70vw; + } + + .WizardStep { + .step-content { + padding: var(--wizard-spacing); + } + + code { + max-height: 50vh; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/dialog/logs-dialog.tsx b/dashboard/client/components/dialog/logs-dialog.tsx new file mode 100644 index 0000000000..7e50de9c79 --- /dev/null +++ b/dashboard/client/components/dialog/logs-dialog.tsx @@ -0,0 +1,52 @@ +import "./logs-dialog.scss"; + +import * as React from "react"; +import { t, Trans } from "@lingui/macro"; +import { Dialog, DialogProps } from "../dialog"; +import { Wizard, WizardStep } from "../wizard"; +import { copyToClipboard } from "../../utils"; +import { Notifications } from "../notifications"; +import { Button } from "../button"; +import { Icon } from "../icon"; +import { _i18n } from "../../i18n"; + +interface Props extends DialogProps { + title: string; + logs: string; +} + +export class LogsDialog extends React.Component { + public logsElem: HTMLElement; + + copyToClipboard = () => { + if (copyToClipboard(this.logsElem)) { + Notifications.ok(_i18n._(t`Logs copied to clipboard.`)) + } + } + + render() { + const { title, logs, ...dialogProps } = this.props; + const header =
{title}
+ const customButtons = ( +
+ + +
+ ) + return ( + + + + this.logsElem = e}> + {logs || There are no logs available.} + + + + + ) + } +} diff --git a/dashboard/client/components/dock/create-resource.scss b/dashboard/client/components/dock/create-resource.scss new file mode 100644 index 0000000000..027b37763d --- /dev/null +++ b/dashboard/client/components/dock/create-resource.scss @@ -0,0 +1,2 @@ +.CreateResource { +} \ No newline at end of file diff --git a/dashboard/client/components/dock/create-resource.store.ts b/dashboard/client/components/dock/create-resource.store.ts new file mode 100644 index 0000000000..6b614db00b --- /dev/null +++ b/dashboard/client/components/dock/create-resource.store.ts @@ -0,0 +1,26 @@ +import { autobind } from "../../utils"; +import { DockTabStore } from "./dock-tab.store"; +import { dockStore, IDockTab, TabKind } from "./dock.store"; + +@autobind() +export class CreateResourceStore extends DockTabStore { + constructor() { + super({ + storageName: "create_resource" + }); + } +} + +export const createResourceStore = new CreateResourceStore(); + +export function createResourceTab(tabParams: Partial = {}) { + return dockStore.createTab({ + kind: TabKind.CREATE_RESOURCE, + title: "Create resource", + ...tabParams + }); +} + +export function isCreateResourceTab(tab: IDockTab) { + return tab && tab.kind === TabKind.CREATE_RESOURCE; +} diff --git a/dashboard/client/components/dock/create-resource.tsx b/dashboard/client/components/dock/create-resource.tsx new file mode 100644 index 0000000000..6d89455b3b --- /dev/null +++ b/dashboard/client/components/dock/create-resource.tsx @@ -0,0 +1,85 @@ +import "./create-resource.scss"; + +import React from "react"; +import jsYaml from "js-yaml" +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { Plural, t, Trans } from "@lingui/macro"; +import { cssNames } from "../../utils"; +import { createResourceStore } from "./create-resource.store"; +import { IDockTab } from "./dock.store"; +import { EditorPanel } from "./editor-panel"; +import { InfoPanel } from "./info-panel"; +import { resourceApplierApi } from "../../api/endpoints/resource-applier.api"; +import { _i18n } from "../../i18n"; +import { JsonApiErrorParsed } from "../../api/json-api"; +import { Notifications } from "../notifications"; + +interface Props { + className?: string; + tab: IDockTab; +} + +@observer +export class CreateResource extends React.Component { + @observable error = "" + + get tabId() { + return this.props.tab.id; + } + + get data() { + return createResourceStore.getData(this.tabId); + } + + onChange = (value: string, error?: string) => { + createResourceStore.setData(this.tabId, value); + this.error = error; + } + + create = async () => { + if (this.error) return; + const resources = jsYaml.safeLoadAll(this.data) + .filter(v => !!v) // skip empty documents if "---" pasted at the beginning or end + const createdResources: string[] = []; + const errors: string[] = []; + await Promise.all( + resources.map(data => { + return resourceApplierApi.update(data) + .then(item => createdResources.push(item.getName())) + .catch((err: JsonApiErrorParsed) => errors.push(err.toString())) + }) + ); + if (errors.length) { + errors.forEach(Notifications.error); + if (!createdResources.length) throw errors[0]; + } + return ( +

+ {" "} + {createdResources.join(", ")} successfully created +

+ ) + } + + render() { + const { tabId, data, error, create, onChange } = this; + const { className } = this.props; + return ( +
+ + +
+ ) + } +} diff --git a/dashboard/client/components/dock/dock-tab.scss b/dashboard/client/components/dock/dock-tab.scss new file mode 100644 index 0000000000..26ae8408e3 --- /dev/null +++ b/dashboard/client/components/dock/dock-tab.scss @@ -0,0 +1,34 @@ +.DockTab { + padding: $padding; + padding-right: 0; + + .Icon { + &.material { + --size: var(--small-size); + } + + &.svg { + --size: 15px; + } + } + + .label { + .title { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + } + } + + &.pinned { + padding-right: $padding; + } + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: $padding; + } +} \ No newline at end of file diff --git a/dashboard/client/components/dock/dock-tab.store.ts b/dashboard/client/components/dock/dock-tab.store.ts new file mode 100644 index 0000000000..d644b628b7 --- /dev/null +++ b/dashboard/client/components/dock/dock-tab.store.ts @@ -0,0 +1,58 @@ +import { autorun, observable, reaction } from "mobx"; +import { autobind, createStorage } from "../../utils"; +import { dockStore, TabId } from "./dock.store"; + +interface Options { + storageName?: string; // name to sync data with localStorage + storageSerializer?: (data: T) => Partial; // allow to customize data before saving to localStorage +} + +@autobind() +export class DockTabStore { + protected data = observable.map([]); + + constructor(protected options: Options = {}) { + const { storageName } = options; + + // auto-save to local-storage + if (storageName) { + const storage = createStorage<[TabId, T][]>(storageName, []); + this.data.replace(storage.get()); + reaction(() => this.serializeData(), (data: T | any) => storage.set(data)); + } + + // clear data for closed tabs + autorun(() => { + const currentTabs = dockStore.tabs.map(tab => tab.id); + Array.from(this.data.keys()).forEach(tabId => { + if (!currentTabs.includes(tabId)) { + this.clearData(tabId); + } + }) + }); + } + + protected serializeData() { + const { storageSerializer } = this.options; + return Array.from(this.data).map(([tabId, tabData]) => { + if (storageSerializer) return [tabId, storageSerializer(tabData)] + return [tabId, tabData]; + }) + } + + getData(tabId: TabId) { + return this.data.get(tabId); + } + + setData(tabId: TabId, data: T) { + this.data.set(tabId, data); + } + + clearData(tabId: TabId) { + this.data.delete(tabId); + } + + reset() { + this.data.clear(); + } +} diff --git a/dashboard/client/components/dock/dock-tab.tsx b/dashboard/client/components/dock/dock-tab.tsx new file mode 100644 index 0000000000..41c595432c --- /dev/null +++ b/dashboard/client/components/dock/dock-tab.tsx @@ -0,0 +1,51 @@ +import "./dock-tab.scss" + +import React from "react" +import { observer } from "mobx-react"; +import { t } from "@lingui/macro"; +import { autobind, cssNames, prevDefault } from "../../utils"; +import { dockStore, IDockTab } from "./dock.store"; +import { Tab, TabProps } from "../tabs"; +import { Icon } from "../icon"; +import { _i18n } from "../../i18n"; + +export interface DockTabProps extends TabProps { + moreActions?: React.ReactNode; +} + +@observer +export class DockTab extends React.Component { + get tabId() { + return this.props.value.id; + } + + @autobind() + close() { + dockStore.closeTab(this.tabId); + } + + render() { + const { className, moreActions, ...tabProps } = this.props; + const { title, pinned } = tabProps.value; + const label = ( +
+ {title} + {moreActions} + {!pinned && ( + + )} +
+ ) + return ( + + ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/dock/dock.scss b/dashboard/client/components/dock/dock.scss new file mode 100644 index 0000000000..2a6fa326de --- /dev/null +++ b/dashboard/client/components/dock/dock.scss @@ -0,0 +1,80 @@ +.Dock { + $borderColor: $borderColor; + + position: relative; + background: $dockHeadBackground; + display: flex; + flex-direction: column; + + &.isOpen { + &.fullSize { + position: fixed; + top: 0; + right: 0; + left: 0; + bottom: 0; + z-index: 100; + + > .resizer { + display: none; + } + } + } + + &:not(.isOpen) { + height: auto !important; + + .Tab:not(:focus):after { + display: none; + } + } + + .tabs-container { + padding: 0 $padding * 2; + border-top: 1px solid $borderColor; + flex-shrink: 0; + + .Tabs:empty + .toolbar { + padding-left: 0; + } + + .toolbar { + min-height: $unit * 4; + padding-left: $padding; + user-select: none; + } + } + + .tab-content { + position: relative; + background: $terminalBackground; + flex: 1; + overflow: hidden; + transition: flex 60ms ease-in-out; + + > *:not(.Spinner) { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + } + } + + .resizer { + $height: 12px; + + position: absolute; + top: -$height / 2; + left: 0; + right: 0; + bottom: 100%; + height: $height; + cursor: row-resize; + z-index: 10; + + &.disabled { + pointer-events: none; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/dock/dock.store.ts b/dashboard/client/components/dock/dock.store.ts new file mode 100644 index 0000000000..c1b70cc925 --- /dev/null +++ b/dashboard/client/components/dock/dock.store.ts @@ -0,0 +1,192 @@ +import MD5 from "crypto-js/md5"; +import { action, computed, IReactionOptions, observable, reaction } from "mobx"; +import { autobind, createStorage } from "../../utils"; +import throttle from "lodash/throttle" + +export type TabId = string; + +export enum TabKind { + TERMINAL = "terminal", + CREATE_RESOURCE = "create-resource", + EDIT_RESOURCE = "edit-resource", + INSTALL_CHART = "install-chart", + UPGRADE_CHART = "upgrade-chart", +} + +export interface IDockTab { + id?: TabId; + kind: TabKind; + title?: string; + pinned?: boolean; // not closable +} + +@autobind() +export class DockStore { + protected initialTabs: IDockTab[] = [ + { id: "terminal", kind: TabKind.TERMINAL, title: "Terminal" }, + ]; + + protected storage = createStorage("dock", {}); // keep settings in localStorage + public defaultTabId = this.initialTabs[0].id; + public minHeight = 100; + + @observable isOpen = false; + @observable fullSize = false; + @observable height = this.defaultHeight; + @observable tabs = observable.array(this.initialTabs); + @observable selectedTabId = this.defaultTabId; + + @computed get selectedTab() { + return this.tabs.find(tab => tab.id === this.selectedTabId); + } + + get defaultHeight() { + return Math.round(window.innerHeight / 2.5); + } + + get maxHeight() { + const mainLayoutHeader = 40; + const mainLayoutTabs = 33; + const mainLayoutMargin = 16; + const dockTabs = 33; + return window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs; + } + + constructor() { + Object.assign(this, this.storage.get()); + + reaction(() => ({ + isOpen: this.isOpen, + selectedTabId: this.selectedTabId, + height: this.height, + tabs: this.tabs.slice(), + }), data => { + this.storage.set(data); + }); + + // adjust terminal height if window size changes + this.checkMaxHeight(); + window.addEventListener("resize", throttle(this.checkMaxHeight, 250)); + } + + protected checkMaxHeight() { + if (!this.height) { + this.setHeight(this.defaultHeight || this.minHeight); + } + if (this.height > this.maxHeight) { + this.setHeight(this.maxHeight); + } + } + + onResize(callback: () => void, options?: IReactionOptions) { + return reaction(() => [this.height, this.fullSize], callback, options); + } + + onTabChange(callback: (tabId: TabId) => void, options?: IReactionOptions) { + return reaction(() => this.selectedTabId, callback, options); + } + + hasTabs() { + return this.tabs.length > 0; + } + + @action + open(fullSize?: boolean) { + this.isOpen = true; + if (typeof fullSize === "boolean") { + this.fullSize = fullSize; + } + } + + @action + close() { + this.isOpen = false; + } + + @action + toggle() { + if (this.isOpen) this.close(); + else this.open(); + } + + @action + toggleFillSize() { + if (!this.isOpen) this.open(); + this.fullSize = !this.fullSize; + } + + getTabById(tabId: TabId) { + return this.tabs.find(tab => tab.id === tabId); + } + + protected getNewTabNumber(kind: TabKind) { + const tabNumbers = this.tabs + .filter(tab => tab.kind === kind) + .map(tab => { + const tabNumber = +tab.title.match(/\d+/); + return tabNumber === 0 ? 1 : tabNumber; // tab without a number is first + }); + for (let i = 1; ; i++) { + if (!tabNumbers.includes(i)) return i; + } + } + + @action + createTab(anonTab: IDockTab, addNumber = true): IDockTab { + const tabId = MD5(Math.random().toString() + Date.now()).toString(); + const tab: IDockTab = { id: tabId, ...anonTab }; + if (addNumber) { + const tabNumber = this.getNewTabNumber(tab.kind); + if (tabNumber > 1) tab.title += ` (${tabNumber})` + } + this.tabs.push(tab); + this.selectTab(tab.id); + this.open(); + return tab; + } + + @action + async closeTab(tabId: TabId) { + const tab = this.getTabById(tabId); + if (!tab || tab.pinned) { + return; + } + this.tabs.remove(tab); + if (this.selectedTabId === tab.id) { + if (this.tabs.length) { + const newTab = this.tabs.slice(-1)[0]; // last + if (newTab.kind === TabKind.TERMINAL) { + // close the dock when selected sibling inactive terminal tab + const { terminalStore } = await import("./terminal.store"); + if (!terminalStore.isConnected(newTab.id)) this.close(); + } + this.selectTab(newTab.id); + } + else { + this.selectedTabId = null; + this.close(); + } + } + } + + @action + selectTab(tabId: TabId) { + const tab = this.getTabById(tabId); + this.selectedTabId = tab ? tab.id : null; + } + + @action + setHeight(height: number) { + this.height = Math.max(0, Math.min(height, this.maxHeight)); + } + + @action + reset() { + this.selectedTabId = this.defaultTabId; + this.tabs.replace(this.initialTabs); + this.height = this.defaultHeight; + this.close(); + } +} + +export const dockStore = new DockStore(); diff --git a/dashboard/client/components/dock/dock.tsx b/dashboard/client/components/dock/dock.tsx new file mode 100644 index 0000000000..d6549d12e6 --- /dev/null +++ b/dashboard/client/components/dock/dock.tsx @@ -0,0 +1,153 @@ +import "./dock.scss"; + +import React, { Fragment } from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { autobind, cssNames, prevDefault } from "../../utils"; +import { Draggable, DraggableState } from "../draggable"; +import { Icon } from "../icon"; +import { Tabs } from "../tabs/tabs"; +import { MenuItem } from "../menu"; +import { MenuActions } from "../menu/menu-actions"; +import { dockStore, IDockTab } from "./dock.store"; +import { DockTab } from "./dock-tab"; +import { TerminalTab } from "./terminal-tab"; +import { TerminalWindow } from "./terminal-window"; +import { CreateResource } from "./create-resource"; +import { InstallChart } from "./install-chart"; +import { EditResource } from "./edit-resource"; +import { UpgradeChart } from "./upgrade-chart"; +import { createTerminalTab, isTerminalTab } from "./terminal.store"; +import { createResourceTab, isCreateResourceTab } from "./create-resource.store"; +import { isEditResourceTab } from "./edit-resource.store"; +import { isInstallChartTab } from "./install-chart.store"; +import { isUpgradeChartTab } from "./upgrade-chart.store"; + +interface Props { + className?: string; +} + +@observer +export class Dock extends React.Component { + onResizeStart = () => { + const { isOpen, open, setHeight, minHeight } = dockStore; + if (!isOpen) { + open(); + setHeight(minHeight); + } + } + + onResize = ({ offsetY }: DraggableState) => { + const { isOpen, close, height, setHeight, minHeight, defaultHeight } = dockStore; + const newHeight = height + offsetY; + if (height > newHeight && newHeight < minHeight) { + setHeight(defaultHeight); + close(); + } + else if (isOpen) { + setHeight(newHeight); + } + } + + onKeydown = (evt: React.KeyboardEvent) => { + const { close, closeTab, selectedTab, fullSize, toggleFillSize } = dockStore; + if (!selectedTab) return; + const { code, ctrlKey, shiftKey } = evt.nativeEvent; + if (shiftKey && code === "Escape") { + close(); + } + if (ctrlKey && code === "KeyW") { + if (selectedTab.pinned) close(); + else closeTab(selectedTab.id); + } + } + + onChangeTab = (tab: IDockTab) => { + const { open, selectTab } = dockStore; + open(); + selectTab(tab.id); + } + + @autobind() + renderTab(tab: IDockTab) { + if (isTerminalTab(tab)) { + return + } + if (isCreateResourceTab(tab) || isEditResourceTab(tab)) { + return + } + if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) { + return }/> + } + } + + renderTabContent() { + const { isOpen, height, selectedTab: tab } = dockStore; + if (!isOpen || !tab) return; + return ( +
+ {isCreateResourceTab(tab) && } + {isEditResourceTab(tab) && } + {isInstallChartTab(tab) && } + {isUpgradeChartTab(tab) && } + {isTerminalTab(tab) && } +
+ ) + } + + render() { + const { className } = this.props; + const { isOpen, toggle, tabs, toggleFillSize, selectedTab, hasTabs, fullSize } = dockStore; + return ( +
+ +
+ {this.renderTab(tab)})} + /> +
+
+ New tab }} closeOnScroll={false}> + createTerminalTab()}> + + Terminal session + + createResourceTab()}> + + Create resource + + +
+ {hasTabs() && ( + <> + Exit full size mode : Fit to window} + onClick={toggleFillSize} + /> + Minimize : Open} + onClick={toggle} + /> + + )} +
+
+ {this.renderTabContent()} +
+ ) + } +} diff --git a/dashboard/client/components/dock/edit-resource.scss b/dashboard/client/components/dock/edit-resource.scss new file mode 100644 index 0000000000..e4027f0675 --- /dev/null +++ b/dashboard/client/components/dock/edit-resource.scss @@ -0,0 +1,2 @@ +.EditResource { +} \ No newline at end of file diff --git a/dashboard/client/components/dock/edit-resource.store.ts b/dashboard/client/components/dock/edit-resource.store.ts new file mode 100644 index 0000000000..d01d604d37 --- /dev/null +++ b/dashboard/client/components/dock/edit-resource.store.ts @@ -0,0 +1,90 @@ +import { autobind, noop } from "../../utils"; +import { DockTabStore } from "./dock-tab.store"; +import { autorun, IReactionDisposer } from "mobx"; +import { dockStore, IDockTab, TabKind } from "./dock.store"; +import { KubeObject } from "../../api/kube-object"; +import { apiManager } from "../../api/api-manager"; + +export interface KubeEditResource { + resource: string; // resource path, e.g. /api/v1/namespaces/default + draft?: string; // edited draft in yaml +} + +@autobind() +export class EditResourceStore extends DockTabStore { + private watchers = new Map(); + + constructor() { + super({ + storageName: "edit_resource_store", + storageSerializer: ({ draft, ...data }) => data, // skip saving draft in local-storage + }); + + autorun(() => { + Array.from(this.data).forEach(([tabId, { resource }]) => { + if (this.watchers.get(tabId)) { + return; + } + this.watchers.set(tabId, autorun(() => { + const store = apiManager.getStore(resource); + if (store) { + const isActiveTab = dockStore.isOpen && dockStore.selectedTabId === tabId; + const obj = store.getByPath(resource); + // preload resource for editing + if (!obj && !store.isLoaded && !store.isLoading && isActiveTab) { + store.loadFromPath(resource).catch(noop); + } + // auto-close tab when resource removed from store + else if (!obj && store.isLoaded) { + dockStore.closeTab(tabId); + } + } + }, { + delay: 100 // make sure all stores initialized + })); + }) + }); + } + + getTabByResource(object: KubeObject): IDockTab { + const [tabId] = Array.from(this.data).find(([tabId, { resource }]) => { + return object.selfLink === resource; + }) || []; + return dockStore.getTabById(tabId); + } + + reset() { + super.reset(); + Array.from(this.watchers).forEach(([tabId, dispose]) => { + this.watchers.delete(tabId); + dispose(); + }) + } +} + +export const editResourceStore = new EditResourceStore(); + +export function editResourceTab(object: KubeObject, tabParams: Partial = {}) { + // use existing tab if already opened + let tab = editResourceStore.getTabByResource(object); + if (tab) { + dockStore.open(); + dockStore.selectTab(tab.id); + } + // or create new tab + if (!tab) { + tab = dockStore.createTab({ + title: `${object.kind}: ${object.getName()}`, + kind: TabKind.EDIT_RESOURCE, + ...tabParams + }, false); + editResourceStore.setData(tab.id, { + resource: object.selfLink, + }); + } + return tab; +} + +export function isEditResourceTab(tab: IDockTab) { + return tab && tab.kind === TabKind.EDIT_RESOURCE; +} \ No newline at end of file diff --git a/dashboard/client/components/dock/edit-resource.tsx b/dashboard/client/components/dock/edit-resource.tsx new file mode 100644 index 0000000000..8168af28bc --- /dev/null +++ b/dashboard/client/components/dock/edit-resource.tsx @@ -0,0 +1,115 @@ +import "./edit-resource.scss"; + +import React from "react"; +import { autorun, observable } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import jsYaml from "js-yaml" +import { t, Trans } from "@lingui/macro"; +import { IDockTab } from "./dock.store"; +import { cssNames } from "../../utils"; +import { editResourceStore } from "./edit-resource.store"; +import { InfoPanel } from "./info-panel"; +import { Badge } from "../badge"; +import { EditorPanel } from "./editor-panel"; +import { Spinner } from "../spinner"; +import { _i18n } from "../../i18n"; +import { apiManager } from "../../api/api-manager"; +import { KubeObject } from "../../api/kube-object"; + +interface Props { + className?: string; + tab: IDockTab; +} + +@observer +export class EditResource extends React.Component { + @observable error = ""; + + @disposeOnUnmount + autoDumpResourceOnInit = autorun(() => { + if (!this.tabData) return; + if (this.tabData.draft === undefined && this.resource) { + this.saveDraft(this.resource); + } + }); + + get tabId() { + return this.props.tab.id; + } + + get tabData() { + return editResourceStore.getData(this.tabId); + } + + get resource(): KubeObject { + const { resource } = this.tabData; + const store = apiManager.getStore(resource); + if (store) { + return store.getByPath(resource); + } + } + + saveDraft(draft: string | object) { + if (typeof draft === "object") { + draft = draft ? jsYaml.dump(draft) : undefined; + } + editResourceStore.setData(this.tabId, { + ...this.tabData, + draft: draft, + }); + } + + onChange = (draft: string, error?: string) => { + this.error = error; + this.saveDraft(draft); + } + + save = async () => { + if (this.error) { + return; + } + const { resource, draft } = this.tabData; + const store = apiManager.getStore(resource); + const updatedResource = await store.update(this.resource, jsYaml.safeLoad(draft)); + this.saveDraft(updatedResource); // update with new resourceVersion to avoid further errors on save + const resourceType = updatedResource.kind; + const resourceName = updatedResource.getName(); + return ( +

+ {resourceType} {resourceName} updated. +

+ ); + } + + render() { + const { tabId, resource, tabData, error, onChange, save } = this; + const { draft } = tabData; + if (!resource || draft === undefined) { + return ; + } + const { kind, getNs, getName } = resource; + return ( +
+ + + Kind: + Name: + Namespace: +
+ )} + /> + + ) + } +} diff --git a/dashboard/client/components/dock/editor-panel.tsx b/dashboard/client/components/dock/editor-panel.tsx new file mode 100644 index 0000000000..bf57eb10ca --- /dev/null +++ b/dashboard/client/components/dock/editor-panel.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import jsYaml from "js-yaml" +import { observable } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { cssNames } from "../../utils"; +import { AceEditor } from "../ace-editor"; +import { dockStore, TabId } from "./dock.store"; +import { DockTabStore } from "./dock-tab.store"; +import { Ace } from "ace-builds"; + +interface Props { + className?: string; + tabId: TabId; + value: string; + onChange(value: string, error?: string): void; +} + +@observer +export class EditorPanel extends React.Component { + static cursorPos = new DockTabStore(); + + public editor: AceEditor; + + @observable yamlError = "" + + componentDidMount() { + // validate and run callback with optional error + this.onChange(this.props.value || ""); + + disposeOnUnmount(this, [ + dockStore.onTabChange(this.onTabChange, { delay: 250 }), + dockStore.onResize(this.onResize, { delay: 250 }), + ]) + } + + validate(value: string) { + try { + jsYaml.safeLoadAll(value); + this.yamlError = ""; + } catch (err) { + this.yamlError = err.toString(); + } + } + + onTabChange = () => { + this.editor.focus(); + } + + onResize = () => { + this.editor.resize(); + } + + onCursorPosChange = (pos: Ace.Point) => { + EditorPanel.cursorPos.setData(this.props.tabId, pos); + } + + onChange = (value: string) => { + this.validate(value); + if (this.props.onChange) { + this.props.onChange(value, this.yamlError); + } + } + + render() { + const { value, tabId } = this.props; + let { className } = this.props; + className = cssNames("EditorPanel", className); + const cursorPos = EditorPanel.cursorPos.getData(tabId); + return ( + this.editor = e} + /> + ) + } +} diff --git a/dashboard/client/components/dock/index.ts b/dashboard/client/components/dock/index.ts new file mode 100644 index 0000000000..08cc7287b9 --- /dev/null +++ b/dashboard/client/components/dock/index.ts @@ -0,0 +1 @@ +export * from "./dock" diff --git a/dashboard/client/components/dock/info-panel.scss b/dashboard/client/components/dock/info-panel.scss new file mode 100644 index 0000000000..3bc3f94f82 --- /dev/null +++ b/dashboard/client/components/dock/info-panel.scss @@ -0,0 +1,48 @@ +.InfoPanel { + @include hidden-scrollbar; + + background: $dockInfoBackground; + border-top: 1px solid $dockInfoBorderColor; + padding: $padding $padding * 2; + flex-shrink: 0; + + .Spinner { + margin-right: $padding; + } + + > .controls { + white-space: nowrap; + + &:empty { + display: none; + } + + &:not(:empty) + .info { + border: 1px solid $borderColor; + border-top: 0; + border-bottom: 0; + min-height: 25px; + padding-left: $padding; + padding-right: $padding; + } + } + + > .info { + @include hidden-scrollbar; + + min-width: 40px; // min-space for icon + flex: 1 1; + white-space: nowrap; + text-overflow: ellipsis; + + > div { + padding-right: $padding; + flex-shrink: 0; + } + + .Icon { + margin: 0; + margin-right: $padding; + } + } +} \ No newline at end of file diff --git a/dashboard/client/components/dock/info-panel.tsx b/dashboard/client/components/dock/info-panel.tsx new file mode 100644 index 0000000000..69e79ad8de --- /dev/null +++ b/dashboard/client/components/dock/info-panel.tsx @@ -0,0 +1,137 @@ +import "./info-panel.scss"; + +import React, { Component, ReactNode } from "react"; +import { computed, observable, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { cssNames } from "../../utils"; +import { Button } from "../button"; +import { Icon } from "../icon"; +import { Spinner } from "../spinner"; +import { dockStore, TabId } from "./dock.store"; +import { Notifications } from "../notifications"; + +interface Props extends OptionalProps { + tabId: TabId; + submit: () => Promise; +} + +interface OptionalProps { + className?: string; + error?: string; + controls?: ReactNode; + submitLabel?: ReactNode; + submittingMessage?: ReactNode; + disableSubmit?: boolean; + showSubmitClose?: boolean; + showInlineInfo?: boolean; + showNotifications?: boolean; +} + +@observer +export class InfoPanel extends Component { + static defaultProps: OptionalProps = { + submitLabel: Submit, + submittingMessage: Submitting.., + showSubmitClose: true, + showInlineInfo: true, + showNotifications: true, + } + + @observable.ref result: ReactNode; + @observable error = ""; + @observable waiting = false; + + componentDidMount() { + disposeOnUnmount(this, [ + reaction(() => this.props.tabId, () => { + this.result = "" + this.error = "" + this.waiting = false + }) + ]) + } + + @computed get errorInfo() { + return this.error || this.props.error; + } + + submit = async () => { + const { showNotifications } = this.props; + this.result = ""; + this.error = ""; + this.waiting = true; + try { + this.result = await this.props.submit().finally(() => { + this.waiting = false; + }); + if (showNotifications) Notifications.ok(this.result); + } catch (error) { + this.error = error.toString(); + if (showNotifications) Notifications.error(this.error); + throw error; + } + } + + submitAndClose = async () => { + await this.submit(); + this.close(); + } + + close = () => { + dockStore.closeTab(this.props.tabId); + } + + renderInfo() { + if (!this.props.showInlineInfo) { + return; + } + const { result, errorInfo } = this; + return ( + <> + {result && ( +
+ {result} +
+ )} + {errorInfo && ( +
+ + {errorInfo} +
+ )} + + ) + } + + render() { + const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showSubmitClose } = this.props; + const { submit, close, submitAndClose, waiting } = this; + const isDisabled = !!(disableSubmit || waiting || error); + return ( +
+
+ {controls} +
+
+ {waiting ? <> {submittingMessage} : this.renderInfo()} +
+
+ ); + } +} \ No newline at end of file diff --git a/dashboard/client/components/dock/install-chart.scss b/dashboard/client/components/dock/install-chart.scss new file mode 100644 index 0000000000..38dbea7f8b --- /dev/null +++ b/dashboard/client/components/dock/install-chart.scss @@ -0,0 +1,16 @@ +.InstallChart { + .Select { + &.NamespaceSelect { + min-width: 130px; + } + + &.chart-version { + min-width: 80px; + } + } +} + +.InstallChartDone { + --flex-gap: #{$padding * 1.5}; + padding: $padding * 2; +} diff --git a/dashboard/client/components/dock/install-chart.store.ts b/dashboard/client/components/dock/install-chart.store.ts new file mode 100644 index 0000000000..1aeb9f4398 --- /dev/null +++ b/dashboard/client/components/dock/install-chart.store.ts @@ -0,0 +1,97 @@ +import { action, autorun } from "mobx"; +import { dockStore, IDockTab, TabId, TabKind } from "./dock.store"; +import { DockTabStore } from "./dock-tab.store"; +import { t } from "@lingui/macro"; +import { HelmChart, helmChartsApi } from "../../api/endpoints/helm-charts.api"; +import { IReleaseUpdateDetails } from "../../api/endpoints/helm-releases.api"; +import { _i18n } from "../../i18n"; + +export interface IChartInstallData { + name: string; + repo: string; + version: string; + values?: string; + releaseName?: string; + description?: string; + namespace?: string; + lastVersion?: boolean; +} + +export class InstallChartStore extends DockTabStore { + public versions = new DockTabStore(); + public details = new DockTabStore(); + + constructor() { + super({ + storageName: "install_charts" + }); + autorun(() => { + const { selectedTab, isOpen } = dockStore; + if (!isInstallChartTab(selectedTab)) return; + if (isOpen) { + this.loadData(); + } + }, { delay: 250 }) + } + + @action + async loadData(tabId = dockStore.selectedTabId) { + const { values } = this.getData(tabId); + const versions = this.versions.getData(tabId); + return Promise.all([ + !versions && this.loadVersions(tabId), + !values && this.loadValues(tabId), + ]) + } + + @action + async loadVersions(tabId: TabId) { + const { repo, name } = this.getData(tabId); + this.versions.clearData(tabId); // reset + const charts = await helmChartsApi.get(repo, name); + const versions = charts.versions.map(chartVersion => chartVersion.version); + this.versions.setData(tabId, versions); + } + + @action + async loadValues(tabId: TabId) { + const data = this.getData(tabId); + const { repo, name, version } = data; + let values = ""; + const fetchValues = async (retry = 1, maxRetries = 3) => { + values = await helmChartsApi.getValues(repo, name, version); + if (values || retry == maxRetries) return; + await fetchValues(retry + 1); + }; + this.setData(tabId, { ...data, values: undefined }); // reset + await fetchValues(); + this.setData(tabId, { ...data, values }); + } +} + +export const installChartStore = new InstallChartStore(); + +export function createInstallChartTab(chart: HelmChart, tabParams: Partial = {}) { + const { name, repo, version } = chart; + + const tab = dockStore.createTab({ + kind: TabKind.INSTALL_CHART, + title: _i18n._(t`Helm Install: ${repo}/${name}`), + ...tabParams + }, false); + + installChartStore.setData(tab.id, { + name, + repo, + version, + namespace: "default", + releaseName: "", + description: "", + }); + + return tab; +} + +export function isInstallChartTab(tab: IDockTab) { + return tab && tab.kind === TabKind.INSTALL_CHART; +} diff --git a/dashboard/client/components/dock/install-chart.tsx b/dashboard/client/components/dock/install-chart.tsx new file mode 100644 index 0000000000..786536dd58 --- /dev/null +++ b/dashboard/client/components/dock/install-chart.tsx @@ -0,0 +1,194 @@ +import "./install-chart.scss"; + +import React, { Component } from "react"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { t, Trans } from "@lingui/macro"; +import { dockStore, IDockTab } from "./dock.store"; +import { InfoPanel } from "./info-panel"; +import { Badge } from "../badge"; +import { NamespaceSelect } from "../+namespaces/namespace-select"; +import { autobind, prevDefault } from "../../utils"; +import { IChartInstallData, installChartStore } from "./install-chart.store"; +import { Spinner } from "../spinner"; +import { Icon } from "../icon"; +import { Button } from "../button"; +import { releaseURL } from "../+apps-releases"; +import { releaseStore } from "../+apps-releases/release.store"; +import { LogsDialog } from "../dialog/logs-dialog"; +import { Select, SelectOption } from "../select"; +import { Input } from "../input"; +import { EditorPanel } from "./editor-panel"; +import { navigate } from "../../navigation"; +import { _i18n } from "../../i18n"; + +interface Props { + tab: IDockTab; +} + +@observer +export class InstallChart extends Component { + @observable error = ""; + @observable showNotes = false; + + get values() { + return this.chartData.values; + } + + get chartData() { + return installChartStore.getData(this.tabId); + } + + get tabId() { + return this.props.tab.id; + } + + get versions() { + return installChartStore.versions.getData(this.tabId); + } + + get releaseDetails() { + return installChartStore.details.getData(this.tabId); + } + + @autobind() + viewRelease() { + const { release } = this.releaseDetails; + navigate(releaseURL({ + params: { + name: release.name, + namespace: release.namespace + } + })); + dockStore.closeTab(this.tabId); + } + + @autobind() + save(data: Partial) { + const chart = { ...this.chartData, ...data }; + installChartStore.setData(this.tabId, chart); + } + + @autobind() + onVersionChange(option: SelectOption) { + const version = option.value; + this.save({ version, values: "" }); + installChartStore.loadValues(this.tabId); + } + + @autobind() + onValuesChange(values: string, error?: string) { + this.error = error; + this.save({ values }); + } + + @autobind() + onNamespaceChange(opt: SelectOption) { + this.save({ namespace: opt.value }); + } + + @autobind() + onReleaseNameChange(name: string) { + this.save({ releaseName: name }); + } + + install = async () => { + const { repo, name, version, namespace, values, releaseName } = this.chartData; + const details = await releaseStore.create({ + name: releaseName || undefined, + chart: name, + repo, namespace, version, values, + }); + installChartStore.details.setData(this.tabId, details); + return ( +

Chart Release {details.release.name} successfully created.

+ ); + } + + render() { + const { tabId, chartData, values, versions, install } = this; + if (!chartData || chartData.values === undefined || !versions) { + return ; + } + + if (this.releaseDetails) { + return ( +
+

+ +

+

Installation complete!

+
+
+ this.showNotes = false} + logs={this.releaseDetails.log} + /> +
+ ) + } + + const { repo, name, version, namespace, releaseName } = chartData; + const panelControls = ( +
+ Chart + + Version + +
+ ); + + return ( +
+ + +
+ ); + } +} \ No newline at end of file diff --git a/dashboard/client/components/dock/terminal-tab.scss b/dashboard/client/components/dock/terminal-tab.scss new file mode 100644 index 0000000000..09519f5542 --- /dev/null +++ b/dashboard/client/components/dock/terminal-tab.scss @@ -0,0 +1,3 @@ +.TerminalTab { + +} \ No newline at end of file diff --git a/dashboard/client/components/dock/terminal-tab.tsx b/dashboard/client/components/dock/terminal-tab.tsx new file mode 100644 index 0000000000..1cb669a38c --- /dev/null +++ b/dashboard/client/components/dock/terminal-tab.tsx @@ -0,0 +1,51 @@ +import "./terminal-tab.scss" + +import React from "react" +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { autobind, cssNames } from "../../utils"; +import { DockTab, DockTabProps } from "./dock-tab"; +import { Icon } from "../icon"; +import { terminalStore } from "./terminal.store"; + +interface Props extends DockTabProps { +} + +@observer +export class TerminalTab extends React.Component { + get tabId() { + return this.props.value.id; + } + + get isDisconnected() { + return terminalStore.isDisconnected(this.tabId); + } + + @autobind() + reconnect() { + terminalStore.reconnect(this.tabId); + } + + render() { + const tabIcon = ; + const className = cssNames("TerminalTab", this.props.className, { + disconnected: this.isDisconnected, + }); + return ( + Restart session} + onClick={this.reconnect} + /> + )} + /> + ) + } +} \ No newline at end of file diff --git a/dashboard/client/components/dock/terminal-window.scss b/dashboard/client/components/dock/terminal-window.scss new file mode 100644 index 0000000000..17cabffdc1 --- /dev/null +++ b/dashboard/client/components/dock/terminal-window.scss @@ -0,0 +1,28 @@ +@import "~xterm"; + +.TerminalWindow { + margin: $padding; + margin-left: $padding * 2; + margin-top: $padding * 2; + + &.light { + .xterm-viewport { + @include custom-scrollbar(dark); + } + } + + > .xterm { + overflow: hidden; + } + + .xterm-viewport { + @include custom-scrollbar; + } + + // fix: safari won't handle paste event for textarea with zero size block + .xterm-helper-textarea { + width: 10px !important; + height: 10px !important; + pointer-events: none; + } +} \ No newline at end of file diff --git a/dashboard/client/components/dock/terminal-window.tsx b/dashboard/client/components/dock/terminal-window.tsx new file mode 100644 index 0000000000..736b400e77 --- /dev/null +++ b/dashboard/client/components/dock/terminal-window.tsx @@ -0,0 +1,45 @@ +import "./terminal-window.scss"; + +import React from "react"; +import { reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { cssNames } from "../../utils"; +import { IDockTab } from "./dock.store"; +import { Terminal } from "./terminal"; +import { terminalStore } from "./terminal.store"; +import { themeStore } from "../../theme.store"; + +interface Props { + className?: string; + tab: IDockTab; +} + +@observer +export class TerminalWindow extends React.Component { + public elem: HTMLElement; + public terminal: Terminal; + + componentDidMount() { + disposeOnUnmount(this, [ + reaction(() => this.props.tab.id, tabId => this.activate(tabId), { + fireImmediately: true + }) + ]) + } + + activate(tabId = this.props.tab.id) { + if (this.terminal) this.terminal.detach(); // detach previous + this.terminal = terminalStore.getTerminal(tabId); + this.terminal.attachTo(this.elem); + } + + render() { + const { className } = this.props; + return ( +
this.elem = e} + /> + ) + } +} diff --git a/dashboard/client/components/dock/terminal.store.ts b/dashboard/client/components/dock/terminal.store.ts new file mode 100644 index 0000000000..278a538852 --- /dev/null +++ b/dashboard/client/components/dock/terminal.store.ts @@ -0,0 +1,119 @@ +import { autorun, observable } from "mobx"; +import { t } from "@lingui/macro"; +import { autobind } from "../../utils"; +import { Terminal } from "./terminal"; +import { TerminalApi } from "../../api/terminal-api"; +import { dockStore, IDockTab, TabId, TabKind } from "./dock.store"; +import { WebSocketApiState } from "../../api/websocket-api"; +import { _i18n } from "../../i18n"; +import { themeStore } from "../../theme.store"; + +export interface ITerminalTab extends IDockTab { + node?: string; // activate node shell mode +} + +export function isTerminalTab(tab: IDockTab) { + return tab && tab.kind === TabKind.TERMINAL; +} + + +export function createTerminalTab(tabParams: Partial = {}) { + return dockStore.createTab({ + kind: TabKind.TERMINAL, + title: _i18n._(t`Terminal`), + ...tabParams + }); +} + +@autobind() +export class TerminalStore { + protected terminals = new Map(); + protected connections = observable.map(); + + constructor() { + // connect active tab + autorun(() => { + const { selectedTab, isOpen } = dockStore; + if (!isTerminalTab(selectedTab)) return; + if (isOpen) { + this.connect(selectedTab.id); + } + }); + // disconnect closed tabs + autorun(() => { + const currentTabs = dockStore.tabs.map(tab => tab.id); + for (const [tabId] of this.connections) { + if (!currentTabs.includes(tabId)) this.disconnect(tabId); + } + }); + } + + async connect(tabId: TabId) { + if (this.isConnected(tabId)) { + return; + } + const tab: ITerminalTab = dockStore.getTabById(tabId); + const api = new TerminalApi({ + id: tabId, + node: tab.node, + colorTheme: themeStore.activeTheme.type + }); + const terminal = new Terminal(tabId, api); + this.connections.set(tabId, api); + this.terminals.set(tabId, terminal); + } + + disconnect(tabId: TabId) { + if (!this.isConnected(tabId)) { + return; + } + const terminal = this.terminals.get(tabId); + const terminalApi = this.connections.get(tabId); + terminal.destroy(); + terminalApi.destroy(); + this.connections.delete(tabId); + this.terminals.delete(tabId); + } + + reconnect(tabId: TabId) { + const terminalApi = this.connections.get(tabId); + if (terminalApi) terminalApi.connect(); + } + + isConnected(tabId: TabId) { + return !!this.connections.get(tabId); + } + + isDisconnected(tabId: TabId) { + const terminalApi = this.connections.get(tabId); + if (terminalApi) { + return terminalApi.readyState === WebSocketApiState.CLOSED; + } + } + + sendCommand(command: string, options: { enter?: boolean; newTab?: boolean; tabId?: TabId } = {}) { + const { enter, newTab, tabId } = options; + const { selectTab, getTabById } = dockStore; + + const tab = tabId && getTabById(tabId); + if (tab) selectTab(tabId); + if (newTab) createTerminalTab(); + + const terminalApi = this.connections.get(dockStore.selectedTabId); + if (terminalApi) { + terminalApi.sendCommand(command + (enter ? "\r" : "")); + } + } + + getTerminal(tabId: TabId) { + return this.terminals.get(tabId); + } + + reset() { + [...this.connections].forEach(([tabId]) => { + this.disconnect(tabId); + }); + } +} + +export const terminalStore = new TerminalStore(); diff --git a/dashboard/client/components/dock/terminal.ts b/dashboard/client/components/dock/terminal.ts new file mode 100644 index 0000000000..298526421d --- /dev/null +++ b/dashboard/client/components/dock/terminal.ts @@ -0,0 +1,189 @@ +import debounce from "lodash/debounce"; +import { autorun } from "mobx"; +import { Terminal as XTerm } from "xterm"; +import { FitAddon } from "xterm-addon-fit"; +import { dockStore, TabId } from "./dock.store"; +import { TerminalApi } from "../../api/terminal-api"; +import { themeStore } from "../../theme.store"; +import { autobind } from "../../utils"; + +export class Terminal { + static spawningPool: HTMLElement; + + static init() { + // terminal element must be in DOM before attaching via xterm.open(elem) + // https://xtermjs.org/docs/api/terminal/classes/terminal/#open + const pool = document.createElement("div"); + pool.className = "terminal-init"; + pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden" + document.body.appendChild(pool); + Terminal.spawningPool = pool; + } + + public xterm: XTerm; + public fitAddon: FitAddon; + public scrollPos = 0; + public disposers: Function[] = []; + + @autobind() + protected setTheme(colors = themeStore.activeTheme.colors) { + // Replacing keys stored in styles to format accepted by terminal + // E.g. terminalBrightBlack -> brightBlack + const colorPrefix = "terminal" + const terminalColors = Object.entries(colors) + .filter(([name]) => name.startsWith(colorPrefix)) + .reduce((colors, [name, color]) => { + const colorName = name.split("").slice(colorPrefix.length); + colorName[0] = colorName[0].toLowerCase(); + colors[colorName.join("")] = color; + return colors; + }, {}); + this.xterm.setOption("theme", terminalColors); + } + + get elem() { + return this.xterm.element; + } + + get viewport() { + return this.xterm.element.querySelector(".xterm-viewport"); + } + + constructor(public tabId: TabId, protected api: TerminalApi) { + this.init(); + } + + get isActive() { + const { isOpen, selectedTabId } = dockStore; + return isOpen && selectedTabId === this.tabId; + } + + attachTo(parentElem: HTMLElement) { + parentElem.appendChild(this.elem); + this.onActivate(); + } + + detach() { + Terminal.spawningPool.appendChild(this.elem); + } + + init() { + if (this.xterm) { + return; + } + this.xterm = new XTerm({ + cursorBlink: true, + cursorStyle: "bar", + fontSize: 13, + fontFamily: "RobotoMono" + }); + + // enable terminal addons + this.fitAddon = new FitAddon(); + this.xterm.loadAddon(this.fitAddon); + + this.xterm.open(Terminal.spawningPool); + this.xterm.registerLinkMatcher(/https?:\/\/[^\s]+/i, this.onClickLink); + this.xterm.attachCustomKeyEventHandler(this.keyHandler); + + // bind events + const onResizeDisposer = dockStore.onResize(this.onResize); + const onData = this.xterm.onData(this.onData); + const onThemeChangeDisposer = autorun(() => this.setTheme(themeStore.activeTheme.colors)); + this.viewport.addEventListener("scroll", this.onScroll); + this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..) + this.api.onData.addListener(this.onApiData); + window.addEventListener("resize", this.onResize); + + // add clean-up handlers to be called on destroy + this.disposers.push( + onResizeDisposer, + onThemeChangeDisposer, + () => onData.dispose(), + () => this.fitAddon.dispose(), + () => this.api.removeAllListeners(), + () => window.removeEventListener("resize", this.onResize), + ); + } + + destroy() { + if (!this.xterm) return; + this.disposers.forEach(dispose => dispose()); + this.disposers = []; + this.xterm.dispose(); + this.xterm = null; + } + + fit = () => { + this.fitAddon.fit(); + const { cols, rows } = this.xterm; + this.api.sendTerminalSize(cols, rows); + }; + + fitLazy = debounce(this.fit, 250); + + focus = () => { + this.xterm.focus(); + } + + onApiData = (data: string) => { + this.xterm.write(data); + } + + onData = (data: string) => { + if (!this.api.isReady) return; + this.api.sendCommand(data); + } + + onScroll = () => { + this.scrollPos = this.viewport.scrollTop; + } + + onClear = () => { + this.xterm.clear(); + } + + onResize = () => { + if (!this.isActive) return; + this.fitLazy(); + } + + onActivate = () => { + this.fit(); + setTimeout(() => this.focus(), 250); // delay used to prevent focus on active tab + this.viewport.scrollTop = this.scrollPos; // restore last scroll position + } + + onClickLink = (evt: MouseEvent, link: string) => { + window.open(link, "_blank"); + } + + keyHandler = (evt: KeyboardEvent): boolean => { + const { code, ctrlKey, type } = evt; + + // Handle custom hotkey bindings + if (ctrlKey) { + switch (code) { + // Ctrl+C: prevent terminal exit on windows / linux (?) + case "KeyC": + if (this.xterm.hasSelection()) return false; + break; + + // Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim + // https://github.com/kontena/lens-app/issues/156#issuecomment-534906480 + case "KeyW": + evt.preventDefault(); + break; + } + } + + // Pass the event above in DOM for to handle common actions + if (!evt.defaultPrevented) { + this.elem.dispatchEvent(new KeyboardEvent(type, evt)); + } + + return true; + } +} + +Terminal.init(); \ No newline at end of file diff --git a/dashboard/client/components/dock/upgrade-chart.scss b/dashboard/client/components/dock/upgrade-chart.scss new file mode 100644 index 0000000000..437b2d04a5 --- /dev/null +++ b/dashboard/client/components/dock/upgrade-chart.scss @@ -0,0 +1,2 @@ +.UpgradeChart { +} \ No newline at end of file diff --git a/dashboard/client/components/dock/upgrade-chart.store.ts b/dashboard/client/components/dock/upgrade-chart.store.ts new file mode 100644 index 0000000000..035929bc5c --- /dev/null +++ b/dashboard/client/components/dock/upgrade-chart.store.ts @@ -0,0 +1,123 @@ +import { action, autorun, IReactionDisposer, reaction } from "mobx"; +import { t } from "@lingui/macro"; +import { dockStore, IDockTab, TabId, TabKind } from "./dock.store"; +import { DockTabStore } from "./dock-tab.store"; +import { HelmRelease, helmReleasesApi } from "../../api/endpoints/helm-releases.api"; +import { releaseStore } from "../+apps-releases/release.store"; +import { _i18n } from "../../i18n"; + +export interface IChartUpgradeData { + releaseName: string; + releaseNamespace: string; +} + +export class UpgradeChartStore extends DockTabStore { + private watchers = new Map(); + + values = new DockTabStore(); + + constructor() { + super({ + storageName: "chart_releases" + }); + + autorun(() => { + const { selectedTab, isOpen } = dockStore; + if (!isUpgradeChartTab(selectedTab)) return; + if (isOpen) { + this.loadData(selectedTab.id); + } + }, { delay: 250 }); + + autorun(() => { + const objects = [...this.data.values()]; + objects.forEach(({ releaseName }) => this.createReleaseWatcher(releaseName)); + }); + } + + private createReleaseWatcher(releaseName: string) { + if (this.watchers.get(releaseName)) { + return; + } + const dispose = reaction(() => { + const release = releaseStore.getByName(releaseName); + if (release) return release.getRevision(); // watch changes only by revision + }, + release => { + const releaseTab = this.getTabByRelease(releaseName); + if (!releaseStore.isLoaded || !releaseTab) { + return; + } + // auto-reload values if was loaded before + if (release) { + if (dockStore.selectedTab === releaseTab && this.values.getData(releaseTab.id)) { + this.loadValues(releaseTab.id); + } + } + // clean up watcher, close tab if release not exists / was removed + else { + dispose(); + this.watchers.delete(releaseName); + dockStore.closeTab(releaseTab.id); + } + }); + this.watchers.set(releaseName, dispose); + } + + isLoading(tabId = dockStore.selectedTabId) { + const values = this.values.getData(tabId); + return !releaseStore.isLoaded || values === undefined; + } + + @action + async loadData(tabId: TabId) { + const values = this.values.getData(tabId); + await Promise.all([ + !releaseStore.isLoaded && releaseStore.loadAll(), + !values && this.loadValues(tabId) + ]); + } + + @action + async loadValues(tabId: TabId) { + this.values.clearData(tabId); // reset + const { releaseName, releaseNamespace } = this.getData(tabId); + const values = await helmReleasesApi.getValues(releaseName, releaseNamespace); + this.values.setData(tabId, values); + } + + getTabByRelease(releaseName: string): IDockTab { + const item = [...this.data].find(item => item[1].releaseName === releaseName); + if (item) { + const [tabId] = item; + return dockStore.getTabById(tabId) + } + } +} + +export const upgradeChartStore = new UpgradeChartStore(); + +export function createUpgradeChartTab(release: HelmRelease, tabParams: Partial = {}) { + let tab = upgradeChartStore.getTabByRelease(release.getName()); + if (tab) { + dockStore.open(); + dockStore.selectTab(tab.id); + } + if (!tab) { + tab = dockStore.createTab({ + kind: TabKind.UPGRADE_CHART, + title: _i18n._(t`Helm Upgrade: ${release.getName()}`), + ...tabParams + }, false); + + upgradeChartStore.setData(tab.id, { + releaseName: release.getName(), + releaseNamespace: release.getNs() + }) + } + return tab; +} + +export function isUpgradeChartTab(tab: IDockTab) { + return tab && tab.kind === TabKind.UPGRADE_CHART; +} diff --git a/dashboard/client/components/dock/upgrade-chart.tsx b/dashboard/client/components/dock/upgrade-chart.tsx new file mode 100644 index 0000000000..8d7619f9be --- /dev/null +++ b/dashboard/client/components/dock/upgrade-chart.tsx @@ -0,0 +1,133 @@ +import "./upgrade-chart.scss" + +import React from "react"; +import { observable, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { t, Trans } from "@lingui/macro"; +import { cssNames } from "../../utils"; +import { IDockTab } from "./dock.store"; +import { InfoPanel } from "./info-panel"; +import { upgradeChartStore } from "./upgrade-chart.store"; +import { Spinner } from "../spinner"; +import { releaseStore } from "../+apps-releases/release.store"; +import { Badge } from "../badge"; +import { EditorPanel } from "./editor-panel"; +import { helmChartStore, IChartVersion } from "../+apps-helm-charts/helm-chart.store"; +import { HelmRelease } from "../../api/endpoints/helm-releases.api"; +import { Select, SelectOption } from "../select"; +import { _i18n } from "../../i18n"; + +interface Props { + className?: string; + tab: IDockTab; +} + +@observer +export class UpgradeChart extends React.Component { + @observable error: string; + @observable versions = observable.array(); + @observable version: IChartVersion; + + componentDidMount() { + this.loadVersions(); + + disposeOnUnmount(this, [ + reaction(() => this.release, () => this.loadVersions()) + ]); + } + + get tabId() { + return this.props.tab.id; + } + + get release(): HelmRelease { + const tabData = upgradeChartStore.getData(this.tabId); + if (!tabData) return; + return releaseStore.getByName(tabData.releaseName); + } + + get value() { + return upgradeChartStore.values.getData(this.tabId); + } + + async loadVersions() { + if (!this.release) return; + this.version = null; + this.versions.clear(); + const versions = await helmChartStore.getVersions(this.release.getChart()); + this.versions.replace(versions); + this.version = this.versions[0]; + } + + onChange = (value: string, error?: string) => { + upgradeChartStore.values.setData(this.tabId, value); + this.error = error; + } + + upgrade = async () => { + if (this.error) return; + const { version, repo } = this.version; + const releaseName = this.release.getName(); + const releaseNs = this.release.getNs() + await releaseStore.update(releaseName, releaseNs, { + chart: this.release.getChart(), + values: this.value, + repo, version, + }); + return ( +

+ Release {releaseName} successfully upgraded to version {version} +

+ ) + } + + formatVersionLabel = ({ value }: SelectOption) => { + const chartName = this.release.getChart(); + const { repo, version } = value; + return `${repo}/${chartName}-${version}`; + } + + render() { + const { tabId, release, value, error, onChange, upgrade, versions, version } = this; + const { className } = this.props; + if (!release || upgradeChartStore.isLoading() || !version) { + return ; + } + const currentVersion = release.getVersion(); + const controlsAndInfo = ( +
+ Release + Namespace + Version + Upgrade version +