diff --git a/.eslintrc.js b/.eslintrc.js index 43adca25e5..516029cdd4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,11 +4,9 @@ module.exports = { files: [ "src/renderer/**/*.js", "build/**/*.js", - "src/renderer/**/*.vue" ], extends: [ 'eslint:recommended', - 'plugin:vue/recommended' ], env: { node: true @@ -20,9 +18,6 @@ module.exports = { rules: { "indent": ["error", 2], "no-unused-vars": "off", - "vue/order-in-components": "off", - "vue/attributes-order": "off", - "vue/max-attributes-per-line": "off" } }, { diff --git a/.gitignore b/.gitignore index 2381ff83b1..53701a44d2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,7 @@ node_modules/ yarn-error.log coverage/ tmp/ -static/build/client/ +static/build/** binaries/client/ binaries/server/ locales/**/**.js diff --git a/.yarnrc b/.yarnrc index 4be7962079..5119ded102 100644 --- a/.yarnrc +++ b/.yarnrc @@ -1,3 +1,3 @@ disturl "https://atom.io/download/electron" -target "6.1.12" +target "9.1.0" runtime "electron" diff --git a/README.md b/README.md index 020b11ef96..fa8c476799 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,17 @@ brew cask install lens Allows faster separately re-run some of involved processes: -1. `yarn dev:main` compiles electron's main process and watch files -1. `yarn dev:renderer:vue` compiles electron's renderer vue-part -1. `yarn dev:renderer:react` compiles electron's renderer react-part +1. `yarn dev:main` compiles electron's main process part and start watching files +1. `yarn dev:renderer` compiles electron's renderer part and start watching files 1. `yarn dev-run` runs app in dev-mode and restarts when electron's main process file has changed -Alternatively to compile both render parts in single command use `yarn dev:renderer` +## Developer's ~~RTFM~~ recommended list: + +- [TypeScript](https://www.typescriptlang.org/docs/home.html) (front-end/back-end) +- [ReactJS](https://reactjs.org/docs/getting-started.html) (front-end, ui) +- [MobX](https://mobx.js.org/) (app-state-management, back-end/front-end) +- [ElectronJS](https://www.electronjs.org/docs) (chrome/node) +- [NodeJS](https://nodejs.org/dist/latest-v12.x/docs/api/) (api docs) ## Contributing diff --git a/__mocks__/electron.js b/__mocks__/electron.ts similarity index 79% rename from __mocks__/electron.js rename to __mocks__/electron.ts index 8e744a11a8..889a09c914 100644 --- a/__mocks__/electron.js +++ b/__mocks__/electron.ts @@ -3,8 +3,10 @@ module.exports = { match: jest.fn(), app: { getVersion: jest.fn().mockReturnValue("3.0.0"), - getPath: jest.fn().mockReturnValue("tmp"), getLocale: jest.fn().mockRejectedValue("en"), + getPath: jest.fn((name: string) => { + return "tmp" + }), }, remote: { app: { diff --git a/__mocks__/styleMock.ts b/__mocks__/styleMock.ts new file mode 100644 index 0000000000..a099545376 --- /dev/null +++ b/__mocks__/styleMock.ts @@ -0,0 +1 @@ +module.exports = {}; \ No newline at end of file diff --git a/build/download_helm.ts b/build/download_helm.ts index 2f81b14583..9dddf7fcdc 100644 --- a/build/download_helm.ts +++ b/build/download_helm.ts @@ -1,3 +1,3 @@ -import { helmCli } from "../src/main/helm-cli" +import { helmCli } from "../src/main/helm/helm-cli" helmCli.ensureBinary() diff --git a/build/download_kubectl.ts b/build/download_kubectl.ts index 708c1c843c..a697552aa8 100644 --- a/build/download_kubectl.ts +++ b/build/download_kubectl.ts @@ -79,7 +79,9 @@ class KubectlDownloader { return new Promise((resolve, reject) => { file.on("close", () => { console.log("kubectl binary download closed") - fs.chmod(this.path, 0o755, () => {}) + fs.chmod(this.path, 0o755, (err) => { + if (err) reject(err); + }) resolve() }) stream.pipe(file) diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 3e3fc60add..72d6e3d732 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -3,13 +3,13 @@ import { Application } from "spectron"; let appPath = "" switch(process.platform) { case "win32": - appPath = "./dist/win-unpacked/LensDev.exe" + appPath = "./dist/win-unpacked/Lens.exe" break case "linux": appPath = "./dist/linux-unpacked/kontena-lens" break case "darwin": - appPath = "./dist/mac/LensDev.app/Contents/MacOS/LensDev" + appPath = "./dist/mac/Lens.app/Contents/MacOS/Lens" break } @@ -20,6 +20,10 @@ export function setup(): Application { path: appPath, startTimeout: 30000, waitTimeout: 30000, + chromeDriverArgs: ['remote-debugging-port=9222'], + env: { + CICD: "true" + } }) } diff --git a/integration/specs/app_spec.ts b/integration/specs/app_spec.ts index 79f867b53a..f3cb922740 100644 --- a/integration/specs/app_spec.ts +++ b/integration/specs/app_spec.ts @@ -1,7 +1,6 @@ import { Application } from "spectron" import * as util from "../helpers/utils" import { spawnSync } from "child_process" -import { stat } from "fs" jest.setTimeout(20000) @@ -11,19 +10,21 @@ describe("app start", () => { let app: Application const clickWhatsNew = async (app: Application) => { await app.client.waitUntilTextExists("h1", "What's new") - await app.client.click("button.btn-primary") + await app.client.click("button.primary") await app.client.waitUntilTextExists("h1", "Welcome") } const addMinikubeCluster = async (app: Application) => { - await app.client.click("a#add-cluster") - await app.client.waitUntilTextExists("legend", "Choose config:") - await app.client.selectByVisibleText("select#kubecontext-select", "minikube (new)") - await app.client.click("button.btn-primary") + await app.client.click("div.add-cluster") + await app.client.waitUntilTextExists("p", "Choose config") + await app.client.click("div#kubecontext-select") + await app.client.waitUntilTextExists("div", "minikube") + await app.client.click("div.minikube") + await app.client.click("button.primary") } const waitForMinikubeDashboard = async (app: Application) => { - await app.client.waitUntilTextExists("pre.auth-output", "Authentication proxy started") + await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started") let windowCount = await app.client.getWindowCount() // wait for webview to appear on window count while (windowCount == 1) { diff --git a/locales/en/messages.po b/locales/en/messages.po index ae725e7dc7..b713158bfb 100644 --- a/locales/en/messages.po +++ b/locales/en/messages.po @@ -13,30 +13,54 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#: src/renderer/components/+workspaces/clusters-menu.tsx:38 +#~ msgid "'Disconnect'" +#~ msgstr "'Disconnect'" + +#: src/renderer/components/+workspaces/clusters-menu.tsx:31 +#~ msgid "'Settings'" +#~ msgstr "'Settings'" + #: src/renderer/components/+config-autoscalers/hpa-details.tsx:28 msgid "(as a percentage of request)" msgstr "(as a percentage of request)" +#: src/renderer/components/+workspaces/workspaces.tsx:108 +msgid "(current)" +msgstr "(current)" + #: src/renderer/components/+network-policies/network-policy-details.tsx:88 msgid "(empty) (Allowing the specific traffic to all pods in this namespace)" msgstr "(empty) (Allowing the specific traffic to all pods in this namespace)" +#: src/renderer/components/+add-cluster/add-cluster.tsx:105 +#~ msgid "(new)" +#~ msgstr "(new)" + #: src/renderer/components/item-object-list/item-list-layout.tsx:224 msgid "<0>Filtered: {itemsCount} / {allItemsCount}" msgstr "<0>Filtered: {itemsCount} / {allItemsCount}" #: src/renderer/browser-check.tsx:11 -msgid "<0>Your browser does not support all Lens features. Please consider using another browser." -msgstr "<0>Your browser does not support all Lens features. Please consider using another browser." +#~ msgid "<0>Your browser does not support all Lens features. Please consider using another browser." +#~ msgstr "<0>Your browser does not support all Lens features. Please consider using another browser." #: src/renderer/components/dock/create-resource.tsx:56 msgid "<0>{0} successfully created" msgstr "<0>{0} successfully created" +#: src/renderer/components/+add-cluster/add-cluster.tsx:176 +#~ msgid "A HTTP proxy server URL (format: http://
:)" +#~ msgstr "A HTTP proxy server URL (format: http://
:)" + #: src/renderer/components/input/input.validators.ts:40 msgid "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics." msgstr "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics." +#: src/renderer/components/+workspaces/workspaces.tsx:84 +msgid "A single workspaces contains a list of clusters and their full configuration." +msgstr "A single workspaces contains a list of clusters and their full configuration." + #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:80 msgid "API Group" msgstr "API Group" @@ -59,6 +83,11 @@ msgstr "Account Name" msgid "Active" msgstr "Active" +#: src/renderer/components/+add-cluster/add-cluster.tsx:170 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:116 +msgid "Add Cluster" +msgstr "Add Cluster" + #: src/renderer/components/+namespaces/namespaces.tsx:43 msgid "Add Namespace" msgstr "Add Namespace" @@ -67,21 +96,53 @@ msgstr "Add Namespace" msgid "Add RoleBinding" msgstr "Add RoleBinding" +#: src/renderer/components/+workspaces/workspaces.tsx:125 +msgid "Add Workspace" +msgstr "Add Workspace" + #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:111 msgid "Add bindings to {name}" msgstr "Add bindings to {name}" +#: src/renderer/components/+add-cluster/add-cluster.tsx:187 +#~ msgid "Add cluster" +#~ msgstr "Add cluster" + +#: src/renderer/components/+add-cluster/add-cluster.tsx:191 +msgid "Add cluster(s)" +msgstr "Add cluster(s)" + +#: src/renderer/components/+workspaces/clusters-menu.tsx:58 +#~ msgid "Add clusters" +#~ msgstr "Add clusters" + #: src/renderer/components/+config-secrets/add-secret-dialog.tsx:125 msgid "Add field" msgstr "Add field" +#: src/renderer/components/+preferences/preferences.tsx:123 +#~ msgid "Added repos" +#~ msgstr "Added repos" + +#: src/renderer/components/+preferences/preferences.tsx:144 +msgid "Added repos:" +msgstr "Added repos:" + +#: src/renderer/components/+preferences/preferences.tsx:103 +msgid "Adding helm branch <0>{0} has failed: {1}" +msgstr "Adding helm branch <0>{0} has failed: {1}" + +#: src/renderer/components/+preferences/preferences.tsx:108 +#~ msgid "Adding repo <0>{0} has failed: {1}" +#~ msgstr "Adding repo <0>{0} has failed: {1}" + #: src/renderer/components/+custom-resources/crd-details.tsx:78 msgid "Additional Printer Columns" msgstr "Additional Printer Columns" #: src/renderer/components/+network-endpoints/endpoint-subset-list.tsx:29 #: src/renderer/components/+network-endpoints/endpoint-subset-list.tsx:60 -#: src/renderer/components/+nodes/node-details.tsx:84 +#: src/renderer/components/+nodes/node-details.tsx:83 msgid "Addresses" msgstr "Addresses" @@ -103,7 +164,7 @@ msgstr "Affinities" #: src/renderer/components/+network-ingresses/ingresses.tsx:35 #: src/renderer/components/+network-policies/network-policies.tsx:34 #: src/renderer/components/+network-services/services.tsx:51 -#: src/renderer/components/+nodes/nodes.tsx:119 +#: src/renderer/components/+nodes/nodes.tsx:126 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:38 #: src/renderer/components/+storage-classes/storage-classes.tsx:38 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:51 @@ -115,12 +176,20 @@ msgstr "Affinities" #: src/renderer/components/+workloads-daemonsets/daemonsets.tsx:50 #: src/renderer/components/+workloads-deployments/deployments.tsx:63 #: src/renderer/components/+workloads-jobs/jobs.tsx:41 -#: src/renderer/components/+workloads-pods/pods.tsx:80 +#: src/renderer/components/+workloads-pods/pods.tsx:81 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:53 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:44 msgid "Age" msgstr "Age" +#: src/renderer/components/+workspaces/workspaces.tsx:64 +msgid "All clusters within workspace will be cleared as well" +msgstr "All clusters within workspace will be cleared as well" + +#: src/renderer/components/+workspaces/workspaces.tsx:64 +#~ msgid "All clusters within workspace will be cleared as well." +#~ msgstr "All clusters within workspace will be cleared as well." + #: src/renderer/components/+custom-resources/crd-list.tsx:56 msgid "All groups" msgstr "All groups" @@ -133,7 +202,7 @@ msgstr "All logs" msgid "All namespaces" msgstr "All namespaces" -#: src/renderer/components/+nodes/node-details.tsx:78 +#: src/renderer/components/+nodes/node-details.tsx:77 msgid "Allocatable" msgstr "Allocatable" @@ -141,6 +210,14 @@ msgstr "Allocatable" msgid "Allow Privilege Escalation" msgstr "Allow Privilege Escalation" +#: src/renderer/components/+preferences/preferences.tsx:172 +msgid "Allow telemetry & usage tracking" +msgstr "Allow telemetry & usage tracking" + +#: src/renderer/components/+preferences/preferences.tsx:164 +msgid "Allow untrusted Certificate Authorities" +msgstr "Allow untrusted Certificate Authorities" + #: src/renderer/components/+pod-security-policies/pod-security-policy-details.tsx:51 msgid "Allowed CSI Drivers" msgstr "Allowed CSI Drivers" @@ -169,7 +246,7 @@ msgstr "Allowed Runtime Class Names" msgid "Allowed Unsafe Sysctls" msgstr "Allowed Unsafe Sysctls" -#: src/renderer/components/+nodes/node-details.tsx:103 +#: src/renderer/components/+nodes/node-details.tsx:102 #: src/renderer/components/kube-object/kube-object-meta.tsx:36 msgid "Annotations" msgstr "Annotations" @@ -195,6 +272,10 @@ msgstr "Applying.." msgid "Apps" msgstr "Apps" +#: src/renderer/components/+workspaces/workspaces.tsx:61 +msgid "Are you sure you want remove workspace <0>{0}?" +msgstr "Are you sure you want remove workspace <0>{0}?" + #: src/renderer/components/+nodes/node-menu.tsx:41 msgid "Are you sure you want to drain <0>{nodeName}?" msgstr "Are you sure you want to drain <0>{nodeName}?" @@ -203,11 +284,15 @@ msgstr "Are you sure you want to drain <0>{nodeName}?" msgid "Arguments" msgstr "Arguments" +#: src/renderer/components/cluster-manager/clusters-menu.tsx:106 +msgid "Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button." +msgstr "Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button." + #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx:101 msgid "Auth App Role" msgstr "Auth App Role" -#: src/renderer/components/error-boundary/error-boundary.tsx:54 +#: src/renderer/components/error-boundary/error-boundary.tsx:53 #: src/renderer/components/wizard/wizard.tsx:130 msgid "Back" msgstr "Back" @@ -230,10 +315,10 @@ msgid "Bindings" msgstr "Bindings" #: src/renderer/components/error-boundary/error-boundary.tsx:37 -msgid "Build version" -msgstr "Build version" +#~ msgid "Build version" +#~ msgstr "Build version" -#: src/renderer/components/+workloads-pods/container-charts.tsx:74 +#: src/renderer/components/+workloads-pods/container-charts.tsx:75 #: src/renderer/components/+workloads-pods/pod-charts.tsx:100 msgid "Bytes consumed on this filesystem" msgstr "Bytes consumed on this filesystem" @@ -269,10 +354,10 @@ msgstr "CA Bundle" #: src/renderer/components/+cluster/cluster-metric-switchers.tsx:24 #: src/renderer/components/+cluster/cluster-pie-charts.tsx:140 -#: src/renderer/components/+nodes/node-details.tsx:63 -#: src/renderer/components/+nodes/node-details.tsx:74 -#: src/renderer/components/+nodes/node-details.tsx:79 -#: src/renderer/components/+nodes/nodes.tsx:113 +#: src/renderer/components/+nodes/node-details.tsx:62 +#: src/renderer/components/+nodes/node-details.tsx:73 +#: src/renderer/components/+nodes/node-details.tsx:78 +#: src/renderer/components/+nodes/nodes.tsx:120 #: src/renderer/components/+workloads-pods/pod-charts.tsx:11 #: src/renderer/components/+workloads-pods/pod-details-container.tsx:26 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:53 @@ -286,24 +371,25 @@ msgid "CPU capacity" msgstr "CPU capacity" #: src/renderer/components/+nodes/node-charts.tsx:26 -#: src/renderer/components/+workloads-pods/container-charts.tsx:26 +#: src/renderer/components/+workloads-pods/container-charts.tsx:27 msgid "CPU cores usage" msgstr "CPU cores usage" -#: src/renderer/components/+workloads-pods/container-charts.tsx:40 +#: src/renderer/components/+workloads-pods/container-charts.tsx:41 #: src/renderer/components/+workloads-pods/pod-charts.tsx:49 msgid "CPU limits" msgstr "CPU limits" #: src/renderer/components/+nodes/node-charts.tsx:33 -#: src/renderer/components/+workloads-pods/container-charts.tsx:33 +#: src/renderer/components/+workloads-pods/container-charts.tsx:34 msgid "CPU requests" msgstr "CPU requests" -#: src/renderer/components/+nodes/nodes.tsx:55 +#: src/renderer/components/+nodes/nodes.tsx:57 msgid "CPU:" msgstr "CPU:" +#: src/renderer/components/+workspaces/workspaces.tsx:119 #: src/renderer/components/confirm-dialog/confirm-dialog.tsx:44 #: src/renderer/components/dock/info-panel.tsx:97 #: src/renderer/components/wizard/wizard.tsx:130 @@ -316,13 +402,17 @@ msgstr "Cancel" #: src/renderer/components/+nodes/node-charts.tsx:39 #: src/renderer/components/+nodes/node-charts.tsx:63 #: src/renderer/components/+nodes/node-charts.tsx:97 -#: src/renderer/components/+nodes/node-details.tsx:73 +#: src/renderer/components/+nodes/node-details.tsx:72 #: src/renderer/components/+storage-volume-claims/volume-claim-disk-chart.tsx:31 #: src/renderer/components/+storage-volumes/volume-details.tsx:29 #: src/renderer/components/+storage-volumes/volumes.tsx:42 msgid "Capacity" msgstr "Capacity" +#: src/renderer/components/+preferences/preferences.tsx:163 +msgid "Certificate Trust" +msgstr "Certificate Trust" + #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificates.tsx:59 msgid "Certificates" msgstr "Certificates" @@ -386,6 +476,10 @@ msgstr "Cluster IP" msgid "Cluster Issuers" msgstr "Cluster Issuers" +#: src/renderer/components/+preferences/preferences.tsx:134 +msgid "Color Theme" +msgstr "Color Theme" + #: src/renderer/components/+workloads-pods/pod-details-container.tsx:79 msgid "Command" msgstr "Command" @@ -404,7 +498,7 @@ msgstr "Compact view" msgid "Completions" msgstr "Completions" -#: src/renderer/components/error-boundary/error-boundary.tsx:46 +#: src/renderer/components/error-boundary/error-boundary.tsx:45 msgid "Component stack" msgstr "Component stack" @@ -413,8 +507,8 @@ msgid "Condition" msgstr "Condition" #: src/renderer/components/+custom-resources/crd-details.tsx:52 -#: src/renderer/components/+nodes/node-details.tsx:108 -#: src/renderer/components/+nodes/nodes.tsx:120 +#: src/renderer/components/+nodes/node-details.tsx:107 +#: src/renderer/components/+nodes/nodes.tsx:127 #: src/renderer/components/+workloads-deployments/deployment-details.tsx:79 #: src/renderer/components/+workloads-deployments/deployments.tsx:64 #: src/renderer/components/+workloads-jobs/job-details.tsx:77 @@ -471,13 +565,13 @@ msgstr "Container memory requests" msgid "Container memory usage" msgstr "Container memory usage" -#: src/renderer/components/+nodes/node-details.tsx:96 +#: src/renderer/components/+nodes/node-details.tsx:95 msgid "Container runtime" msgstr "Container runtime" #: src/renderer/components/+workloads-pods/pod-details.tsx:122 #: src/renderer/components/+workloads-pods/pod-logs-dialog.tsx:186 -#: src/renderer/components/+workloads-pods/pods.tsx:76 +#: src/renderer/components/+workloads-pods/pods.tsx:77 msgid "Containers" msgstr "Containers" @@ -485,7 +579,7 @@ msgstr "Containers" msgid "Context" msgstr "Context" -#: src/renderer/components/+workloads-pods/pods.tsx:78 +#: src/renderer/components/+workloads-pods/pods.tsx:79 #: src/renderer/components/kube-object/kube-object-meta.tsx:39 msgid "Controlled By" msgstr "Controlled By" @@ -585,7 +679,7 @@ msgid "Cron Jobs" msgstr "Cron Jobs" #: src/renderer/components/+workloads/workloads.tsx:77 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:69 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:67 msgid "CronJobs" msgstr "CronJobs" @@ -601,11 +695,19 @@ msgstr "Current replica scale: {currentReplicas}" msgid "Currently applied filters:" msgstr "Currently applied filters:" +#: src/renderer/components/+add-cluster/add-cluster.tsx:36 +#~ msgid "Custom" +#~ msgstr "Custom" + #: src/renderer/components/+custom-resources/crd-list.tsx:55 #: src/renderer/components/layout/sidebar.tsx:89 msgid "Custom Resources" msgstr "Custom Resources" +#: src/renderer/components/+add-cluster/add-cluster.tsx:115 +msgid "Custom.." +msgstr "Custom.." + #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:95 msgid "DNS Provider" msgstr "DNS Provider" @@ -619,10 +721,14 @@ msgid "Daemon Sets" msgstr "Daemon Sets" #: src/renderer/components/+workloads/workloads.tsx:53 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:59 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:57 msgid "DaemonSets" msgstr "DaemonSets" +#: src/renderer/theme.store.ts:32 +#~ msgid "Dark" +#~ msgstr "Dark" + #: src/renderer/components/+config-maps/config-map-details.tsx:69 #: src/renderer/components/+config-secrets/secret-details.tsx:78 msgid "Data" @@ -644,6 +750,7 @@ msgstr "Default Runtime Class Name" msgid "Definitions" msgstr "Definitions" +#: src/renderer/components/+workspaces/workspaces.tsx:113 #: src/renderer/components/menu/menu-actions.tsx:84 msgid "Delete" msgstr "Delete" @@ -654,11 +761,12 @@ msgstr "Deploy Revisions" #: src/renderer/components/+workloads/workloads.tsx:45 #: src/renderer/components/+workloads-deployments/deployments.tsx:57 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:49 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:47 msgid "Deployments" msgstr "Deployments" #: src/renderer/components/+apps-helm-charts/helm-charts.tsx:65 +#: src/renderer/components/+workspaces/workspaces.tsx:118 msgid "Description" msgstr "Description" @@ -666,24 +774,40 @@ msgstr "Description" msgid "Desired number of replicas" msgstr "Desired number of replicas" -#: src/renderer/components/+nodes/node-details.tsx:65 -#: src/renderer/components/+nodes/nodes.tsx:115 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:64 +msgid "Disconnect" +msgstr "Disconnect" + +#: src/renderer/components/+nodes/node-details.tsx:64 +#: src/renderer/components/+nodes/nodes.tsx:122 #: src/renderer/components/+storage-volume-claims/volume-claim-details.tsx:44 msgid "Disk" msgstr "Disk" -#: src/renderer/components/+nodes/nodes.tsx:71 +#: src/renderer/components/+nodes/nodes.tsx:79 msgid "Disk:" msgstr "Disk:" +#: src/renderer/components/+preferences/preferences.tsx:168 +msgid "Does not affect cluster communications!" +msgstr "Does not affect cluster communications!" + #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:89 msgid "Domains" msgstr "Domains" +#: src/renderer/components/+preferences/preferences.tsx:137 +msgid "Download Mirror" +msgstr "Download Mirror" + #: src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx:91 msgid "Download file" msgstr "Download file" +#: src/renderer/components/+preferences/preferences.tsx:138 +msgid "Download mirror for kubectl" +msgstr "Download mirror for kubectl" + #: src/renderer/components/+nodes/node-menu.tsx:59 #: src/renderer/components/+nodes/node-menu.tsx:60 msgid "Drain" @@ -706,6 +830,7 @@ msgstr "Duration" msgid "E-mail" msgstr "E-mail" +#: src/renderer/components/+workspaces/workspaces.tsx:112 #: src/renderer/components/menu/menu-actions.tsx:80 #: src/renderer/components/menu/menu-actions.tsx:81 msgid "Edit" @@ -743,7 +868,7 @@ msgstr "Enter a name" msgid "Environment" msgstr "Environment" -#: src/renderer/components/error-boundary/error-boundary.tsx:50 +#: src/renderer/components/error-boundary/error-boundary.tsx:49 msgid "Error stack" msgstr "Error stack" @@ -768,8 +893,8 @@ msgid "Exit full size mode" msgstr "Exit full size mode" #: src/renderer/components/layout/sidebar.tsx:76 -msgid "Extended view" -msgstr "Extended view" +#~ msgid "Extended view" +#~ msgstr "Extended view" #: src/renderer/components/+network-services/services.tsx:49 msgid "External IP" @@ -828,6 +953,14 @@ msgstr "From <0>{from} to <1>{to}" msgid "Fs Group" msgstr "Fs Group" +#: src/renderer/components/+landing-page/landing-page.tsx:23 +msgid "Get started by associating one or more clusters to Lens." +msgstr "Get started by associating one or more clusters to Lens." + +#: src/renderer/components/+preferences/preferences.tsx:39 +#~ msgid "Global Lens Settings page" +#~ msgstr "Global Lens Settings page" + #: src/renderer/components/+custom-resources/crd-details.tsx:32 #: src/renderer/components/+custom-resources/crd-list.tsx:58 #: src/renderer/components/+custom-resources/crd-list.tsx:74 @@ -842,6 +975,18 @@ msgstr "Groups" msgid "HPA" msgstr "HPA" +#: src/renderer/components/+preferences/preferences.tsx:157 +msgid "HTTP Proxy" +msgstr "HTTP Proxy" + +#: src/renderer/components/+add-cluster/add-cluster.tsx:178 +#~ msgid "HTTP Proxy server. Used for communicating with Kubernetes API." +#~ msgstr "HTTP Proxy server. Used for communicating with Kubernetes API." + +#: src/renderer/components/+preferences/preferences.tsx:140 +msgid "Helm" +msgstr "Helm" + #: src/renderer/components/dock/install-chart.tsx:113 msgid "Helm Chart Install" msgstr "Helm Chart Install" @@ -850,10 +995,18 @@ msgstr "Helm Chart Install" msgid "Helm Install: {repo}/{name}" msgstr "Helm Install: {repo}/{name}" +#: src/renderer/components/+preferences/preferences.tsx:61 +#~ msgid "Helm Repository <0>{0} already in use." +#~ msgstr "Helm Repository <0>{0} already in use." + #: src/renderer/components/dock/upgrade-chart.store.ts:114 msgid "Helm Upgrade: {0}" msgstr "Helm Upgrade: {0}" +#: src/renderer/components/+preferences/preferences.tsx:47 +msgid "Helm branch <0>{0} already in use" +msgstr "Helm branch <0>{0} already in use" + #: src/renderer/components/+config-secrets/secret-details.tsx:93 #: src/renderer/components/+workloads-pods/pod-logs-dialog.tsx:215 #: src/renderer/components/drawer/drawer-param-toggler.tsx:19 @@ -993,11 +1146,11 @@ msgstr "JSON Path" #: src/renderer/components/+workloads/workloads.tsx:69 #: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:62 #: src/renderer/components/+workloads-jobs/jobs.tsx:36 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:64 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:62 msgid "Jobs" msgstr "Jobs" -#: src/renderer/components/+nodes/node-details.tsx:93 +#: src/renderer/components/+nodes/node-details.tsx:92 msgid "Kernel version" msgstr "Kernel version" @@ -1037,14 +1190,14 @@ msgstr "Kubeconfig" msgid "Kubeconfig File" msgstr "Kubeconfig File" -#: src/renderer/components/+nodes/node-details.tsx:99 +#: src/renderer/components/+nodes/node-details.tsx:98 msgid "Kubelet version" msgstr "Kubelet version" #: src/renderer/components/+config-secrets/secrets.tsx:43 #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuers.tsx:65 #: src/renderer/components/+namespaces/namespaces.tsx:32 -#: src/renderer/components/+nodes/node-details.tsx:102 +#: src/renderer/components/+nodes/node-details.tsx:101 #: src/renderer/components/kube-object/kube-object-meta.tsx:35 msgid "Labels" msgstr "Labels" @@ -1069,18 +1222,28 @@ msgstr "Last seen" msgid "Last transition time: {lastTransitionTime}" msgstr "Last transition time: {lastTransitionTime}" +#: src/renderer/components/+preferences/preferences.tsx:126 +msgid "Lens Global Settings" +msgstr "Lens Global Settings" + #: src/renderer/components/+pod-security-policies/pod-security-policy-details.tsx:146 msgid "Level" msgstr "Level" +#: src/renderer/theme.store.ts:33 +#~ msgid "Light" +#~ msgstr "Light" + #: src/renderer/components/+events/events.tsx:59 msgid "Limited to {0}" msgstr "Limited to {0}" #: src/renderer/components/+cluster/cluster-pie-charts.tsx:72 #: src/renderer/components/+cluster/cluster-pie-charts.tsx:115 -#: src/renderer/components/+workloads-pods/container-charts.tsx:39 -#: src/renderer/components/+workloads-pods/container-charts.tsx:63 + +#: src/renderer/components/+workloads-pods/container-charts.tsx:40 +#: src/renderer/components/+workloads-pods/container-charts.tsx:64 + #: src/renderer/components/+workloads-pods/pod-charts.tsx:48 #: src/renderer/components/+workloads-pods/pod-charts.tsx:72 msgid "Limits" @@ -1150,10 +1313,10 @@ msgstr "Medium" #: src/renderer/components/+cluster/cluster-metric-switchers.tsx:25 #: src/renderer/components/+cluster/cluster-pie-charts.tsx:144 -#: src/renderer/components/+nodes/node-details.tsx:64 -#: src/renderer/components/+nodes/node-details.tsx:75 -#: src/renderer/components/+nodes/node-details.tsx:80 -#: src/renderer/components/+nodes/nodes.tsx:114 +#: src/renderer/components/+nodes/node-details.tsx:63 +#: src/renderer/components/+nodes/node-details.tsx:74 +#: src/renderer/components/+nodes/node-details.tsx:79 +#: src/renderer/components/+nodes/nodes.tsx:121 #: src/renderer/components/+workloads-pods/pod-charts.tsx:12 #: src/renderer/components/+workloads-pods/pod-details-container.tsx:27 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:63 @@ -1166,21 +1329,21 @@ msgstr "Memory" msgid "Memory capacity" msgstr "Memory capacity" -#: src/renderer/components/+workloads-pods/container-charts.tsx:64 +#: src/renderer/components/+workloads-pods/container-charts.tsx:65 msgid "Memory limits" msgstr "Memory limits" #: src/renderer/components/+nodes/node-charts.tsx:57 -#: src/renderer/components/+workloads-pods/container-charts.tsx:57 +#: src/renderer/components/+workloads-pods/container-charts.tsx:58 msgid "Memory requests" msgstr "Memory requests" #: src/renderer/components/+nodes/node-charts.tsx:50 -#: src/renderer/components/+workloads-pods/container-charts.tsx:50 +#: src/renderer/components/+workloads-pods/container-charts.tsx:51 msgid "Memory usage" msgstr "Memory usage" -#: src/renderer/components/+nodes/nodes.tsx:63 +#: src/renderer/components/+nodes/nodes.tsx:68 msgid "Memory:" msgstr "Memory:" @@ -1228,6 +1391,10 @@ msgstr "Mountable secrets" msgid "Mounts" msgstr "Mounts" +#: src/renderer/components/+workspaces/workspaces.tsx:36 +#~ msgid "My Workspace" +#~ msgstr "My Workspace" + #: src/renderer/components/+apps-helm-charts/helm-charts.tsx:64 #: src/renderer/components/+apps-releases/releases.tsx:87 #: src/renderer/components/+config-autoscalers/hpa-details.tsx:49 @@ -1249,7 +1416,7 @@ msgstr "Mounts" #: src/renderer/components/+network-policies/network-policies.tsx:31 #: src/renderer/components/+network-services/service-details-endpoint.tsx:26 #: src/renderer/components/+network-services/services.tsx:44 -#: src/renderer/components/+nodes/nodes.tsx:112 +#: src/renderer/components/+nodes/nodes.tsx:119 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:35 #: src/renderer/components/+storage-classes/storage-classes.tsx:34 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:46 @@ -1267,9 +1434,10 @@ msgstr "Mounts" #: src/renderer/components/+workloads-jobs/jobs.tsx:37 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:92 #: src/renderer/components/+workloads-pods/pod-details.tsx:144 -#: src/renderer/components/+workloads-pods/pods.tsx:73 +#: src/renderer/components/+workloads-pods/pods.tsx:74 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:50 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:40 +#: src/renderer/components/+workspaces/workspaces.tsx:117 #: src/renderer/components/dock/edit-resource.tsx:90 #: src/renderer/components/kube-object/kube-object-meta.tsx:20 msgid "Name" @@ -1313,7 +1481,7 @@ msgstr "Names" #: src/renderer/components/+workloads-daemonsets/daemonsets.tsx:46 #: src/renderer/components/+workloads-deployments/deployments.tsx:59 #: src/renderer/components/+workloads-jobs/jobs.tsx:38 -#: src/renderer/components/+workloads-pods/pods.tsx:75 +#: src/renderer/components/+workloads-pods/pods.tsx:76 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41 #: src/renderer/components/dock/edit-resource.tsx:91 #: src/renderer/components/dock/install-chart.tsx:122 @@ -1336,7 +1504,11 @@ msgstr "Namespaces" msgid "Namespaces: {0}" msgstr "Namespaces: {0}" -#: src/renderer/components/+network-ingresses/ingress-details.tsx:86 +#: src/renderer/components/+preferences/preferences.tsx:167 +msgid "Needed with some corporate proxies that do certificate re-writing." +msgstr "Needed with some corporate proxies that do certificate re-writing." + +#: src/renderer/components/+network-ingresses/ingress-details.tsx:66 #: src/renderer/components/+workloads-pods/pod-charts.tsx:13 #: src/renderer/components/layout/sidebar.tsx:83 msgid "Network" @@ -1371,6 +1543,7 @@ msgstr "New tab" msgid "Next" msgstr "Next" +#: src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx:29 #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:44 #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx:71 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:42 @@ -1435,7 +1608,7 @@ msgstr "Node filesystem usage in bytes" msgid "Node shell" msgstr "Node shell" -#: src/renderer/components/+nodes/nodes.tsx:111 +#: src/renderer/components/+nodes/nodes.tsx:118 #: src/renderer/components/layout/sidebar.tsx:80 msgid "Nodes" msgstr "Nodes" @@ -1460,11 +1633,11 @@ msgstr "Notes" msgid "Number of running Pods" msgstr "Number of running Pods" -#: src/renderer/components/+nodes/node-details.tsx:87 +#: src/renderer/components/+nodes/node-details.tsx:86 msgid "OS" msgstr "OS" -#: src/renderer/components/+nodes/node-details.tsx:90 +#: src/renderer/components/+nodes/node-details.tsx:89 msgid "OS Image" msgstr "OS Image" @@ -1476,6 +1649,10 @@ msgstr "Object" msgid "Ok" msgstr "Ok" +#: src/renderer/components/+whats-new/whats-new.tsx:35 +msgid "Ok, got it!" +msgstr "Ok, got it!" + #: src/renderer/components/dock/dock.tsx:117 msgid "Open" msgstr "Open" @@ -1496,7 +1673,7 @@ msgid "Organization" msgstr "Organization" #: src/renderer/components/+workloads/workloads.tsx:29 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:37 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:35 msgid "Overview" msgstr "Overview" @@ -1522,16 +1699,20 @@ msgstr "Path" msgid "Path Prefix" msgstr "Path Prefix" -#: src/renderer/components/+storage/storage.tsx:26 +#: src/renderer/components/+storage/storage.tsx:25 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:45 msgid "Persistent Volume Claims" msgstr "Persistent Volume Claims" -#: src/renderer/components/+storage/storage.tsx:33 +#: src/renderer/components/+storage/storage.tsx:32 #: src/renderer/components/+storage-volumes/volumes.tsx:39 msgid "Persistent Volumes" msgstr "Persistent Volumes" +#: src/renderer/components/+add-cluster/add-cluster.tsx:62 +msgid "Please select kubeconfig" +msgstr "Please select kubeconfig" + #: src/renderer/components/+workloads-pods/pod-menu.tsx:50 msgid "Pod" msgstr "Pod" @@ -1541,7 +1722,7 @@ msgid "Pod IP" msgstr "Pod IP" #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:34 -#: src/renderer/components/+user-management/user-management.tsx:44 +#: src/renderer/components/+user-management/user-management.tsx:43 msgid "Pod Security Policies" msgstr "Pod Security Policies" @@ -1561,17 +1742,17 @@ msgid "Pod shell" msgstr "Pod shell" #: src/renderer/components/+cluster/cluster-pie-charts.tsx:148 -#: src/renderer/components/+nodes/node-details.tsx:66 -#: src/renderer/components/+nodes/node-details.tsx:76 -#: src/renderer/components/+nodes/node-details.tsx:81 +#: src/renderer/components/+nodes/node-details.tsx:65 +#: src/renderer/components/+nodes/node-details.tsx:75 +#: src/renderer/components/+nodes/node-details.tsx:80 #: src/renderer/components/+storage-volume-claims/volume-claim-details.tsx:60 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:50 #: src/renderer/components/+workloads/workloads.tsx:37 #: src/renderer/components/+workloads-daemonsets/daemonsets.tsx:47 #: src/renderer/components/+workloads-deployments/deployments.tsx:60 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:44 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:42 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:89 -#: src/renderer/components/+workloads-pods/pods.tsx:72 +#: src/renderer/components/+workloads-pods/pods.tsx:73 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:52 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:42 msgid "Pods" @@ -1595,6 +1776,10 @@ msgstr "Port" msgid "Ports" msgstr "Ports" +#: src/renderer/components/+preferences/preferences.tsx:121 +msgid "Preferences" +msgstr "Preferences" + #: src/renderer/components/+workloads-pods/pod-details.tsx:93 msgid "Priority Class" msgstr "Priority Class" @@ -1613,7 +1798,15 @@ msgstr "Privileged" msgid "Provisioner" msgstr "Provisioner" -#: src/renderer/components/+workloads-pods/pods.tsx:79 +#: src/renderer/components/+preferences/preferences.tsx:160 +msgid "Proxy is used only for non-cluster communication." +msgstr "Proxy is used only for non-cluster communication." + +#: src/renderer/components/+add-cluster/add-cluster.tsx:175 +msgid "Proxy settings" +msgstr "Proxy settings" + +#: src/renderer/components/+workloads-pods/pods.tsx:80 msgid "QoS" msgstr "QoS" @@ -1659,6 +1852,10 @@ msgstr "Receive" msgid "Reclaim Policy" msgstr "Reclaim Policy" +#: src/renderer/components/cluster-manager/cluster-status.tsx:52 +#~ msgid "Reconnect" +#~ msgstr "Reconnect" + #: src/renderer/components/+config-autoscalers/hpa-details.tsx:70 #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:75 msgid "Reference" @@ -1685,7 +1882,10 @@ msgstr "Release: {0}" msgid "Releases" msgstr "Releases" +#: src/renderer/components/+preferences/preferences.tsx:151 #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:60 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:74 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:80 #: src/renderer/components/item-object-list/item-list-layout.tsx:179 #: src/renderer/components/menu/menu-actions.tsx:49 #: src/renderer/components/menu/menu-actions.tsx:85 @@ -1696,6 +1896,10 @@ msgstr "Remove" msgid "Remove <0>{releaseNames}?" msgstr "Remove <0>{releaseNames}?" +#: src/renderer/components/+workspaces/workspaces.tsx:51 +msgid "Remove Workspace" +msgstr "Remove Workspace" + #: src/renderer/components/+config-secrets/add-secret-dialog.tsx:133 msgid "Remove field" msgstr "Remove field" @@ -1720,6 +1924,14 @@ msgstr "Remove selected items ({0})" msgid "Remove {resourceKind} <0>{resourceName}?" msgstr "Remove {resourceKind} <0>{resourceName}?" +#: src/renderer/components/+preferences/preferences.tsx:114 +msgid "Removing helm branch <0>{0} has failed: {1}" +msgstr "Removing helm branch <0>{0} has failed: {1}" + +#: src/renderer/components/+preferences/preferences.tsx:119 +#~ msgid "Removing repo <0>{0} has failed: {1}" +#~ msgstr "Removing repo <0>{0} has failed: {1}" + #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:62 msgid "Renew Before" msgstr "Renew Before" @@ -1736,6 +1948,10 @@ msgstr "Replicas" msgid "Repo/Name" msgstr "Repo/Name" +#: src/renderer/components/+preferences/preferences.tsx:141 +msgid "Repositories" +msgstr "Repositories" + #: src/renderer/components/+apps-helm-charts/helm-charts.tsx:68 msgid "Repository" msgstr "Repository" @@ -1752,8 +1968,8 @@ msgstr "Request duration in seconds" #: src/renderer/components/+cluster/cluster-pie-charts.tsx:114 #: src/renderer/components/+nodes/node-charts.tsx:32 #: src/renderer/components/+nodes/node-charts.tsx:56 -#: src/renderer/components/+workloads-pods/container-charts.tsx:32 -#: src/renderer/components/+workloads-pods/container-charts.tsx:56 +#: src/renderer/components/+workloads-pods/container-charts.tsx:33 +#: src/renderer/components/+workloads-pods/container-charts.tsx:57 #: src/renderer/components/+workloads-pods/pod-charts.tsx:41 #: src/renderer/components/+workloads-pods/pod-charts.tsx:65 msgid "Requests" @@ -1823,7 +2039,7 @@ msgstr "Response duration in seconds" msgid "Restart session" msgstr "Restart session" -#: src/renderer/components/+workloads-pods/pods.tsx:77 +#: src/renderer/components/+workloads-pods/pods.tsx:78 msgid "Restarts" msgstr "Restarts" @@ -1841,7 +2057,7 @@ msgstr "Right click cluster icon to open cluster settings." msgid "Role" msgstr "Role" -#: src/renderer/components/+user-management/user-management.tsx:32 +#: src/renderer/components/+user-management/user-management.tsx:31 #: src/renderer/components/+user-management-roles-bindings/role-bindings.tsx:34 msgid "Role Bindings" msgstr "Role Bindings" @@ -1854,8 +2070,8 @@ msgstr "Role ID" msgid "Role name" msgstr "Role name" -#: src/renderer/components/+nodes/nodes.tsx:117 -#: src/renderer/components/+user-management/user-management.tsx:37 +#: src/renderer/components/+nodes/nodes.tsx:124 +#: src/renderer/components/+user-management/user-management.tsx:36 #: src/renderer/components/+user-management-roles/roles.tsx:32 msgid "Roles" msgstr "Roles" @@ -1897,6 +2113,7 @@ msgstr "Runtime Class" #: src/renderer/components/+config-maps/config-map-details.tsx:78 #: src/renderer/components/+config-secrets/secret-details.tsx:97 #: src/renderer/components/+workloads-pods/pod-logs-dialog.tsx:216 +#: src/renderer/components/+workspaces/workspaces.tsx:120 #: src/renderer/components/dock/edit-resource.tsx:88 msgid "Save" msgstr "Save" @@ -1973,6 +2190,14 @@ msgstr "Secrets" msgid "Select a quota.." msgstr "Select a quota.." +#: src/renderer/components/+add-cluster/add-cluster.tsx:172 +msgid "Select kubeconfig" +msgstr "Select kubeconfig" + +#: src/renderer/components/+preferences/preferences.tsx:88 +#~ msgid "Select repository" +#~ msgstr "Select repository" + #: src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx:188 msgid "Select role.." msgstr "Select role.." @@ -2002,7 +2227,7 @@ msgstr "Server" msgid "Service" msgstr "Service" -#: src/renderer/components/+user-management/user-management.tsx:27 +#: src/renderer/components/+user-management/user-management.tsx:26 #: src/renderer/components/+user-management-service-accounts/service-accounts.tsx:35 msgid "Service Accounts" msgstr "Service Accounts" @@ -2024,6 +2249,10 @@ msgstr "Set" msgid "Set quota" msgstr "Set quota" +#: src/renderer/components/cluster-manager/clusters-menu.tsx:53 +msgid "Settings" +msgstr "Settings" + #: src/renderer/components/+nodes/node-menu.tsx:48 #: src/renderer/components/+workloads-pods/pod-menu.tsx:68 msgid "Shell" @@ -2072,7 +2301,7 @@ msgid "Stateful Sets" msgstr "Stateful Sets" #: src/renderer/components/+workloads/workloads.tsx:61 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:54 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:52 msgid "StatefulSets" msgstr "StatefulSets" @@ -2095,7 +2324,7 @@ msgstr "StatefulSets" #: src/renderer/components/+workloads-pods/pod-details-container.tsx:39 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:97 #: src/renderer/components/+workloads-pods/pod-details.tsx:82 -#: src/renderer/components/+workloads-pods/pods.tsx:81 +#: src/renderer/components/+workloads-pods/pods.tsx:82 msgid "Status" msgstr "Status" @@ -2117,7 +2346,7 @@ msgstr "Storage Class" msgid "Storage Class Name" msgstr "Storage Class Name" -#: src/renderer/components/+storage/storage.tsx:41 +#: src/renderer/components/+storage/storage.tsx:40 #: src/renderer/components/+storage-classes/storage-classes.tsx:33 msgid "Storage Classes" msgstr "Storage Classes" @@ -2165,12 +2394,20 @@ msgstr "Suspend" msgid "TLS" msgstr "TLS" -#: src/renderer/components/+nodes/node-details.tsx:104 -#: src/renderer/components/+nodes/nodes.tsx:116 +#: src/renderer/components/+nodes/node-details.tsx:103 +#: src/renderer/components/+nodes/nodes.tsx:123 msgid "Taints" msgstr "Taints" -#: src/renderer/components/dock/terminal.store.ts:29 +#: src/renderer/components/+preferences/preferences.tsx:171 +msgid "Telemetry & Usage Tracking" +msgstr "Telemetry & Usage Tracking" + +#: src/renderer/components/+preferences/preferences.tsx:174 +msgid "Telemetry & usage data is collected to continuously improve the Lens experience." +msgstr "Telemetry & usage data is collected to continuously improve the Lens experience." + +#: src/renderer/components/dock/terminal.store.ts:28 msgid "Terminal" msgstr "Terminal" @@ -2190,11 +2427,23 @@ msgstr "There are no logs available." msgid "This field is required" msgstr "This field is required" +#: src/renderer/components/input/input.validators.ts:39 +msgid "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics." +msgstr "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics." + +#: src/renderer/components/cluster-manager/clusters-menu.tsx:104 +msgid "This is the quick launch menu." +msgstr "This is the quick launch menu." + +#: src/renderer/components/+preferences/preferences.tsx:166 +msgid "This will make Lens to trust ANY certificate authority without any validations." +msgstr "This will make Lens to trust ANY certificate authority without any validations." + #: src/renderer/components/+network-policies/network-policy-details.tsx:59 msgid "To" msgstr "To" -#: src/renderer/components/error-boundary/error-boundary.tsx:40 +#: src/renderer/components/error-boundary/error-boundary.tsx:39 msgid "To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker." msgstr "To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker." @@ -2229,6 +2478,10 @@ msgstr "Transmit" msgid "Type" msgstr "Type" +#: src/renderer/components/+preferences/preferences.tsx:158 +msgid "Type HTTP proxy url (example: http://proxy.acme.org:8080)" +msgstr "Type HTTP proxy url (example: http://proxy.acme.org:8080)" + #: src/renderer/components/kube-object/kube-object-meta.tsx:26 msgid "UID" msgstr "UID" @@ -2273,9 +2526,9 @@ msgstr "Upgrade version" #: src/renderer/components/+nodes/node-charts.tsx:73 #: src/renderer/components/+nodes/node-charts.tsx:90 #: src/renderer/components/+storage-volume-claims/volume-claim-disk-chart.tsx:24 -#: src/renderer/components/+workloads-pods/container-charts.tsx:25 -#: src/renderer/components/+workloads-pods/container-charts.tsx:49 -#: src/renderer/components/+workloads-pods/container-charts.tsx:73 +#: src/renderer/components/+workloads-pods/container-charts.tsx:26 +#: src/renderer/components/+workloads-pods/container-charts.tsx:50 +#: src/renderer/components/+workloads-pods/container-charts.tsx:74 #: src/renderer/components/+workloads-pods/pod-charts.tsx:34 #: src/renderer/components/+workloads-pods/pod-charts.tsx:58 #: src/renderer/components/+workloads-pods/pod-charts.tsx:99 @@ -2321,7 +2574,7 @@ msgstr "Verbs" #: src/renderer/components/+apps-releases/releases.tsx:91 #: src/renderer/components/+custom-resources/crd-details.tsx:35 #: src/renderer/components/+custom-resources/crd-list.tsx:75 -#: src/renderer/components/+nodes/nodes.tsx:118 +#: src/renderer/components/+nodes/nodes.tsx:125 #: src/renderer/components/dock/install-chart.tsx:120 #: src/renderer/components/dock/upgrade-chart.tsx:99 msgid "Version" @@ -2357,6 +2610,14 @@ msgstr "Waiting services to be running" msgid "Warnings: {0}" msgstr "Warnings: {0}" +#: src/renderer/components/+landing-page/landing-page.tsx:20 +msgid "Welcome!" +msgstr "Welcome!" + +#: src/renderer/components/+workspaces/workspaces.tsx:79 +msgid "What is a Workspace?" +msgstr "What is a Workspace?" + #: src/renderer/components/+cluster/cluster-metric-switchers.tsx:19 msgid "Worker" msgstr "Worker" @@ -2365,6 +2626,15 @@ msgstr "Worker" msgid "Workloads" msgstr "Workloads" +#: src/renderer/components/+workspaces/workspace-menu.tsx:39 +#: src/renderer/components/+workspaces/workspaces.tsx:91 +msgid "Workspaces" +msgstr "Workspaces" + +#: src/renderer/components/+workspaces/workspaces.tsx:81 +msgid "Workspaces are used to organize number of clusters into logical groups." +msgstr "Workspaces are used to organize number of clusters into logical groups." + #: src/renderer/components/input/input.validators.ts:10 msgid "Wrong email format" msgstr "Wrong email format" @@ -2373,6 +2643,7 @@ msgstr "Wrong email format" msgid "Wrong url format" msgstr "Wrong url format" +#: src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx:28 #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:44 #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx:71 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:42 @@ -2402,7 +2673,11 @@ msgstr "ago" msgid "and <0>{tailCount} more" msgstr "and <0>{tailCount} more" -#: src/renderer/components/+nodes/nodes.tsx:55 +#: src/renderer/components/+preferences/preferences.tsx:126 +msgid "applicable to all clusters" +msgstr "applicable to all clusters" + +#: src/renderer/components/+nodes/nodes.tsx:57 msgid "cores:" msgstr "cores:" @@ -2423,6 +2698,10 @@ msgstr "listKind" msgid "never" msgstr "never" +#: src/renderer/components/cluster-manager/clusters-menu.tsx:119 +msgid "new" +msgstr "new" + #: src/renderer/components/+custom-resources/crd-details.tsx:64 msgid "plural" msgstr "plural" @@ -2471,7 +2750,7 @@ msgstr "{0} total, {1} available" msgid "{0} unavailable" msgstr "{0} unavailable" -#: src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx:134 +#: src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx:129 msgid "{accountName} kubeconfig" msgstr "{accountName} kubeconfig" diff --git a/locales/fi/messages.po b/locales/fi/messages.po index e4768400cc..81bf95e3e5 100644 --- a/locales/fi/messages.po +++ b/locales/fi/messages.po @@ -13,30 +13,54 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#: src/renderer/components/+workspaces/clusters-menu.tsx:38 +#~ msgid "'Disconnect'" +#~ msgstr "" + +#: src/renderer/components/+workspaces/clusters-menu.tsx:31 +#~ msgid "'Settings'" +#~ msgstr "" + #: src/renderer/components/+config-autoscalers/hpa-details.tsx:28 msgid "(as a percentage of request)" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:108 +msgid "(current)" +msgstr "" + #: src/renderer/components/+network-policies/network-policy-details.tsx:88 msgid "(empty) (Allowing the specific traffic to all pods in this namespace)" msgstr "" +#: src/renderer/components/+add-cluster/add-cluster.tsx:105 +#~ msgid "(new)" +#~ msgstr "" + #: src/renderer/components/item-object-list/item-list-layout.tsx:224 msgid "<0>Filtered: {itemsCount} / {allItemsCount}" msgstr "" #: src/renderer/browser-check.tsx:11 -msgid "<0>Your browser does not support all Lens features. Please consider using another browser." -msgstr "" +#~ msgid "<0>Your browser does not support all Lens features. Please consider using another browser." +#~ msgstr "" #: src/renderer/components/dock/create-resource.tsx:56 msgid "<0>{0} successfully created" msgstr "" +#: src/renderer/components/+add-cluster/add-cluster.tsx:176 +#~ msgid "A HTTP proxy server URL (format: http://
:)" +#~ msgstr "" + #: src/renderer/components/input/input.validators.ts:40 msgid "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics." msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:84 +msgid "A single workspaces contains a list of clusters and their full configuration." +msgstr "" + #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:80 msgid "API Group" msgstr "" @@ -59,6 +83,11 @@ msgstr "" msgid "Active" msgstr "" +#: src/renderer/components/+add-cluster/add-cluster.tsx:170 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:116 +msgid "Add Cluster" +msgstr "" + #: src/renderer/components/+namespaces/namespaces.tsx:43 msgid "Add Namespace" msgstr "" @@ -67,21 +96,53 @@ msgstr "" msgid "Add RoleBinding" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:125 +msgid "Add Workspace" +msgstr "" + #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:111 msgid "Add bindings to {name}" msgstr "" +#: src/renderer/components/+add-cluster/add-cluster.tsx:187 +#~ msgid "Add cluster" +#~ msgstr "" + +#: src/renderer/components/+add-cluster/add-cluster.tsx:191 +msgid "Add cluster(s)" +msgstr "" + +#: src/renderer/components/+workspaces/clusters-menu.tsx:58 +#~ msgid "Add clusters" +#~ msgstr "" + #: src/renderer/components/+config-secrets/add-secret-dialog.tsx:125 msgid "Add field" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:123 +#~ msgid "Added repos" +#~ msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:144 +msgid "Added repos:" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:103 +msgid "Adding helm branch <0>{0} has failed: {1}" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:108 +#~ msgid "Adding repo <0>{0} has failed: {1}" +#~ msgstr "" + #: src/renderer/components/+custom-resources/crd-details.tsx:78 msgid "Additional Printer Columns" msgstr "" #: src/renderer/components/+network-endpoints/endpoint-subset-list.tsx:29 #: src/renderer/components/+network-endpoints/endpoint-subset-list.tsx:60 -#: src/renderer/components/+nodes/node-details.tsx:84 +#: src/renderer/components/+nodes/node-details.tsx:83 msgid "Addresses" msgstr "" @@ -103,7 +164,7 @@ msgstr "" #: src/renderer/components/+network-ingresses/ingresses.tsx:35 #: src/renderer/components/+network-policies/network-policies.tsx:34 #: src/renderer/components/+network-services/services.tsx:51 -#: src/renderer/components/+nodes/nodes.tsx:119 +#: src/renderer/components/+nodes/nodes.tsx:126 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:38 #: src/renderer/components/+storage-classes/storage-classes.tsx:38 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:51 @@ -115,12 +176,20 @@ msgstr "" #: src/renderer/components/+workloads-daemonsets/daemonsets.tsx:50 #: src/renderer/components/+workloads-deployments/deployments.tsx:63 #: src/renderer/components/+workloads-jobs/jobs.tsx:41 -#: src/renderer/components/+workloads-pods/pods.tsx:80 +#: src/renderer/components/+workloads-pods/pods.tsx:81 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:53 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:44 msgid "Age" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:64 +msgid "All clusters within workspace will be cleared as well" +msgstr "" + +#: src/renderer/components/+workspaces/workspaces.tsx:64 +#~ msgid "All clusters within workspace will be cleared as well." +#~ msgstr "" + #: src/renderer/components/+custom-resources/crd-list.tsx:56 msgid "All groups" msgstr "" @@ -133,7 +202,7 @@ msgstr "" msgid "All namespaces" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:78 +#: src/renderer/components/+nodes/node-details.tsx:77 msgid "Allocatable" msgstr "" @@ -141,6 +210,14 @@ msgstr "" msgid "Allow Privilege Escalation" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:172 +msgid "Allow telemetry & usage tracking" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:164 +msgid "Allow untrusted Certificate Authorities" +msgstr "" + #: src/renderer/components/+pod-security-policies/pod-security-policy-details.tsx:51 msgid "Allowed CSI Drivers" msgstr "" @@ -169,7 +246,7 @@ msgstr "" msgid "Allowed Unsafe Sysctls" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:103 +#: src/renderer/components/+nodes/node-details.tsx:102 #: src/renderer/components/kube-object/kube-object-meta.tsx:36 msgid "Annotations" msgstr "" @@ -195,6 +272,10 @@ msgstr "" msgid "Apps" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:61 +msgid "Are you sure you want remove workspace <0>{0}?" +msgstr "" + #: src/renderer/components/+nodes/node-menu.tsx:41 msgid "Are you sure you want to drain <0>{nodeName}?" msgstr "" @@ -203,11 +284,15 @@ msgstr "" msgid "Arguments" msgstr "" +#: src/renderer/components/cluster-manager/clusters-menu.tsx:106 +msgid "Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button." +msgstr "" + #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx:101 msgid "Auth App Role" msgstr "" -#: src/renderer/components/error-boundary/error-boundary.tsx:54 +#: src/renderer/components/error-boundary/error-boundary.tsx:53 #: src/renderer/components/wizard/wizard.tsx:130 msgid "Back" msgstr "" @@ -230,10 +315,11 @@ msgid "Bindings" msgstr "" #: src/renderer/components/error-boundary/error-boundary.tsx:37 -msgid "Build version" -msgstr "" +#~ msgid "Build version" +#~ msgstr "" -#: src/renderer/components/+workloads-pods/container-charts.tsx:74 + +#: src/renderer/components/+workloads-pods/container-charts.tsx:75 #: src/renderer/components/+workloads-pods/pod-charts.tsx:100 msgid "Bytes consumed on this filesystem" msgstr "" @@ -269,10 +355,10 @@ msgstr "" #: src/renderer/components/+cluster/cluster-metric-switchers.tsx:24 #: src/renderer/components/+cluster/cluster-pie-charts.tsx:140 -#: src/renderer/components/+nodes/node-details.tsx:63 -#: src/renderer/components/+nodes/node-details.tsx:74 -#: src/renderer/components/+nodes/node-details.tsx:79 -#: src/renderer/components/+nodes/nodes.tsx:113 +#: src/renderer/components/+nodes/node-details.tsx:62 +#: src/renderer/components/+nodes/node-details.tsx:73 +#: src/renderer/components/+nodes/node-details.tsx:78 +#: src/renderer/components/+nodes/nodes.tsx:120 #: src/renderer/components/+workloads-pods/pod-charts.tsx:11 #: src/renderer/components/+workloads-pods/pod-details-container.tsx:26 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:53 @@ -286,24 +372,25 @@ msgid "CPU capacity" msgstr "" #: src/renderer/components/+nodes/node-charts.tsx:26 -#: src/renderer/components/+workloads-pods/container-charts.tsx:26 +#: src/renderer/components/+workloads-pods/container-charts.tsx:27 msgid "CPU cores usage" msgstr "" -#: src/renderer/components/+workloads-pods/container-charts.tsx:40 +#: src/renderer/components/+workloads-pods/container-charts.tsx:41 #: src/renderer/components/+workloads-pods/pod-charts.tsx:49 msgid "CPU limits" msgstr "" #: src/renderer/components/+nodes/node-charts.tsx:33 -#: src/renderer/components/+workloads-pods/container-charts.tsx:33 +#: src/renderer/components/+workloads-pods/container-charts.tsx:34 msgid "CPU requests" msgstr "" -#: src/renderer/components/+nodes/nodes.tsx:55 +#: src/renderer/components/+nodes/nodes.tsx:57 msgid "CPU:" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:119 #: src/renderer/components/confirm-dialog/confirm-dialog.tsx:44 #: src/renderer/components/dock/info-panel.tsx:97 #: src/renderer/components/wizard/wizard.tsx:130 @@ -316,13 +403,17 @@ msgstr "" #: src/renderer/components/+nodes/node-charts.tsx:39 #: src/renderer/components/+nodes/node-charts.tsx:63 #: src/renderer/components/+nodes/node-charts.tsx:97 -#: src/renderer/components/+nodes/node-details.tsx:73 +#: src/renderer/components/+nodes/node-details.tsx:72 #: src/renderer/components/+storage-volume-claims/volume-claim-disk-chart.tsx:31 #: src/renderer/components/+storage-volumes/volume-details.tsx:29 #: src/renderer/components/+storage-volumes/volumes.tsx:42 msgid "Capacity" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:163 +msgid "Certificate Trust" +msgstr "" + #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificates.tsx:59 msgid "Certificates" msgstr "" @@ -382,6 +473,10 @@ msgstr "" msgid "Cluster Issuers" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:134 +msgid "Color Theme" +msgstr "" + #: src/renderer/components/+workloads-pods/pod-details-container.tsx:79 msgid "Command" msgstr "" @@ -400,7 +495,7 @@ msgstr "" msgid "Completions" msgstr "" -#: src/renderer/components/error-boundary/error-boundary.tsx:46 +#: src/renderer/components/error-boundary/error-boundary.tsx:45 msgid "Component stack" msgstr "" @@ -409,8 +504,8 @@ msgid "Condition" msgstr "" #: src/renderer/components/+custom-resources/crd-details.tsx:52 -#: src/renderer/components/+nodes/node-details.tsx:108 -#: src/renderer/components/+nodes/nodes.tsx:120 +#: src/renderer/components/+nodes/node-details.tsx:107 +#: src/renderer/components/+nodes/nodes.tsx:127 #: src/renderer/components/+workloads-deployments/deployment-details.tsx:79 #: src/renderer/components/+workloads-deployments/deployments.tsx:64 #: src/renderer/components/+workloads-jobs/job-details.tsx:77 @@ -467,13 +562,13 @@ msgstr "" msgid "Container memory usage" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:96 +#: src/renderer/components/+nodes/node-details.tsx:95 msgid "Container runtime" msgstr "" #: src/renderer/components/+workloads-pods/pod-details.tsx:122 #: src/renderer/components/+workloads-pods/pod-logs-dialog.tsx:186 -#: src/renderer/components/+workloads-pods/pods.tsx:76 +#: src/renderer/components/+workloads-pods/pods.tsx:77 msgid "Containers" msgstr "" @@ -481,7 +576,7 @@ msgstr "" msgid "Context" msgstr "" -#: src/renderer/components/+workloads-pods/pods.tsx:78 +#: src/renderer/components/+workloads-pods/pods.tsx:79 #: src/renderer/components/kube-object/kube-object-meta.tsx:39 msgid "Controlled By" msgstr "" @@ -581,7 +676,7 @@ msgid "Cron Jobs" msgstr "" #: src/renderer/components/+workloads/workloads.tsx:77 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:69 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:67 msgid "CronJobs" msgstr "" @@ -597,11 +692,19 @@ msgstr "" msgid "Currently applied filters:" msgstr "" +#: src/renderer/components/+add-cluster/add-cluster.tsx:36 +#~ msgid "Custom" +#~ msgstr "" + #: src/renderer/components/+custom-resources/crd-list.tsx:55 #: src/renderer/components/layout/sidebar.tsx:89 msgid "Custom Resources" msgstr "" +#: src/renderer/components/+add-cluster/add-cluster.tsx:115 +msgid "Custom.." +msgstr "" + #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:95 msgid "DNS Provider" msgstr "" @@ -615,10 +718,14 @@ msgid "Daemon Sets" msgstr "" #: src/renderer/components/+workloads/workloads.tsx:53 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:59 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:57 msgid "DaemonSets" msgstr "" +#: src/renderer/theme.store.ts:32 +#~ msgid "Dark" +#~ msgstr "" + #: src/renderer/components/+config-maps/config-map-details.tsx:69 #: src/renderer/components/+config-secrets/secret-details.tsx:78 msgid "Data" @@ -640,6 +747,7 @@ msgstr "" msgid "Definitions" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:113 #: src/renderer/components/menu/menu-actions.tsx:84 msgid "Delete" msgstr "" @@ -650,11 +758,12 @@ msgstr "" #: src/renderer/components/+workloads/workloads.tsx:45 #: src/renderer/components/+workloads-deployments/deployments.tsx:57 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:49 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:47 msgid "Deployments" msgstr "" #: src/renderer/components/+apps-helm-charts/helm-charts.tsx:65 +#: src/renderer/components/+workspaces/workspaces.tsx:118 msgid "Description" msgstr "" @@ -662,24 +771,40 @@ msgstr "" msgid "Desired number of replicas" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:65 -#: src/renderer/components/+nodes/nodes.tsx:115 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:64 +msgid "Disconnect" +msgstr "" + +#: src/renderer/components/+nodes/node-details.tsx:64 +#: src/renderer/components/+nodes/nodes.tsx:122 #: src/renderer/components/+storage-volume-claims/volume-claim-details.tsx:44 msgid "Disk" msgstr "" -#: src/renderer/components/+nodes/nodes.tsx:71 +#: src/renderer/components/+nodes/nodes.tsx:79 msgid "Disk:" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:168 +msgid "Does not affect cluster communications!" +msgstr "" + #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:89 msgid "Domains" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:137 +msgid "Download Mirror" +msgstr "" + #: src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx:91 msgid "Download file" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:138 +msgid "Download mirror for kubectl" +msgstr "" + #: src/renderer/components/+nodes/node-menu.tsx:59 #: src/renderer/components/+nodes/node-menu.tsx:60 msgid "Drain" @@ -702,6 +827,7 @@ msgstr "" msgid "E-mail" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:112 #: src/renderer/components/menu/menu-actions.tsx:80 #: src/renderer/components/menu/menu-actions.tsx:81 msgid "Edit" @@ -739,7 +865,7 @@ msgstr "" msgid "Environment" msgstr "" -#: src/renderer/components/error-boundary/error-boundary.tsx:50 +#: src/renderer/components/error-boundary/error-boundary.tsx:49 msgid "Error stack" msgstr "" @@ -759,8 +885,8 @@ msgid "Exit full size mode" msgstr "" #: src/renderer/components/layout/sidebar.tsx:76 -msgid "Extended view" -msgstr "" +#~ msgid "Extended view" +#~ msgstr "" #: src/renderer/components/+network-services/services.tsx:49 msgid "External IP" @@ -819,6 +945,14 @@ msgstr "" msgid "Fs Group" msgstr "" +#: src/renderer/components/+landing-page/landing-page.tsx:23 +msgid "Get started by associating one or more clusters to Lens." +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:39 +#~ msgid "Global Lens Settings page" +#~ msgstr "" + #: src/renderer/components/+custom-resources/crd-details.tsx:32 #: src/renderer/components/+custom-resources/crd-list.tsx:58 #: src/renderer/components/+custom-resources/crd-list.tsx:74 @@ -833,6 +967,18 @@ msgstr "" msgid "HPA" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:157 +msgid "HTTP Proxy" +msgstr "" + +#: src/renderer/components/+add-cluster/add-cluster.tsx:178 +#~ msgid "HTTP Proxy server. Used for communicating with Kubernetes API." +#~ msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:140 +msgid "Helm" +msgstr "" + #: src/renderer/components/dock/install-chart.tsx:113 msgid "Helm Chart Install" msgstr "" @@ -841,10 +987,18 @@ msgstr "" msgid "Helm Install: {repo}/{name}" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:61 +#~ msgid "Helm Repository <0>{0} already in use." +#~ msgstr "" + #: src/renderer/components/dock/upgrade-chart.store.ts:114 msgid "Helm Upgrade: {0}" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:47 +msgid "Helm branch <0>{0} already in use" +msgstr "" + #: src/renderer/components/+config-secrets/secret-details.tsx:93 #: src/renderer/components/+workloads-pods/pod-logs-dialog.tsx:215 #: src/renderer/components/drawer/drawer-param-toggler.tsx:19 @@ -984,11 +1138,11 @@ msgstr "" #: src/renderer/components/+workloads/workloads.tsx:69 #: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:62 #: src/renderer/components/+workloads-jobs/jobs.tsx:36 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:64 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:62 msgid "Jobs" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:93 +#: src/renderer/components/+nodes/node-details.tsx:92 msgid "Kernel version" msgstr "" @@ -1028,14 +1182,14 @@ msgstr "" msgid "Kubeconfig File" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:99 +#: src/renderer/components/+nodes/node-details.tsx:98 msgid "Kubelet version" msgstr "" #: src/renderer/components/+config-secrets/secrets.tsx:43 #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuers.tsx:65 #: src/renderer/components/+namespaces/namespaces.tsx:32 -#: src/renderer/components/+nodes/node-details.tsx:102 +#: src/renderer/components/+nodes/node-details.tsx:101 #: src/renderer/components/kube-object/kube-object-meta.tsx:35 msgid "Labels" msgstr "" @@ -1060,18 +1214,26 @@ msgstr "" msgid "Last transition time: {lastTransitionTime}" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:126 +msgid "Lens Global Settings" +msgstr "" + #: src/renderer/components/+pod-security-policies/pod-security-policy-details.tsx:146 msgid "Level" msgstr "" +#: src/renderer/theme.store.ts:33 +#~ msgid "Light" +#~ msgstr "" + #: src/renderer/components/+events/events.tsx:59 msgid "Limited to {0}" msgstr "" #: src/renderer/components/+cluster/cluster-pie-charts.tsx:72 #: src/renderer/components/+cluster/cluster-pie-charts.tsx:115 -#: src/renderer/components/+workloads-pods/container-charts.tsx:39 -#: src/renderer/components/+workloads-pods/container-charts.tsx:63 +#: src/renderer/components/+workloads-pods/container-charts.tsx:40 +#: src/renderer/components/+workloads-pods/container-charts.tsx:64 #: src/renderer/components/+workloads-pods/pod-charts.tsx:48 #: src/renderer/components/+workloads-pods/pod-charts.tsx:72 msgid "Limits" @@ -1141,10 +1303,10 @@ msgstr "" #: src/renderer/components/+cluster/cluster-metric-switchers.tsx:25 #: src/renderer/components/+cluster/cluster-pie-charts.tsx:144 -#: src/renderer/components/+nodes/node-details.tsx:64 -#: src/renderer/components/+nodes/node-details.tsx:75 -#: src/renderer/components/+nodes/node-details.tsx:80 -#: src/renderer/components/+nodes/nodes.tsx:114 +#: src/renderer/components/+nodes/node-details.tsx:63 +#: src/renderer/components/+nodes/node-details.tsx:74 +#: src/renderer/components/+nodes/node-details.tsx:79 +#: src/renderer/components/+nodes/nodes.tsx:121 #: src/renderer/components/+workloads-pods/pod-charts.tsx:12 #: src/renderer/components/+workloads-pods/pod-details-container.tsx:27 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:63 @@ -1157,21 +1319,21 @@ msgstr "" msgid "Memory capacity" msgstr "" -#: src/renderer/components/+workloads-pods/container-charts.tsx:64 +#: src/renderer/components/+workloads-pods/container-charts.tsx:65 msgid "Memory limits" msgstr "" #: src/renderer/components/+nodes/node-charts.tsx:57 -#: src/renderer/components/+workloads-pods/container-charts.tsx:57 +#: src/renderer/components/+workloads-pods/container-charts.tsx:58 msgid "Memory requests" msgstr "" #: src/renderer/components/+nodes/node-charts.tsx:50 -#: src/renderer/components/+workloads-pods/container-charts.tsx:50 +#: src/renderer/components/+workloads-pods/container-charts.tsx:51 msgid "Memory usage" msgstr "" -#: src/renderer/components/+nodes/nodes.tsx:63 +#: src/renderer/components/+nodes/nodes.tsx:68 msgid "Memory:" msgstr "" @@ -1219,6 +1381,10 @@ msgstr "" msgid "Mounts" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:36 +#~ msgid "My Workspace" +#~ msgstr "" + #: src/renderer/components/+apps-helm-charts/helm-charts.tsx:64 #: src/renderer/components/+apps-releases/releases.tsx:87 #: src/renderer/components/+config-autoscalers/hpa-details.tsx:49 @@ -1240,7 +1406,7 @@ msgstr "" #: src/renderer/components/+network-policies/network-policies.tsx:31 #: src/renderer/components/+network-services/service-details-endpoint.tsx:26 #: src/renderer/components/+network-services/services.tsx:44 -#: src/renderer/components/+nodes/nodes.tsx:112 +#: src/renderer/components/+nodes/nodes.tsx:119 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:35 #: src/renderer/components/+storage-classes/storage-classes.tsx:34 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:46 @@ -1258,9 +1424,10 @@ msgstr "" #: src/renderer/components/+workloads-jobs/jobs.tsx:37 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:92 #: src/renderer/components/+workloads-pods/pod-details.tsx:144 -#: src/renderer/components/+workloads-pods/pods.tsx:73 +#: src/renderer/components/+workloads-pods/pods.tsx:74 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:50 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:40 +#: src/renderer/components/+workspaces/workspaces.tsx:117 #: src/renderer/components/dock/edit-resource.tsx:90 #: src/renderer/components/kube-object/kube-object-meta.tsx:20 msgid "Name" @@ -1304,7 +1471,7 @@ msgstr "" #: src/renderer/components/+workloads-daemonsets/daemonsets.tsx:46 #: src/renderer/components/+workloads-deployments/deployments.tsx:59 #: src/renderer/components/+workloads-jobs/jobs.tsx:38 -#: src/renderer/components/+workloads-pods/pods.tsx:75 +#: src/renderer/components/+workloads-pods/pods.tsx:76 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41 #: src/renderer/components/dock/edit-resource.tsx:91 #: src/renderer/components/dock/install-chart.tsx:122 @@ -1327,7 +1494,11 @@ msgstr "" msgid "Namespaces: {0}" msgstr "" -#: src/renderer/components/+network-ingresses/ingress-details.tsx:86 +#: src/renderer/components/+preferences/preferences.tsx:167 +msgid "Needed with some corporate proxies that do certificate re-writing." +msgstr "" + +#: src/renderer/components/+network-ingresses/ingress-details.tsx:66 #: src/renderer/components/+workloads-pods/pod-charts.tsx:13 #: src/renderer/components/layout/sidebar.tsx:83 msgid "Network" @@ -1354,6 +1525,7 @@ msgstr "" msgid "Next" msgstr "" +#: src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx:29 #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:44 #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx:71 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:42 @@ -1418,7 +1590,7 @@ msgstr "" msgid "Node shell" msgstr "" -#: src/renderer/components/+nodes/nodes.tsx:111 +#: src/renderer/components/+nodes/nodes.tsx:118 #: src/renderer/components/layout/sidebar.tsx:80 msgid "Nodes" msgstr "" @@ -1443,11 +1615,11 @@ msgstr "" msgid "Number of running Pods" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:87 +#: src/renderer/components/+nodes/node-details.tsx:86 msgid "OS" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:90 +#: src/renderer/components/+nodes/node-details.tsx:89 msgid "OS Image" msgstr "" @@ -1459,6 +1631,10 @@ msgstr "" msgid "Ok" msgstr "" +#: src/renderer/components/+whats-new/whats-new.tsx:35 +msgid "Ok, got it!" +msgstr "" + #: src/renderer/components/dock/dock.tsx:117 msgid "Open" msgstr "" @@ -1479,7 +1655,7 @@ msgid "Organization" msgstr "" #: src/renderer/components/+workloads/workloads.tsx:29 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:37 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:35 msgid "Overview" msgstr "" @@ -1505,16 +1681,20 @@ msgstr "" msgid "Path Prefix" msgstr "" -#: src/renderer/components/+storage/storage.tsx:26 +#: src/renderer/components/+storage/storage.tsx:25 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:45 msgid "Persistent Volume Claims" msgstr "" -#: src/renderer/components/+storage/storage.tsx:33 +#: src/renderer/components/+storage/storage.tsx:32 #: src/renderer/components/+storage-volumes/volumes.tsx:39 msgid "Persistent Volumes" msgstr "" +#: src/renderer/components/+add-cluster/add-cluster.tsx:62 +msgid "Please select kubeconfig" +msgstr "" + #: src/renderer/components/+workloads-pods/pod-menu.tsx:50 msgid "Pod" msgstr "" @@ -1524,7 +1704,7 @@ msgid "Pod IP" msgstr "" #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:34 -#: src/renderer/components/+user-management/user-management.tsx:44 +#: src/renderer/components/+user-management/user-management.tsx:43 msgid "Pod Security Policies" msgstr "" @@ -1544,17 +1724,17 @@ msgid "Pod shell" msgstr "" #: src/renderer/components/+cluster/cluster-pie-charts.tsx:148 -#: src/renderer/components/+nodes/node-details.tsx:66 -#: src/renderer/components/+nodes/node-details.tsx:76 -#: src/renderer/components/+nodes/node-details.tsx:81 +#: src/renderer/components/+nodes/node-details.tsx:65 +#: src/renderer/components/+nodes/node-details.tsx:75 +#: src/renderer/components/+nodes/node-details.tsx:80 #: src/renderer/components/+storage-volume-claims/volume-claim-details.tsx:60 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:50 #: src/renderer/components/+workloads/workloads.tsx:37 #: src/renderer/components/+workloads-daemonsets/daemonsets.tsx:47 #: src/renderer/components/+workloads-deployments/deployments.tsx:60 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:44 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:42 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:89 -#: src/renderer/components/+workloads-pods/pods.tsx:72 +#: src/renderer/components/+workloads-pods/pods.tsx:73 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:52 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:42 msgid "Pods" @@ -1578,6 +1758,10 @@ msgstr "" msgid "Ports" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:121 +msgid "Preferences" +msgstr "" + #: src/renderer/components/+workloads-pods/pod-details.tsx:93 msgid "Priority Class" msgstr "" @@ -1596,7 +1780,15 @@ msgstr "" msgid "Provisioner" msgstr "" -#: src/renderer/components/+workloads-pods/pods.tsx:79 +#: src/renderer/components/+preferences/preferences.tsx:160 +msgid "Proxy is used only for non-cluster communication." +msgstr "" + +#: src/renderer/components/+add-cluster/add-cluster.tsx:175 +msgid "Proxy settings" +msgstr "" + +#: src/renderer/components/+workloads-pods/pods.tsx:80 msgid "QoS" msgstr "" @@ -1642,6 +1834,10 @@ msgstr "" msgid "Reclaim Policy" msgstr "" +#: src/renderer/components/cluster-manager/cluster-status.tsx:52 +#~ msgid "Reconnect" +#~ msgstr "" + #: src/renderer/components/+config-autoscalers/hpa-details.tsx:70 #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:75 msgid "Reference" @@ -1668,7 +1864,10 @@ msgstr "" msgid "Releases" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:151 #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:60 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:74 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:80 #: src/renderer/components/item-object-list/item-list-layout.tsx:179 #: src/renderer/components/menu/menu-actions.tsx:49 #: src/renderer/components/menu/menu-actions.tsx:85 @@ -1679,6 +1878,10 @@ msgstr "" msgid "Remove <0>{releaseNames}?" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:51 +msgid "Remove Workspace" +msgstr "" + #: src/renderer/components/+config-secrets/add-secret-dialog.tsx:133 msgid "Remove field" msgstr "" @@ -1703,6 +1906,14 @@ msgstr "" msgid "Remove {resourceKind} <0>{resourceName}?" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:114 +msgid "Removing helm branch <0>{0} has failed: {1}" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:119 +#~ msgid "Removing repo <0>{0} has failed: {1}" +#~ msgstr "" + #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:62 msgid "Renew Before" msgstr "" @@ -1719,6 +1930,10 @@ msgstr "" msgid "Repo/Name" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:141 +msgid "Repositories" +msgstr "" + #: src/renderer/components/+apps-helm-charts/helm-charts.tsx:68 msgid "Repository" msgstr "" @@ -1735,8 +1950,8 @@ msgstr "" #: src/renderer/components/+cluster/cluster-pie-charts.tsx:114 #: src/renderer/components/+nodes/node-charts.tsx:32 #: src/renderer/components/+nodes/node-charts.tsx:56 -#: src/renderer/components/+workloads-pods/container-charts.tsx:32 -#: src/renderer/components/+workloads-pods/container-charts.tsx:56 +#: src/renderer/components/+workloads-pods/container-charts.tsx:33 +#: src/renderer/components/+workloads-pods/container-charts.tsx:57 #: src/renderer/components/+workloads-pods/pod-charts.tsx:41 #: src/renderer/components/+workloads-pods/pod-charts.tsx:65 msgid "Requests" @@ -1806,7 +2021,7 @@ msgstr "" msgid "Restart session" msgstr "" -#: src/renderer/components/+workloads-pods/pods.tsx:77 +#: src/renderer/components/+workloads-pods/pods.tsx:78 msgid "Restarts" msgstr "" @@ -1824,7 +2039,7 @@ msgstr "" msgid "Role" msgstr "" -#: src/renderer/components/+user-management/user-management.tsx:32 +#: src/renderer/components/+user-management/user-management.tsx:31 #: src/renderer/components/+user-management-roles-bindings/role-bindings.tsx:34 msgid "Role Bindings" msgstr "" @@ -1837,8 +2052,8 @@ msgstr "" msgid "Role name" msgstr "" -#: src/renderer/components/+nodes/nodes.tsx:117 -#: src/renderer/components/+user-management/user-management.tsx:37 +#: src/renderer/components/+nodes/nodes.tsx:124 +#: src/renderer/components/+user-management/user-management.tsx:36 #: src/renderer/components/+user-management-roles/roles.tsx:32 msgid "Roles" msgstr "" @@ -1880,6 +2095,7 @@ msgstr "" #: src/renderer/components/+config-maps/config-map-details.tsx:78 #: src/renderer/components/+config-secrets/secret-details.tsx:97 #: src/renderer/components/+workloads-pods/pod-logs-dialog.tsx:216 +#: src/renderer/components/+workspaces/workspaces.tsx:120 #: src/renderer/components/dock/edit-resource.tsx:88 msgid "Save" msgstr "" @@ -1956,6 +2172,14 @@ msgstr "" msgid "Select a quota.." msgstr "" +#: src/renderer/components/+add-cluster/add-cluster.tsx:172 +msgid "Select kubeconfig" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:88 +#~ msgid "Select repository" +#~ msgstr "" + #: src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx:188 msgid "Select role.." msgstr "" @@ -1985,7 +2209,7 @@ msgstr "" msgid "Service" msgstr "" -#: src/renderer/components/+user-management/user-management.tsx:27 +#: src/renderer/components/+user-management/user-management.tsx:26 #: src/renderer/components/+user-management-service-accounts/service-accounts.tsx:35 msgid "Service Accounts" msgstr "" @@ -2007,6 +2231,10 @@ msgstr "" msgid "Set quota" msgstr "" +#: src/renderer/components/cluster-manager/clusters-menu.tsx:53 +msgid "Settings" +msgstr "" + #: src/renderer/components/+nodes/node-menu.tsx:48 #: src/renderer/components/+workloads-pods/pod-menu.tsx:68 msgid "Shell" @@ -2055,7 +2283,7 @@ msgid "Stateful Sets" msgstr "" #: src/renderer/components/+workloads/workloads.tsx:61 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:54 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:52 msgid "StatefulSets" msgstr "" @@ -2078,7 +2306,7 @@ msgstr "" #: src/renderer/components/+workloads-pods/pod-details-container.tsx:39 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:97 #: src/renderer/components/+workloads-pods/pod-details.tsx:82 -#: src/renderer/components/+workloads-pods/pods.tsx:81 +#: src/renderer/components/+workloads-pods/pods.tsx:82 msgid "Status" msgstr "" @@ -2100,7 +2328,7 @@ msgstr "" msgid "Storage Class Name" msgstr "" -#: src/renderer/components/+storage/storage.tsx:41 +#: src/renderer/components/+storage/storage.tsx:40 #: src/renderer/components/+storage-classes/storage-classes.tsx:33 msgid "Storage Classes" msgstr "" @@ -2148,12 +2376,20 @@ msgstr "" msgid "TLS" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:104 -#: src/renderer/components/+nodes/nodes.tsx:116 +#: src/renderer/components/+nodes/node-details.tsx:103 +#: src/renderer/components/+nodes/nodes.tsx:123 msgid "Taints" msgstr "" -#: src/renderer/components/dock/terminal.store.ts:29 +#: src/renderer/components/+preferences/preferences.tsx:171 +msgid "Telemetry & Usage Tracking" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:174 +msgid "Telemetry & usage data is collected to continuously improve the Lens experience." +msgstr "" + +#: src/renderer/components/dock/terminal.store.ts:28 msgid "Terminal" msgstr "" @@ -2173,11 +2409,23 @@ msgstr "" msgid "This field is required" msgstr "" +#: src/renderer/components/input/input.validators.ts:39 +msgid "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics." +msgstr "" + +#: src/renderer/components/cluster-manager/clusters-menu.tsx:104 +msgid "This is the quick launch menu." +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:166 +msgid "This will make Lens to trust ANY certificate authority without any validations." +msgstr "" + #: src/renderer/components/+network-policies/network-policy-details.tsx:59 msgid "To" msgstr "" -#: src/renderer/components/error-boundary/error-boundary.tsx:40 +#: src/renderer/components/error-boundary/error-boundary.tsx:39 msgid "To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker." msgstr "" @@ -2212,6 +2460,10 @@ msgstr "" msgid "Type" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:158 +msgid "Type HTTP proxy url (example: http://proxy.acme.org:8080)" +msgstr "" + #: src/renderer/components/kube-object/kube-object-meta.tsx:26 msgid "UID" msgstr "" @@ -2256,9 +2508,9 @@ msgstr "" #: src/renderer/components/+nodes/node-charts.tsx:73 #: src/renderer/components/+nodes/node-charts.tsx:90 #: src/renderer/components/+storage-volume-claims/volume-claim-disk-chart.tsx:24 -#: src/renderer/components/+workloads-pods/container-charts.tsx:25 -#: src/renderer/components/+workloads-pods/container-charts.tsx:49 -#: src/renderer/components/+workloads-pods/container-charts.tsx:73 +#: src/renderer/components/+workloads-pods/container-charts.tsx:26 +#: src/renderer/components/+workloads-pods/container-charts.tsx:50 +#: src/renderer/components/+workloads-pods/container-charts.tsx:74 #: src/renderer/components/+workloads-pods/pod-charts.tsx:34 #: src/renderer/components/+workloads-pods/pod-charts.tsx:58 #: src/renderer/components/+workloads-pods/pod-charts.tsx:99 @@ -2304,7 +2556,7 @@ msgstr "" #: src/renderer/components/+apps-releases/releases.tsx:91 #: src/renderer/components/+custom-resources/crd-details.tsx:35 #: src/renderer/components/+custom-resources/crd-list.tsx:75 -#: src/renderer/components/+nodes/nodes.tsx:118 +#: src/renderer/components/+nodes/nodes.tsx:125 #: src/renderer/components/dock/install-chart.tsx:120 #: src/renderer/components/dock/upgrade-chart.tsx:99 msgid "Version" @@ -2340,6 +2592,14 @@ msgstr "" msgid "Warnings: {0}" msgstr "" +#: src/renderer/components/+landing-page/landing-page.tsx:20 +msgid "Welcome!" +msgstr "" + +#: src/renderer/components/+workspaces/workspaces.tsx:79 +msgid "What is a Workspace?" +msgstr "" + #: src/renderer/components/+cluster/cluster-metric-switchers.tsx:19 msgid "Worker" msgstr "" @@ -2348,6 +2608,15 @@ msgstr "" msgid "Workloads" msgstr "" +#: src/renderer/components/+workspaces/workspace-menu.tsx:39 +#: src/renderer/components/+workspaces/workspaces.tsx:91 +msgid "Workspaces" +msgstr "" + +#: src/renderer/components/+workspaces/workspaces.tsx:81 +msgid "Workspaces are used to organize number of clusters into logical groups." +msgstr "" + #: src/renderer/components/input/input.validators.ts:10 msgid "Wrong email format" msgstr "" @@ -2356,6 +2625,7 @@ msgstr "" msgid "Wrong url format" msgstr "" +#: src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx:28 #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:44 #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx:71 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:42 @@ -2385,7 +2655,11 @@ msgstr "" msgid "and <0>{tailCount} more" msgstr "" -#: src/renderer/components/+nodes/nodes.tsx:55 +#: src/renderer/components/+preferences/preferences.tsx:126 +msgid "applicable to all clusters" +msgstr "" + +#: src/renderer/components/+nodes/nodes.tsx:57 msgid "cores:" msgstr "" @@ -2406,6 +2680,10 @@ msgstr "" msgid "never" msgstr "" +#: src/renderer/components/cluster-manager/clusters-menu.tsx:119 +msgid "new" +msgstr "" + #: src/renderer/components/+custom-resources/crd-details.tsx:64 msgid "plural" msgstr "" @@ -2454,7 +2732,7 @@ msgstr "" msgid "{0} unavailable" msgstr "" -#: src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx:134 +#: src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx:129 msgid "{accountName} kubeconfig" msgstr "" diff --git a/locales/ru/messages.po b/locales/ru/messages.po index 4480209094..eecd9ca56d 100644 --- a/locales/ru/messages.po +++ b/locales/ru/messages.po @@ -14,30 +14,54 @@ msgstr "" "Plural-Forms: \n" "MIME-Version: 1.0\n" +#: src/renderer/components/+workspaces/clusters-menu.tsx:38 +#~ msgid "'Disconnect'" +#~ msgstr "" + +#: src/renderer/components/+workspaces/clusters-menu.tsx:31 +#~ msgid "'Settings'" +#~ msgstr "" + #: src/renderer/components/+config-autoscalers/hpa-details.tsx:28 msgid "(as a percentage of request)" msgstr "" +#: src/renderer/components/+workspaces/workspaces.tsx:108 +msgid "(current)" +msgstr "" + #: src/renderer/components/+network-policies/network-policy-details.tsx:88 msgid "(empty) (Allowing the specific traffic to all pods in this namespace)" msgstr "(Пусто) (Допускается трафик ко всем подам в данной области имен)" +#: src/renderer/components/+add-cluster/add-cluster.tsx:105 +#~ msgid "(new)" +#~ msgstr "" + #: src/renderer/components/item-object-list/item-list-layout.tsx:224 msgid "<0>Filtered: {itemsCount} / {allItemsCount}" msgstr "<0>Отфильтровано: {itemsCount} / {allItemsCount}" #: src/renderer/browser-check.tsx:11 -msgid "<0>Your browser does not support all Lens features. Please consider using another browser." -msgstr "<0>Ваш браузер не поддерживает все возможности Lens. Пожалуйста рассмотрите использование другого современного браузера." +#~ msgid "<0>Your browser does not support all Lens features. Please consider using another browser." +#~ msgstr "<0>Ваш браузер не поддерживает все возможности Lens. Пожалуйста рассмотрите использование другого современного браузера." #: src/renderer/components/dock/create-resource.tsx:56 msgid "<0>{0} successfully created" msgstr "" +#: src/renderer/components/+add-cluster/add-cluster.tsx:176 +#~ msgid "A HTTP proxy server URL (format: http://
:)" +#~ msgstr "" + #: src/renderer/components/input/input.validators.ts:40 msgid "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics." msgstr "Это поле может содержать только латинские буквы в нижнем регистре, номера и дефис." +#: src/renderer/components/+workspaces/workspaces.tsx:84 +msgid "A single workspaces contains a list of clusters and their full configuration." +msgstr "" + #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:80 msgid "API Group" msgstr "" @@ -60,6 +84,11 @@ msgstr "Название аккаунта" msgid "Active" msgstr "Активный" +#: src/renderer/components/+add-cluster/add-cluster.tsx:170 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:116 +msgid "Add Cluster" +msgstr "" + #: src/renderer/components/+namespaces/namespaces.tsx:43 msgid "Add Namespace" msgstr "Добавить Namespace" @@ -68,21 +97,53 @@ msgstr "Добавить Namespace" msgid "Add RoleBinding" msgstr "Добавить привязку ролей" +#: src/renderer/components/+workspaces/workspaces.tsx:125 +msgid "Add Workspace" +msgstr "" + #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:111 msgid "Add bindings to {name}" msgstr "Добавить привязки к {name}" +#: src/renderer/components/+add-cluster/add-cluster.tsx:187 +#~ msgid "Add cluster" +#~ msgstr "" + +#: src/renderer/components/+add-cluster/add-cluster.tsx:191 +msgid "Add cluster(s)" +msgstr "" + +#: src/renderer/components/+workspaces/clusters-menu.tsx:58 +#~ msgid "Add clusters" +#~ msgstr "" + #: src/renderer/components/+config-secrets/add-secret-dialog.tsx:125 msgid "Add field" msgstr "Добавить поле" +#: src/renderer/components/+preferences/preferences.tsx:123 +#~ msgid "Added repos" +#~ msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:144 +msgid "Added repos:" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:103 +msgid "Adding helm branch <0>{0} has failed: {1}" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:108 +#~ msgid "Adding repo <0>{0} has failed: {1}" +#~ msgstr "" + #: src/renderer/components/+custom-resources/crd-details.tsx:78 msgid "Additional Printer Columns" msgstr "" #: src/renderer/components/+network-endpoints/endpoint-subset-list.tsx:29 #: src/renderer/components/+network-endpoints/endpoint-subset-list.tsx:60 -#: src/renderer/components/+nodes/node-details.tsx:84 +#: src/renderer/components/+nodes/node-details.tsx:83 msgid "Addresses" msgstr "Адреса" @@ -104,7 +165,7 @@ msgstr "Аффинитеты" #: src/renderer/components/+network-ingresses/ingresses.tsx:35 #: src/renderer/components/+network-policies/network-policies.tsx:34 #: src/renderer/components/+network-services/services.tsx:51 -#: src/renderer/components/+nodes/nodes.tsx:119 +#: src/renderer/components/+nodes/nodes.tsx:126 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:38 #: src/renderer/components/+storage-classes/storage-classes.tsx:38 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:51 @@ -116,12 +177,20 @@ msgstr "Аффинитеты" #: src/renderer/components/+workloads-daemonsets/daemonsets.tsx:50 #: src/renderer/components/+workloads-deployments/deployments.tsx:63 #: src/renderer/components/+workloads-jobs/jobs.tsx:41 -#: src/renderer/components/+workloads-pods/pods.tsx:80 +#: src/renderer/components/+workloads-pods/pods.tsx:81 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:53 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:44 msgid "Age" msgstr "Возраст" +#: src/renderer/components/+workspaces/workspaces.tsx:64 +msgid "All clusters within workspace will be cleared as well" +msgstr "" + +#: src/renderer/components/+workspaces/workspaces.tsx:64 +#~ msgid "All clusters within workspace will be cleared as well." +#~ msgstr "" + #: src/renderer/components/+custom-resources/crd-list.tsx:56 msgid "All groups" msgstr "" @@ -134,7 +203,7 @@ msgstr "Все логи" msgid "All namespaces" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:78 +#: src/renderer/components/+nodes/node-details.tsx:77 msgid "Allocatable" msgstr "" @@ -142,6 +211,14 @@ msgstr "" msgid "Allow Privilege Escalation" msgstr "" +#: src/renderer/components/+preferences/preferences.tsx:172 +msgid "Allow telemetry & usage tracking" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:164 +msgid "Allow untrusted Certificate Authorities" +msgstr "" + #: src/renderer/components/+pod-security-policies/pod-security-policy-details.tsx:51 msgid "Allowed CSI Drivers" msgstr "" @@ -170,7 +247,7 @@ msgstr "" msgid "Allowed Unsafe Sysctls" msgstr "" -#: src/renderer/components/+nodes/node-details.tsx:103 +#: src/renderer/components/+nodes/node-details.tsx:102 #: src/renderer/components/kube-object/kube-object-meta.tsx:36 msgid "Annotations" msgstr "Аннотации" @@ -196,6 +273,10 @@ msgstr "Применение.." msgid "Apps" msgstr "Приложения" +#: src/renderer/components/+workspaces/workspaces.tsx:61 +msgid "Are you sure you want remove workspace <0>{0}?" +msgstr "" + #: src/renderer/components/+nodes/node-menu.tsx:41 msgid "Are you sure you want to drain <0>{nodeName}?" msgstr "Выполнить команду drain для ноды <0>{nodeName}?" @@ -204,11 +285,15 @@ msgstr "Выполнить команду drain для ноды <0>{nodeName}{from} до <1>{to}" msgid "Fs Group" msgstr "" +#: src/renderer/components/+landing-page/landing-page.tsx:23 +msgid "Get started by associating one or more clusters to Lens." +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:39 +#~ msgid "Global Lens Settings page" +#~ msgstr "" + #: src/renderer/components/+custom-resources/crd-details.tsx:32 #: src/renderer/components/+custom-resources/crd-list.tsx:58 #: src/renderer/components/+custom-resources/crd-list.tsx:74 @@ -843,6 +976,18 @@ msgstr "Группы" msgid "HPA" msgstr "HPA" +#: src/renderer/components/+preferences/preferences.tsx:157 +msgid "HTTP Proxy" +msgstr "" + +#: src/renderer/components/+add-cluster/add-cluster.tsx:178 +#~ msgid "HTTP Proxy server. Used for communicating with Kubernetes API." +#~ msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:140 +msgid "Helm" +msgstr "" + #: src/renderer/components/dock/install-chart.tsx:113 msgid "Helm Chart Install" msgstr "Установка Helm чарта" @@ -851,10 +996,18 @@ msgstr "Установка Helm чарта" msgid "Helm Install: {repo}/{name}" msgstr "Helm установка: {repo}/{name}" +#: src/renderer/components/+preferences/preferences.tsx:61 +#~ msgid "Helm Repository <0>{0} already in use." +#~ msgstr "" + #: src/renderer/components/dock/upgrade-chart.store.ts:114 msgid "Helm Upgrade: {0}" msgstr "Helm обновление: {0}" +#: src/renderer/components/+preferences/preferences.tsx:47 +msgid "Helm branch <0>{0} already in use" +msgstr "" + #: src/renderer/components/+config-secrets/secret-details.tsx:93 #: src/renderer/components/+workloads-pods/pod-logs-dialog.tsx:215 #: src/renderer/components/drawer/drawer-param-toggler.tsx:19 @@ -994,11 +1147,11 @@ msgstr "" #: src/renderer/components/+workloads/workloads.tsx:69 #: src/renderer/components/+workloads-cronjobs/cronjob-details.tsx:62 #: src/renderer/components/+workloads-jobs/jobs.tsx:36 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:64 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:62 msgid "Jobs" msgstr "Jobs" -#: src/renderer/components/+nodes/node-details.tsx:93 +#: src/renderer/components/+nodes/node-details.tsx:92 msgid "Kernel version" msgstr "Версия Kernel" @@ -1038,14 +1191,14 @@ msgstr "Файл конфигурации" msgid "Kubeconfig File" msgstr "Файл конфигурации" -#: src/renderer/components/+nodes/node-details.tsx:99 +#: src/renderer/components/+nodes/node-details.tsx:98 msgid "Kubelet version" msgstr "Версия Kubelet" #: src/renderer/components/+config-secrets/secrets.tsx:43 #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuers.tsx:65 #: src/renderer/components/+namespaces/namespaces.tsx:32 -#: src/renderer/components/+nodes/node-details.tsx:102 +#: src/renderer/components/+nodes/node-details.tsx:101 #: src/renderer/components/kube-object/kube-object-meta.tsx:35 msgid "Labels" msgstr "Метки" @@ -1070,18 +1223,26 @@ msgstr "Увиденно в последний раз" msgid "Last transition time: {lastTransitionTime}" msgstr "Последнее изменение: {lastTransitionTime}" +#: src/renderer/components/+preferences/preferences.tsx:126 +msgid "Lens Global Settings" +msgstr "" + #: src/renderer/components/+pod-security-policies/pod-security-policy-details.tsx:146 msgid "Level" msgstr "" +#: src/renderer/theme.store.ts:33 +#~ msgid "Light" +#~ msgstr "" + #: src/renderer/components/+events/events.tsx:59 msgid "Limited to {0}" msgstr "" #: src/renderer/components/+cluster/cluster-pie-charts.tsx:72 #: src/renderer/components/+cluster/cluster-pie-charts.tsx:115 -#: src/renderer/components/+workloads-pods/container-charts.tsx:39 -#: src/renderer/components/+workloads-pods/container-charts.tsx:63 +#: src/renderer/components/+workloads-pods/container-charts.tsx:40 +#: src/renderer/components/+workloads-pods/container-charts.tsx:64 #: src/renderer/components/+workloads-pods/pod-charts.tsx:48 #: src/renderer/components/+workloads-pods/pod-charts.tsx:72 msgid "Limits" @@ -1151,10 +1312,10 @@ msgstr "" #: src/renderer/components/+cluster/cluster-metric-switchers.tsx:25 #: src/renderer/components/+cluster/cluster-pie-charts.tsx:144 -#: src/renderer/components/+nodes/node-details.tsx:64 -#: src/renderer/components/+nodes/node-details.tsx:75 -#: src/renderer/components/+nodes/node-details.tsx:80 -#: src/renderer/components/+nodes/nodes.tsx:114 +#: src/renderer/components/+nodes/node-details.tsx:63 +#: src/renderer/components/+nodes/node-details.tsx:74 +#: src/renderer/components/+nodes/node-details.tsx:79 +#: src/renderer/components/+nodes/nodes.tsx:121 #: src/renderer/components/+workloads-pods/pod-charts.tsx:12 #: src/renderer/components/+workloads-pods/pod-details-container.tsx:27 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:63 @@ -1167,21 +1328,21 @@ msgstr "Память" msgid "Memory capacity" msgstr "Объем памяти" -#: src/renderer/components/+workloads-pods/container-charts.tsx:64 +#: src/renderer/components/+workloads-pods/container-charts.tsx:65 msgid "Memory limits" msgstr "Лимиты памяти" #: src/renderer/components/+nodes/node-charts.tsx:57 -#: src/renderer/components/+workloads-pods/container-charts.tsx:57 +#: src/renderer/components/+workloads-pods/container-charts.tsx:58 msgid "Memory requests" msgstr "Запросы к памяти" #: src/renderer/components/+nodes/node-charts.tsx:50 -#: src/renderer/components/+workloads-pods/container-charts.tsx:50 +#: src/renderer/components/+workloads-pods/container-charts.tsx:51 msgid "Memory usage" msgstr "Использование памяти" -#: src/renderer/components/+nodes/nodes.tsx:63 +#: src/renderer/components/+nodes/nodes.tsx:68 msgid "Memory:" msgstr "Память:" @@ -1229,6 +1390,10 @@ msgstr "Монтируемые секреты" msgid "Mounts" msgstr "Установки" +#: src/renderer/components/+workspaces/workspaces.tsx:36 +#~ msgid "My Workspace" +#~ msgstr "" + #: src/renderer/components/+apps-helm-charts/helm-charts.tsx:64 #: src/renderer/components/+apps-releases/releases.tsx:87 #: src/renderer/components/+config-autoscalers/hpa-details.tsx:49 @@ -1250,7 +1415,7 @@ msgstr "Установки" #: src/renderer/components/+network-policies/network-policies.tsx:31 #: src/renderer/components/+network-services/service-details-endpoint.tsx:26 #: src/renderer/components/+network-services/services.tsx:44 -#: src/renderer/components/+nodes/nodes.tsx:112 +#: src/renderer/components/+nodes/nodes.tsx:119 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:35 #: src/renderer/components/+storage-classes/storage-classes.tsx:34 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:46 @@ -1268,9 +1433,10 @@ msgstr "Установки" #: src/renderer/components/+workloads-jobs/jobs.tsx:37 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:92 #: src/renderer/components/+workloads-pods/pod-details.tsx:144 -#: src/renderer/components/+workloads-pods/pods.tsx:73 +#: src/renderer/components/+workloads-pods/pods.tsx:74 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:50 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:40 +#: src/renderer/components/+workspaces/workspaces.tsx:117 #: src/renderer/components/dock/edit-resource.tsx:90 #: src/renderer/components/kube-object/kube-object-meta.tsx:20 msgid "Name" @@ -1314,7 +1480,7 @@ msgstr "" #: src/renderer/components/+workloads-daemonsets/daemonsets.tsx:46 #: src/renderer/components/+workloads-deployments/deployments.tsx:59 #: src/renderer/components/+workloads-jobs/jobs.tsx:38 -#: src/renderer/components/+workloads-pods/pods.tsx:75 +#: src/renderer/components/+workloads-pods/pods.tsx:76 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:41 #: src/renderer/components/dock/edit-resource.tsx:91 #: src/renderer/components/dock/install-chart.tsx:122 @@ -1337,7 +1503,11 @@ msgstr "Namespaces" msgid "Namespaces: {0}" msgstr "Namespaces: {0}" -#: src/renderer/components/+network-ingresses/ingress-details.tsx:86 +#: src/renderer/components/+preferences/preferences.tsx:167 +msgid "Needed with some corporate proxies that do certificate re-writing." +msgstr "" + +#: src/renderer/components/+network-ingresses/ingress-details.tsx:66 #: src/renderer/components/+workloads-pods/pod-charts.tsx:13 #: src/renderer/components/layout/sidebar.tsx:83 msgid "Network" @@ -1372,6 +1542,7 @@ msgstr "Новая вкладка" msgid "Next" msgstr "Далее" +#: src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx:29 #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:44 #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx:71 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:42 @@ -1436,7 +1607,7 @@ msgstr "Использование файловой системы ноды в msgid "Node shell" msgstr "Командная строка ноды" -#: src/renderer/components/+nodes/nodes.tsx:111 +#: src/renderer/components/+nodes/nodes.tsx:118 #: src/renderer/components/layout/sidebar.tsx:80 msgid "Nodes" msgstr "Ноды" @@ -1461,11 +1632,11 @@ msgstr "Заметки" msgid "Number of running Pods" msgstr "Кол-во работающих подов" -#: src/renderer/components/+nodes/node-details.tsx:87 +#: src/renderer/components/+nodes/node-details.tsx:86 msgid "OS" msgstr "ОС" -#: src/renderer/components/+nodes/node-details.tsx:90 +#: src/renderer/components/+nodes/node-details.tsx:89 msgid "OS Image" msgstr "Образ ОС" @@ -1477,6 +1648,10 @@ msgstr "Объект" msgid "Ok" msgstr "Ок" +#: src/renderer/components/+whats-new/whats-new.tsx:35 +msgid "Ok, got it!" +msgstr "" + #: src/renderer/components/dock/dock.tsx:117 msgid "Open" msgstr "Открыть" @@ -1497,7 +1672,7 @@ msgid "Organization" msgstr "Организация" #: src/renderer/components/+workloads/workloads.tsx:29 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:37 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:35 msgid "Overview" msgstr "Обзор" @@ -1523,16 +1698,20 @@ msgstr "Путь" msgid "Path Prefix" msgstr "" -#: src/renderer/components/+storage/storage.tsx:26 +#: src/renderer/components/+storage/storage.tsx:25 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:45 msgid "Persistent Volume Claims" msgstr "Persistent Volume Claims" -#: src/renderer/components/+storage/storage.tsx:33 +#: src/renderer/components/+storage/storage.tsx:32 #: src/renderer/components/+storage-volumes/volumes.tsx:39 msgid "Persistent Volumes" msgstr "Persistent Volumes" +#: src/renderer/components/+add-cluster/add-cluster.tsx:62 +msgid "Please select kubeconfig" +msgstr "" + #: src/renderer/components/+workloads-pods/pod-menu.tsx:50 msgid "Pod" msgstr "" @@ -1542,7 +1721,7 @@ msgid "Pod IP" msgstr "IP пода" #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:34 -#: src/renderer/components/+user-management/user-management.tsx:44 +#: src/renderer/components/+user-management/user-management.tsx:43 msgid "Pod Security Policies" msgstr "" @@ -1562,17 +1741,17 @@ msgid "Pod shell" msgstr "Командная строка пода" #: src/renderer/components/+cluster/cluster-pie-charts.tsx:148 -#: src/renderer/components/+nodes/node-details.tsx:66 -#: src/renderer/components/+nodes/node-details.tsx:76 -#: src/renderer/components/+nodes/node-details.tsx:81 +#: src/renderer/components/+nodes/node-details.tsx:65 +#: src/renderer/components/+nodes/node-details.tsx:75 +#: src/renderer/components/+nodes/node-details.tsx:80 #: src/renderer/components/+storage-volume-claims/volume-claim-details.tsx:60 #: src/renderer/components/+storage-volume-claims/volume-claims.tsx:50 #: src/renderer/components/+workloads/workloads.tsx:37 #: src/renderer/components/+workloads-daemonsets/daemonsets.tsx:47 #: src/renderer/components/+workloads-deployments/deployments.tsx:60 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:44 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:42 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:89 -#: src/renderer/components/+workloads-pods/pods.tsx:72 +#: src/renderer/components/+workloads-pods/pods.tsx:73 #: src/renderer/components/+workloads-replicasets/replicasets.tsx:52 #: src/renderer/components/+workloads-statefulsets/statefulsets.tsx:42 msgid "Pods" @@ -1596,6 +1775,10 @@ msgstr "" msgid "Ports" msgstr "Порты" +#: src/renderer/components/+preferences/preferences.tsx:121 +msgid "Preferences" +msgstr "" + #: src/renderer/components/+workloads-pods/pod-details.tsx:93 msgid "Priority Class" msgstr "Класс приоритета" @@ -1614,7 +1797,15 @@ msgstr "" msgid "Provisioner" msgstr "Комиссия" -#: src/renderer/components/+workloads-pods/pods.tsx:79 +#: src/renderer/components/+preferences/preferences.tsx:160 +msgid "Proxy is used only for non-cluster communication." +msgstr "" + +#: src/renderer/components/+add-cluster/add-cluster.tsx:175 +msgid "Proxy settings" +msgstr "" + +#: src/renderer/components/+workloads-pods/pods.tsx:80 msgid "QoS" msgstr "QoS" @@ -1660,6 +1851,10 @@ msgstr "Получение" msgid "Reclaim Policy" msgstr "Политика отката" +#: src/renderer/components/cluster-manager/cluster-status.tsx:52 +#~ msgid "Reconnect" +#~ msgstr "" + #: src/renderer/components/+config-autoscalers/hpa-details.tsx:70 #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:75 msgid "Reference" @@ -1686,7 +1881,10 @@ msgstr "Установка: {0}" msgid "Releases" msgstr "Релизы" +#: src/renderer/components/+preferences/preferences.tsx:151 #: src/renderer/components/+user-management-roles-bindings/role-binding-details.tsx:60 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:74 +#: src/renderer/components/cluster-manager/clusters-menu.tsx:80 #: src/renderer/components/item-object-list/item-list-layout.tsx:179 #: src/renderer/components/menu/menu-actions.tsx:49 #: src/renderer/components/menu/menu-actions.tsx:85 @@ -1697,6 +1895,10 @@ msgstr "Удалить" msgid "Remove <0>{releaseNames}?" msgstr "Удалить <0>{releaseNames}?" +#: src/renderer/components/+workspaces/workspaces.tsx:51 +msgid "Remove Workspace" +msgstr "" + #: src/renderer/components/+config-secrets/add-secret-dialog.tsx:133 msgid "Remove field" msgstr "Удалить поле" @@ -1721,6 +1923,14 @@ msgstr "Удалить выбранные элементы ({0})" msgid "Remove {resourceKind} <0>{resourceName}?" msgstr "Удалить {resourceKind} <0>{resourceName}?" +#: src/renderer/components/+preferences/preferences.tsx:114 +msgid "Removing helm branch <0>{0} has failed: {1}" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:119 +#~ msgid "Removing repo <0>{0} has failed: {1}" +#~ msgstr "" + #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:62 msgid "Renew Before" msgstr "Обновить до" @@ -1737,6 +1947,10 @@ msgstr "Реплики" msgid "Repo/Name" msgstr "Репозиторий/Имя" +#: src/renderer/components/+preferences/preferences.tsx:141 +msgid "Repositories" +msgstr "" + #: src/renderer/components/+apps-helm-charts/helm-charts.tsx:68 msgid "Repository" msgstr "Репозиторий" @@ -1753,8 +1967,8 @@ msgstr "Продолжительность запроса в секундах" #: src/renderer/components/+cluster/cluster-pie-charts.tsx:114 #: src/renderer/components/+nodes/node-charts.tsx:32 #: src/renderer/components/+nodes/node-charts.tsx:56 -#: src/renderer/components/+workloads-pods/container-charts.tsx:32 -#: src/renderer/components/+workloads-pods/container-charts.tsx:56 +#: src/renderer/components/+workloads-pods/container-charts.tsx:33 +#: src/renderer/components/+workloads-pods/container-charts.tsx:57 #: src/renderer/components/+workloads-pods/pod-charts.tsx:41 #: src/renderer/components/+workloads-pods/pod-charts.tsx:65 msgid "Requests" @@ -1824,7 +2038,7 @@ msgstr "Продолжительность ответа в секундах" msgid "Restart session" msgstr "Перезагрузить сессию" -#: src/renderer/components/+workloads-pods/pods.tsx:77 +#: src/renderer/components/+workloads-pods/pods.tsx:78 msgid "Restarts" msgstr "Перезагрузки" @@ -1842,7 +2056,7 @@ msgstr "" msgid "Role" msgstr "Role" -#: src/renderer/components/+user-management/user-management.tsx:32 +#: src/renderer/components/+user-management/user-management.tsx:31 #: src/renderer/components/+user-management-roles-bindings/role-bindings.tsx:34 msgid "Role Bindings" msgstr "Role Bindings" @@ -1855,8 +2069,8 @@ msgstr "Идентификатор роли" msgid "Role name" msgstr "Имя роли" -#: src/renderer/components/+nodes/nodes.tsx:117 -#: src/renderer/components/+user-management/user-management.tsx:37 +#: src/renderer/components/+nodes/nodes.tsx:124 +#: src/renderer/components/+user-management/user-management.tsx:36 #: src/renderer/components/+user-management-roles/roles.tsx:32 msgid "Roles" msgstr "Roles" @@ -1898,6 +2112,7 @@ msgstr "" #: src/renderer/components/+config-maps/config-map-details.tsx:78 #: src/renderer/components/+config-secrets/secret-details.tsx:97 #: src/renderer/components/+workloads-pods/pod-logs-dialog.tsx:216 +#: src/renderer/components/+workspaces/workspaces.tsx:120 #: src/renderer/components/dock/edit-resource.tsx:88 msgid "Save" msgstr "Сохранить" @@ -1974,6 +2189,14 @@ msgstr "Secrets" msgid "Select a quota.." msgstr "Выберите квоту..." +#: src/renderer/components/+add-cluster/add-cluster.tsx:172 +msgid "Select kubeconfig" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:88 +#~ msgid "Select repository" +#~ msgstr "" + #: src/renderer/components/+user-management-roles-bindings/add-role-binding-dialog.tsx:188 msgid "Select role.." msgstr "Выбрать роль.." @@ -2003,7 +2226,7 @@ msgstr "Сервер" msgid "Service" msgstr "Service" -#: src/renderer/components/+user-management/user-management.tsx:27 +#: src/renderer/components/+user-management/user-management.tsx:26 #: src/renderer/components/+user-management-service-accounts/service-accounts.tsx:35 msgid "Service Accounts" msgstr "Service Accounts" @@ -2025,6 +2248,10 @@ msgstr "Установлено" msgid "Set quota" msgstr "Установить квоту" +#: src/renderer/components/cluster-manager/clusters-menu.tsx:53 +msgid "Settings" +msgstr "" + #: src/renderer/components/+nodes/node-menu.tsx:48 #: src/renderer/components/+workloads-pods/pod-menu.tsx:68 msgid "Shell" @@ -2073,7 +2300,7 @@ msgid "Stateful Sets" msgstr "" #: src/renderer/components/+workloads/workloads.tsx:61 -#: src/renderer/components/+workloads-overview/overview-statuses.tsx:54 +#: src/renderer/components/+workloads-overview/overview-statuses.tsx:52 msgid "StatefulSets" msgstr "StatefulSets" @@ -2096,7 +2323,7 @@ msgstr "StatefulSets" #: src/renderer/components/+workloads-pods/pod-details-container.tsx:39 #: src/renderer/components/+workloads-pods/pod-details-list.tsx:97 #: src/renderer/components/+workloads-pods/pod-details.tsx:82 -#: src/renderer/components/+workloads-pods/pods.tsx:81 +#: src/renderer/components/+workloads-pods/pods.tsx:82 msgid "Status" msgstr "Статус" @@ -2118,7 +2345,7 @@ msgstr "" msgid "Storage Class Name" msgstr "Имя Storage Class" -#: src/renderer/components/+storage/storage.tsx:41 +#: src/renderer/components/+storage/storage.tsx:40 #: src/renderer/components/+storage-classes/storage-classes.tsx:33 msgid "Storage Classes" msgstr "Storage Classes" @@ -2166,12 +2393,20 @@ msgstr "Заморозка" msgid "TLS" msgstr "TLS" -#: src/renderer/components/+nodes/node-details.tsx:104 -#: src/renderer/components/+nodes/nodes.tsx:116 +#: src/renderer/components/+nodes/node-details.tsx:103 +#: src/renderer/components/+nodes/nodes.tsx:123 msgid "Taints" msgstr "Метки блокировки" -#: src/renderer/components/dock/terminal.store.ts:29 +#: src/renderer/components/+preferences/preferences.tsx:171 +msgid "Telemetry & Usage Tracking" +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:174 +msgid "Telemetry & usage data is collected to continuously improve the Lens experience." +msgstr "" + +#: src/renderer/components/dock/terminal.store.ts:28 msgid "Terminal" msgstr "Терминал" @@ -2191,11 +2426,23 @@ msgstr "Логи отсутствуют." msgid "This field is required" msgstr "Это обязательное поле" +#: src/renderer/components/input/input.validators.ts:39 +msgid "A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics." +msgstr "Это поле может содержать только латинские буквы в нижнем регистре, номера и дефис." + +#: src/renderer/components/cluster-manager/clusters-menu.tsx:104 +msgid "This is the quick launch menu." +msgstr "" + +#: src/renderer/components/+preferences/preferences.tsx:166 +msgid "This will make Lens to trust ANY certificate authority without any validations." +msgstr "" + #: src/renderer/components/+network-policies/network-policy-details.tsx:59 msgid "To" msgstr "Из" -#: src/renderer/components/error-boundary/error-boundary.tsx:40 +#: src/renderer/components/error-boundary/error-boundary.tsx:39 msgid "To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker." msgstr "Чтобы помочь нам улучшить продукт пожалуйста отправляйте ошибки на {slackLink} сообщество или {githubLink} трекер ошибок." @@ -2230,6 +2477,10 @@ msgstr "Транзит" msgid "Type" msgstr "Тип" +#: src/renderer/components/+preferences/preferences.tsx:158 +msgid "Type HTTP proxy url (example: http://proxy.acme.org:8080)" +msgstr "" + #: src/renderer/components/kube-object/kube-object-meta.tsx:26 msgid "UID" msgstr "" @@ -2274,9 +2525,9 @@ msgstr "Обновить версию" #: src/renderer/components/+nodes/node-charts.tsx:73 #: src/renderer/components/+nodes/node-charts.tsx:90 #: src/renderer/components/+storage-volume-claims/volume-claim-disk-chart.tsx:24 -#: src/renderer/components/+workloads-pods/container-charts.tsx:25 -#: src/renderer/components/+workloads-pods/container-charts.tsx:49 -#: src/renderer/components/+workloads-pods/container-charts.tsx:73 +#: src/renderer/components/+workloads-pods/container-charts.tsx:26 +#: src/renderer/components/+workloads-pods/container-charts.tsx:50 +#: src/renderer/components/+workloads-pods/container-charts.tsx:74 #: src/renderer/components/+workloads-pods/pod-charts.tsx:34 #: src/renderer/components/+workloads-pods/pod-charts.tsx:58 #: src/renderer/components/+workloads-pods/pod-charts.tsx:99 @@ -2322,7 +2573,7 @@ msgstr "Определения" #: src/renderer/components/+apps-releases/releases.tsx:91 #: src/renderer/components/+custom-resources/crd-details.tsx:35 #: src/renderer/components/+custom-resources/crd-list.tsx:75 -#: src/renderer/components/+nodes/nodes.tsx:118 +#: src/renderer/components/+nodes/nodes.tsx:125 #: src/renderer/components/dock/install-chart.tsx:120 #: src/renderer/components/dock/upgrade-chart.tsx:99 msgid "Version" @@ -2358,6 +2609,14 @@ msgstr "Ожидание запуска сервисов" msgid "Warnings: {0}" msgstr "Предупреждения: {0}" +#: src/renderer/components/+landing-page/landing-page.tsx:20 +msgid "Welcome!" +msgstr "" + +#: src/renderer/components/+workspaces/workspaces.tsx:79 +msgid "What is a Workspace?" +msgstr "" + #: src/renderer/components/+cluster/cluster-metric-switchers.tsx:19 msgid "Worker" msgstr "Рабочие" @@ -2366,6 +2625,15 @@ msgstr "Рабочие" msgid "Workloads" msgstr "Ресурсы" +#: src/renderer/components/+workspaces/workspace-menu.tsx:39 +#: src/renderer/components/+workspaces/workspaces.tsx:91 +msgid "Workspaces" +msgstr "" + +#: src/renderer/components/+workspaces/workspaces.tsx:81 +msgid "Workspaces are used to organize number of clusters into logical groups." +msgstr "" + #: src/renderer/components/input/input.validators.ts:10 msgid "Wrong email format" msgstr "Неверный формат электронной почты" @@ -2374,6 +2642,7 @@ msgstr "Неверный формат электронной почты" msgid "Wrong url format" msgstr "Неверный url формат" +#: src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx:28 #: src/renderer/components/+custom-resources/certmanager.k8s.io/certificate-details.tsx:44 #: src/renderer/components/+custom-resources/certmanager.k8s.io/issuer-details.tsx:71 #: src/renderer/components/+pod-security-policies/pod-security-policies.tsx:42 @@ -2403,7 +2672,11 @@ msgstr "тому назад" msgid "and <0>{tailCount} more" msgstr "и <0>{tailCount} ещё" -#: src/renderer/components/+nodes/nodes.tsx:55 +#: src/renderer/components/+preferences/preferences.tsx:126 +msgid "applicable to all clusters" +msgstr "" + +#: src/renderer/components/+nodes/nodes.tsx:57 msgid "cores:" msgstr "ядер:" @@ -2424,6 +2697,10 @@ msgstr "" msgid "never" msgstr "" +#: src/renderer/components/cluster-manager/clusters-menu.tsx:119 +msgid "new" +msgstr "" + #: src/renderer/components/+custom-resources/crd-details.tsx:64 msgid "plural" msgstr "" @@ -2472,7 +2749,7 @@ msgstr "{0} всего, {1} доступно" msgid "{0} unavailable" msgstr "{0} недоступно" -#: src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx:134 +#: src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx:129 msgid "{accountName} kubeconfig" msgstr "{accountName} конфигурация" diff --git a/package.json b/package.json index 23cd13f145..bdfb91473e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "Lens", "description": "Lens - The Kubernetes IDE", "version": "3.6.0-dev", - "main": "out/main.js", + "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", "author": { @@ -12,31 +12,28 @@ }, "scripts": { "dev": "concurrently -k \"yarn dev-run -C\" \"yarn dev:main\" \"yarn dev:renderer\"", - "dev-run": "nodemon --watch out/main.* --exec \"electron --inspect .\" $@", - "dev-test": "yarn test --watch", + "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\" $@", "dev:main": "env DEBUG=true yarn compile:main --watch $@", "dev:renderer": "env DEBUG=true yarn compile:renderer --watch $@", - "dev:renderer:react": "yarn dev:renderer --config-name react $@", - "dev:renderer:vue": "yarn dev:renderer --config-name vue $@", - "compile": "concurrently \"yarn i18n:compile\" \"yarn compile:main -p\" \"yarn compile:renderer -p\"", - "compile:main": "webpack --progress --config webpack.main.ts", - "compile:renderer": "webpack --progress --config webpack.renderer.ts", - "compile:dll": "webpack --config webpack.dll.ts", - "build:linux": "yarn compile && electron-builder --linux --dir -c.productName=LensDev", - "build:mac": "yarn compile && electron-builder --mac --dir -c.productName=LensDev", - "build:win": "yarn compile && electron-builder --win --dir -c.productName=LensDev", + "compile": "env NODE_ENV=production concurrently yarn:compile:*", + "compile:main": "webpack --config webpack.main.ts", + "compile:renderer": "webpack --config webpack.renderer.ts", + "compile:i18n": "lingui compile", + "build:linux": "yarn compile && electron-builder --linux --dir -c.productName=Lens", + "build:mac": "yarn compile && electron-builder --mac --dir -c.productName=Lens", + "build:win": "yarn compile && electron-builder --win --dir -c.productName=Lens", "test": "jest --env=jsdom src $@", "integration": "jest --coverage integration $@", - "dist": "yarn compile && electron-builder -p onTag", - "dist:win": "yarn compile && electron-builder -p onTag --x64 --ia32", + "dist": "yarn compile && electron-builder --publish onTag", + "dist:win": "yarn compile && electron-builder --publish onTag --x64 --ia32", "dist:dir": "yarn dist --dir -c.compression=store -c.mac.identity=null", "postinstall": "patch-package", "i18n:extract": "lingui extract", - "i18n:compile": "lingui compile", "download-bins": "concurrently yarn:download:*", "download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:helm": "yarn run ts-node build/download_helm.ts", - "lint": "eslint $@ --ext js,ts,tsx,vue --max-warnings=0 src/" + "lint": "eslint $@ --ext js,ts,tsx --max-warnings=0 src/", + "rebuild-pty": "yarn run electron-rebuild -f -w node-pty" }, "config": { "bundledKubectlVersion": "1.17.4", @@ -69,6 +66,9 @@ "testEnvironment": "node", "transform": { "^.+\\.tsx?$": "ts-jest" + }, + "moduleNameMapper": { + "\\.(css|scss)$": "/__mocks__/styleMock.ts" } }, "build": { @@ -87,7 +87,7 @@ { "from": "static/", "to": "static/", - "filter": "**/*" + "filter": "!**/main.js" }, "LICENSE" ], @@ -170,29 +170,35 @@ "@types/node": "^12.12.45", "@types/proper-lockfile": "^4.1.1", "@types/tar": "^4.0.3", + "chalk": "^4.1.0", + "conf": "^7.0.1", "crypto-js": "^4.0.0", - "electron-promise-ipc": "^2.1.0", - "electron-store": "^5.2.0", "electron-updater": "^4.3.1", "electron-window-state": "^5.0.3", "filenamify": "^4.1.0", "fs-extra": "^9.0.1", "handlebars": "^4.7.6", "http-proxy": "^1.18.1", + "immer": "^7.0.5", "js-yaml": "^3.14.0", "jsonpath": "^1.0.2", "lodash": "^4.17.15", "mac-ca": "^1.0.4", "marked": "^1.1.0", "md5-file": "^5.0.0", + "mobx": "^5.15.5", + "mobx-observable-history": "^1.0.3", "mock-fs": "^4.12.0", "node-machine-id": "^1.1.12", "node-pty": "^0.9.0", "openid-client": "^3.15.2", + "path-to-regexp": "^6.1.0", "proper-lockfile": "^4.1.1", + "react-router": "^5.2.0", "request": "^2.88.2", "request-promise-native": "^1.0.8", "semver": "^7.3.2", + "serializr": "^2.0.3", "shell-env": "^3.0.0", "tar": "^6.0.2", "tcp-port-used": "^1.0.1", @@ -201,6 +207,7 @@ "uuid": "^8.1.0", "win-ca": "^3.2.0", "winston": "^3.2.1", + "winston-transport-browserconsole": "^1.0.5", "ws": "^7.3.0" }, "devDependencies": { @@ -210,6 +217,7 @@ "@babel/preset-env": "^7.10.2", "@babel/preset-react": "^7.10.1", "@babel/preset-typescript": "^7.10.1", + "@emeraldpay/hashicon-react": "^0.4.0", "@lingui/babel-preset-react": "^2.9.1", "@lingui/cli": "^3.0.0-13", "@lingui/loader": "^3.0.0-13", @@ -228,7 +236,6 @@ "@types/md5-file": "^4.0.2", "@types/mini-css-extract-plugin": "^0.9.1", "@types/react": "^16.9.35", - "@types/react-dom": "^16.9.8", "@types/react-router-dom": "^5.1.5", "@types/react-select": "^3.0.13", "@types/react-window": "^1.8.2", @@ -253,8 +260,6 @@ "babel-loader": "^8.1.0", "babel-plugin-macros": "^2.8.0", "babel-runtime": "^6.26.0", - "bootstrap": "^4.5.0", - "bootstrap-vue": "^2.15.0", "chart.js": "^2.9.3", "circular-dependency-plugin": "^5.2.0", "color": "^3.1.2", @@ -262,15 +267,14 @@ "css-element-queries": "^1.2.3", "css-loader": "^3.5.3", "dompurify": "^2.0.11", - "electron": "^6.1.12", + "electron": "^9.1.2", "electron-builder": "^22.7.0", "electron-notarize": "^0.3.0", + "electron-rebuild": "^1.11.0", "eslint": "^7.3.1", - "eslint-plugin-vue": "^6.2.2", "file-loader": "^6.0.0", "flex.box": "^3.4.4", "fork-ts-checker-webpack-plugin": "^5.0.0", - "hashicon": "^0.3.0", "hoist-non-react-statics": "^3.3.2", "html-webpack-plugin": "^4.3.0", "identity-obj-proxy": "^3.0.0", @@ -279,17 +283,13 @@ "make-plural": "^6.2.1", "material-design-icons": "^3.0.1", "mini-css-extract-plugin": "^0.9.0", - "mobx": "^5.15.4", - "mobx-observable-history": "^1.0.3", "mobx-react": "^6.2.2", "moment": "^2.26.0", "node-loader": "^0.6.0", "node-sass": "^4.14.1", "nodemon": "^2.0.4", "patch-package": "^6.2.2", - "path-to-regexp": "^6.1.0", "postinstall-postinstall": "^2.1.0", - "prismjs": "^1.20.0", "raw-loader": "^4.0.1", "react": "^16.13.1", "react-dom": "^16.13.1", @@ -297,7 +297,7 @@ "react-select": "^3.1.0", "react-window": "^1.8.5", "sass-loader": "^8.0.2", - "spectron": "^8.0.0", + "spectron": "11.0.0", "style-loader": "^1.2.1", "terser-webpack-plugin": "^3.0.3", "ts-jest": "^26.1.0", @@ -306,15 +306,6 @@ "typeface-roboto": "^0.0.75", "typescript": "^3.9.5", "url-loader": "^4.1.0", - "vue": "^2.6.11", - "vue-electron": "^1.0.6", - "vue-loader": "^15.9.2", - "vue-prism-editor": "^0.6.1", - "vue-router": "^3.3.2", - "vue-style-loader": "^4.1.2", - "vue-template-compiler": "^2.6.11", - "vuedraggable": "^2.23.2", - "vuex": "^3.4.0", "webpack": "^4.43.0", "webpack-cli": "^3.3.11", "webpack-node-externals": "^1.7.2", diff --git a/src/common/base-store.ts b/src/common/base-store.ts new file mode 100644 index 0000000000..0cc087eb9f --- /dev/null +++ b/src/common/base-store.ts @@ -0,0 +1,138 @@ +import path from "path" +import Config from "conf" +import { Options as ConfOptions } from "conf/dist/source/types" +import { app, ipcMain, IpcMainEvent, ipcRenderer, IpcRendererEvent, remote } from "electron" +import { action, observable, reaction, runInAction, toJS, when } from "mobx"; +import Singleton from "./utils/singleton"; +import { getAppVersion } from "./utils/app-version"; +import logger from "../main/logger"; +import { broadcastIpc } from "./ipc"; +import isEqual from "lodash/isEqual"; + +export interface BaseStoreParams extends ConfOptions { + autoLoad?: boolean; + syncEnabled?: boolean; +} + +export class BaseStore extends Singleton { + protected storeConfig: Config; + protected syncDisposers: Function[] = []; + + whenLoaded = when(() => this.isLoaded); + @observable isLoaded = false; + @observable protected data: T; + + protected constructor(protected params: BaseStoreParams) { + super(); + this.params = { + autoLoad: false, + syncEnabled: true, + ...params, + } + this.init(); + } + + get name() { + return path.basename(this.storeConfig.path); + } + + get syncChannel() { + return `store-sync:${this.name}` + } + + protected async init() { + if (this.params.autoLoad) { + await this.load(); + } + if (this.params.syncEnabled) { + await this.whenLoaded; + this.enableSync(); + } + } + + async load() { + const { autoLoad, syncEnabled, ...confOptions } = this.params; + this.storeConfig = new Config({ + ...confOptions, + projectName: "lens", + projectVersion: getAppVersion(), + cwd: (app || remote.app).getPath("userData"), + }); + logger.info(`[STORE]: LOADED from ${this.storeConfig.path}`); + this.fromStore(this.storeConfig.store); + this.isLoaded = true; + } + + protected async save(model: T) { + logger.info(`[STORE]: SAVING ${this.name}`); + // todo: update when fixed https://github.com/sindresorhus/conf/issues/114 + Object.entries(model).forEach(([key, value]) => { + this.storeConfig.set(key, value); + }); + } + + enableSync() { + this.syncDisposers.push( + reaction(() => this.toJSON(), model => this.onModelChange(model)), + ); + if (ipcMain) { + const callback = (event: IpcMainEvent, model: T) => { + logger.debug(`[STORE]: SYNC ${this.name} from renderer`, { model }); + this.onSync(model); + }; + ipcMain.on(this.syncChannel, callback); + this.syncDisposers.push(() => ipcMain.off(this.syncChannel, callback)); + } + if (ipcRenderer) { + const callback = (event: IpcRendererEvent, model: T) => { + logger.debug(`[STORE]: SYNC ${this.name} from main`, { model }); + this.onSync(model); + }; + ipcRenderer.on(this.syncChannel, callback); + this.syncDisposers.push(() => ipcRenderer.off(this.syncChannel, callback)); + } + } + + disableSync() { + this.syncDisposers.forEach(dispose => dispose()); + this.syncDisposers.length = 0; + } + + protected applyWithoutSync(callback: () => void) { + this.disableSync(); + runInAction(callback); + if (this.params.syncEnabled) { + this.enableSync(); + } + } + + protected onSync(model: T) { + // todo: use "resourceVersion" if merge required (to avoid equality checks => better performance) + if (!isEqual(this.toJSON(), model)) { + this.fromStore(model); + } + } + + protected async onModelChange(model: T) { + if (ipcMain) { + this.save(model); // save config file + broadcastIpc({ channel: this.syncChannel, args: [model] }); // broadcast to renderer views + } + // send "update-request" to main-process + if (ipcRenderer) { + ipcRenderer.send(this.syncChannel, model); + } + } + + @action + protected fromStore(data: T) { + this.data = data; + } + + // todo: use "serializr" ? + toJSON(): T { + return toJS(this.data, { + recurseEverything: true, + }) + } +} diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts new file mode 100644 index 0000000000..5e1b86992b --- /dev/null +++ b/src/common/cluster-ipc.ts @@ -0,0 +1,59 @@ +import { createIpcChannel } from "./ipc"; +import { ClusterId, clusterStore } from "./cluster-store"; +import { tracker } from "./tracker"; + +export const clusterIpc = { + init: createIpcChannel({ + channel: "cluster:init", + handle: async (clusterId: ClusterId, frameId: number) => { + const cluster = clusterStore.getById(clusterId); + if (cluster) { + cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates + return cluster.pushState(); + } + }, + }), + activate: createIpcChannel({ + channel: "cluster:activate", + handle: (clusterId: ClusterId) => { + return clusterStore.getById(clusterId)?.activate(); + }, + }), + + disconnect: createIpcChannel({ + channel: "cluster:disconnect", + handle: (clusterId: ClusterId) => { + tracker.event("cluster", "stop"); + return clusterStore.getById(clusterId)?.disconnect(); + }, + }), + + installFeature: createIpcChannel({ + channel: "cluster:install-feature", + handle: async (clusterId: ClusterId, feature: string, config?: any) => { + tracker.event("cluster", "install", feature); + const cluster = clusterStore.getById(clusterId); + if (cluster) { + await cluster.installFeature(feature, config) + } else { + throw `${clusterId} is not a valid cluster id`; + } + } + }), + + uninstallFeature: createIpcChannel({ + channel: "cluster:uninstall-feature", + handle: (clusterId: ClusterId, feature: string) => { + tracker.event("cluster", "uninstall", feature); + return clusterStore.getById(clusterId)?.uninstallFeature(feature) + } + }), + + upgradeFeature: createIpcChannel({ + channel: "cluster:upgrade-feature", + handle: (clusterId: ClusterId, feature: string, config?: any) => { + tracker.event("cluster", "upgrade", feature); + return clusterStore.getById(clusterId)?.upgradeFeature(feature, config) + } + }), +} \ No newline at end of file diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index f7f323fad2..ad25f43f26 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -1,120 +1,189 @@ -import ElectronStore from "electron-store" -import { Cluster, ClusterBaseInfo } from "../main/cluster"; -import * as version200Beta2 from "../migrations/cluster-store/2.0.0-beta.2" -import * as version241 from "../migrations/cluster-store/2.4.1" -import * as version260Beta2 from "../migrations/cluster-store/2.6.0-beta.2" -import * as version260Beta3 from "../migrations/cluster-store/2.6.0-beta.3" -import * as version270Beta0 from "../migrations/cluster-store/2.7.0-beta.0" -import * as version270Beta1 from "../migrations/cluster-store/2.7.0-beta.1" -import * as version360Beta1 from "../migrations/cluster-store/3.6.0-beta.1" -import { getAppVersion } from "./utils/app-version"; +import type { WorkspaceId } from "./workspace-store"; +import path from "path"; +import { app, ipcRenderer, remote } from "electron"; +import { unlink } from "fs-extra"; +import { action, computed, observable, toJS } from "mobx"; +import { BaseStore } from "./base-store"; +import { Cluster, ClusterState } from "../main/cluster"; +import migrations from "../migrations/cluster-store" +import logger from "../main/logger"; +import { tracker } from "./tracker"; -export class ClusterStore { - private static instance: ClusterStore; - public store: ElectronStore; +export interface ClusterIconUpload { + clusterId: string; + name: string; + path: string; +} + +export interface ClusterStoreModel { + activeCluster?: ClusterId; // last opened cluster + clusters?: ClusterModel[] +} + +export type ClusterId = string; + +export interface ClusterModel { + id: ClusterId; + workspace?: WorkspaceId; + contextName?: string; + preferences?: ClusterPreferences; + kubeConfigPath: string; + + /** @deprecated */ + kubeConfig?: string; // yaml +} + +export interface ClusterPreferences { + terminalCWD?: string; + clusterName?: string; + prometheus?: { + namespace: string; + service: string; + port: number; + prefix: string; + }; + prometheusProvider?: { + type: string; + }; + icon?: string; + httpsProxy?: string; +} + +export class ClusterStore extends BaseStore { + static get iconsDir() { + // TODO: remove remote cheat + return path.join((app || remote.app).getPath("userData"), "icons"); + } private constructor() { - this.store = new ElectronStore({ - // @ts-ignore - // fixme: tests are failed without "projectVersion" - projectVersion: getAppVersion(), - name: "lens-cluster-store", + super({ + configName: "lens-cluster-store", accessPropertiesByDotNotation: false, // To make dots safe in cluster context names - migrations: { - "2.0.0-beta.2": version200Beta2.migration, - "2.4.1": version241.migration, - "2.6.0-beta.2": version260Beta2.migration, - "2.6.0-beta.3": version260Beta3.migration, - "2.7.0-beta.0": version270Beta0.migration, - "2.7.0-beta.1": version270Beta1.migration, - "3.6.0-beta.1": version360Beta1.migration - } - }) - } - - public getAllClusterObjects(): Array { - return this.store.get("clusters", []).map((clusterInfo: ClusterBaseInfo) => { - return new Cluster(clusterInfo) - }) - } - - public getAllClusters(): Array { - return this.store.get("clusters", []) - } - - public removeCluster(id: string): void { - this.store.delete(id); - const clusterBaseInfos = this.getAllClusters() - const index = clusterBaseInfos.findIndex((cbi) => cbi.id === id) - if (index !== -1) { - clusterBaseInfos.splice(index, 1) - this.store.set("clusters", clusterBaseInfos) + migrations: migrations, + }); + if (ipcRenderer) { + ipcRenderer.on("cluster:state", (event, model: ClusterState) => { + this.applyWithoutSync(() => { + logger.debug(`[CLUSTER-STORE]: received push-state at ${location.host}`, model); + this.getById(model.id)?.updateModel(model); + }) + }) } } - public removeClustersByWorkspace(workspace: string) { - this.getAllClusters().forEach((cluster) => { - if (cluster.workspace === workspace) { - this.removeCluster(cluster.id) - } - }) + @observable activeClusterId: ClusterId; + @observable removedClusters = observable.map(); + @observable clusters = observable.map(); + + @computed get activeCluster(): Cluster | null { + return this.getById(this.activeClusterId); } - public getCluster(id: string): Cluster { - const cluster = this.getAllClusterObjects().find((cluster) => cluster.id === id) + @computed get clustersList(): Cluster[] { + return Array.from(this.clusters.values()); + } + + isActive(id: ClusterId) { + return this.activeClusterId === id; + } + + setActive(id: ClusterId) { + this.activeClusterId = id; + } + + hasClusters() { + return this.clusters.size > 0; + } + + hasContext(name: string) { + return this.clustersList.some(cluster => cluster.contextName === name); + } + + getById(id: ClusterId): Cluster { + return this.clusters.get(id); + } + + getByWorkspaceId(workspaceId: string): Cluster[] { + return this.clustersList.filter(cluster => cluster.workspace === workspaceId) + } + + @action + async addCluster(model: ClusterModel, activate = true): Promise { + tracker.event("cluster", "add"); + const cluster = new Cluster(model); + this.clusters.set(model.id, cluster); + if (activate) this.activeClusterId = model.id; + return cluster; + } + + @action + async removeById(clusterId: ClusterId) { + tracker.event("cluster", "remove"); + const cluster = this.getById(clusterId); if (cluster) { - return cluster + this.clusters.delete(clusterId); + if (this.activeClusterId === clusterId) { + this.activeClusterId = null; + } + unlink(cluster.kubeConfigPath).catch(() => null); } - - return null } - public storeCluster(cluster: ClusterBaseInfo) { - const clusters = this.getAllClusters(); - const index = clusters.findIndex((cl) => cl.id === cluster.id) - const storable = { - id: cluster.id, - kubeConfigPath: cluster.kubeConfigPath, - contextName: cluster.contextName, - preferences: cluster.preferences, - workspace: cluster.workspace - } - if (index === -1) { - clusters.push(storable) - } - else { - clusters[index] = storable - } - this.store.set("clusters", clusters) - } - - public storeClusters(clusters: ClusterBaseInfo[]) { - clusters.forEach((cluster: ClusterBaseInfo) => { - this.removeCluster(cluster.id) - this.storeCluster(cluster) + @action + removeByWorkspaceId(workspaceId: string) { + this.getByWorkspaceId(workspaceId).forEach(cluster => { + this.removeById(cluster.id) }) } - public reloadCluster(cluster: ClusterBaseInfo): void { - const storedCluster = this.getCluster(cluster.id); - if (storedCluster) { - cluster.kubeConfigPath = storedCluster.kubeConfigPath - cluster.contextName = storedCluster.contextName - cluster.preferences = storedCluster.preferences - cluster.workspace = storedCluster.workspace - } + @action + protected fromStore({ activeCluster, clusters = [] }: ClusterStoreModel = {}) { + const currentClusters = this.clusters.toJS(); + const newClusters = new Map(); + const removedClusters = new Map(); + + // update new clusters + clusters.forEach(clusterModel => { + let cluster = currentClusters.get(clusterModel.id); + if (cluster) { + cluster.updateModel(clusterModel); + } else { + cluster = new Cluster(clusterModel); + } + newClusters.set(clusterModel.id, cluster); + }); + + // update removed clusters + currentClusters.forEach(cluster => { + if (!newClusters.has(cluster.id)) { + removedClusters.set(cluster.id, cluster); + } + }); + + this.activeClusterId = newClusters.has(activeCluster) ? activeCluster : null; + this.clusters.replace(newClusters); + this.removedClusters.replace(removedClusters); } - static getInstance(): ClusterStore { - if (!ClusterStore.instance) { - ClusterStore.instance = new ClusterStore(); - } - return ClusterStore.instance; - } - - static resetInstance() { - ClusterStore.instance = null + toJSON(): ClusterStoreModel { + return toJS({ + activeCluster: this.activeClusterId, + clusters: this.clustersList.map(cluster => cluster.toJSON()), + }, { + recurseEverything: true + }) } } -export const clusterStore: ClusterStore = ClusterStore.getInstance(); +export const clusterStore = ClusterStore.getInstance(); + +export function getHostedClusterId(): ClusterId { + const clusterHost = location.hostname.match(/^(.*?)\.localhost/); + if (clusterHost) { + return clusterHost[1] + } +} + +export function getHostedCluster(): Cluster { + return clusterStore.getById(getHostedClusterId()); +} diff --git a/src/common/cluster-store_spec.ts b/src/common/cluster-store_spec.ts deleted file mode 100644 index 3bc87a7cd8..0000000000 --- a/src/common/cluster-store_spec.ts +++ /dev/null @@ -1,349 +0,0 @@ -import mockFs from "mock-fs" -import yaml from "js-yaml" -import * as fs from "fs" -import { ClusterStore } from "./cluster-store"; -import { Cluster } from "../main/cluster"; - -jest.mock("electron", () => { - return { - app: { - getVersion: () => '99.99.99', - getPath: () => 'tmp', - getLocale: () => 'en' - } - } -}) - -// Console.log needs to be called before fs-mocks, see https://github.com/tschaub/mock-fs/issues/234 -console.log(""); - -describe("for an empty config", () => { - beforeEach(() => { - ClusterStore.resetInstance() - const mockOpts = { - 'tmp': { - 'lens-cluster-store.json': JSON.stringify({}) - } - } - mockFs(mockOpts) - }) - - afterEach(() => { - mockFs.restore() - }) - - it("allows to store and retrieve a cluster", async () => { - const cluster = new Cluster({ - id: 'foo', - kubeConfigPath: 'kubeconfig', - contextName: "foo", - preferences: { - terminalCWD: '/tmp', - icon: 'path to icon' - } - }) - const clusterStore = ClusterStore.getInstance() - clusterStore.storeCluster(cluster); - const storedCluster = clusterStore.getCluster(cluster.id); - expect(storedCluster.kubeConfigPath).toBe(cluster.kubeConfigPath) - expect(storedCluster.contextName).toBe(cluster.contextName) - expect(storedCluster.preferences.icon).toBe(cluster.preferences.icon) - expect(storedCluster.preferences.terminalCWD).toBe(cluster.preferences.terminalCWD) - expect(storedCluster.id).toBe(cluster.id) - }) - - it("allows to delete a cluster", async () => { - const cluster = new Cluster({ - id: 'foofoo', - kubeConfigPath: 'kubeconfig', - contextName: "foo", - preferences: { - terminalCWD: '/tmp' - } - }) - const clusterStore = ClusterStore.getInstance() - - clusterStore.storeCluster(cluster); - - const storedCluster = clusterStore.getCluster(cluster.id); - expect(storedCluster.id).toBe(cluster.id) - - clusterStore.removeCluster(cluster.id); - - expect(clusterStore.getCluster(cluster.id)).toBe(null) - }) -}) - -describe("for a config with existing clusters", () => { - beforeEach(() => { - ClusterStore.resetInstance() - const mockOpts = { - 'tmp': { - 'lens-cluster-store.json': JSON.stringify({ - __internal__: { - migrations: { - version: "99.99.99" - } - }, - clusters: [ - { - id: 'cluster1', - kubeConfigPath: 'foo', - preferences: { terminalCWD: '/foo' } - }, - { - id: 'cluster2', - kubeConfigPath: 'foo2', - preferences: { terminalCWD: '/foo2' } - } - ] - }) - } - } - mockFs(mockOpts) - }) - - afterEach(() => { - mockFs.restore() - }) - - it("allows to retrieve a cluster", async () => { - const clusterStore = ClusterStore.getInstance() - const storedCluster = clusterStore.getCluster('cluster1') - expect(storedCluster.kubeConfigPath).toBe('foo') - expect(storedCluster.preferences.terminalCWD).toBe('/foo') - expect(storedCluster.id).toBe('cluster1') - - const storedCluster2 = clusterStore.getCluster('cluster2') - expect(storedCluster2.kubeConfigPath).toBe('foo2') - expect(storedCluster2.preferences.terminalCWD).toBe('/foo2') - expect(storedCluster2.id).toBe('cluster2') - }) - - it("allows to delete a cluster", async () => { - const clusterStore = ClusterStore.getInstance() - - clusterStore.removeCluster('cluster2') - - // Verify the other cluster still exists: - const storedCluster = clusterStore.getCluster('cluster1') - expect(storedCluster.id).toBe('cluster1') - - const storedCluster2 = clusterStore.getCluster('cluster2') - expect(storedCluster2).toBe(null) - }) - - it("allows to reload a cluster in-place", async () => { - const cluster = new Cluster({ - id: 'cluster1', - kubeConfigPath: 'kubeconfig string', - contextName: "foo", - preferences: { - terminalCWD: '/tmp' - } - }) - - const clusterStore = ClusterStore.getInstance() - clusterStore.reloadCluster(cluster) - - expect(cluster.kubeConfigPath).toBe('foo') - expect(cluster.preferences.terminalCWD).toBe('/foo') - expect(cluster.id).toBe('cluster1') - }) - - it("allows getting all the clusters", async () => { - const clusterStore = ClusterStore.getInstance() - const storedClusters = clusterStore.getAllClusters() - - expect(storedClusters[0].id).toBe('cluster1') - expect(storedClusters[0].preferences.terminalCWD).toBe('/foo') - expect(storedClusters[0].kubeConfigPath).toBe('foo') - - expect(storedClusters[1].id).toBe('cluster2') - expect(storedClusters[1].preferences.terminalCWD).toBe('/foo2') - expect(storedClusters[1].kubeConfigPath).toBe('foo2') - }) - - it("allows storing the clusters in a different order", async () => { - const clusterStore = ClusterStore.getInstance() - const storedClusters = clusterStore.getAllClusters() - - const reorderedClusters = [storedClusters[1], storedClusters[0]] - clusterStore.storeClusters(reorderedClusters) - const storedClusters2 = clusterStore.getAllClusters() - - expect(storedClusters2[0].id).toBe('cluster2') - expect(storedClusters2[1].id).toBe('cluster1') - }) -}) - -describe("for a pre 2.0 config with an existing cluster", () => { - beforeEach(() => { - ClusterStore.resetInstance() - const mockOpts = { - 'tmp': { - 'lens-cluster-store.json': JSON.stringify({ - __internal__: { - migrations: { - version: "1.0.0" - } - }, - cluster1: 'kubeconfig content' - }) - } - } - mockFs(mockOpts) - }) - - afterEach(() => { - mockFs.restore() - }) - - it("migrates to modern format with kubeconfig under a key", async () => { - const clusterStore = ClusterStore.getInstance() - const storedCluster = clusterStore.store.get('clusters')[0] - expect(storedCluster.kubeConfigPath).toBe(`tmp/kubeconfigs/${storedCluster.id}`) - }) -}) - -describe("for a pre 2.4.1 config with an existing cluster", () => { - beforeEach(() => { - ClusterStore.resetInstance() - const mockOpts = { - 'tmp': { - 'lens-cluster-store.json': JSON.stringify({ - __internal__: { - migrations: { - version: "2.0.0-beta.2" - } - }, - cluster1: { - kubeConfig: 'foo', - online: true, - accessible: false, - failureReason: 'user error' - }, - }) - } - } - mockFs(mockOpts) - }) - - afterEach(() => { - mockFs.restore() - }) - - it("migrates to modern format throwing out the state related data", async () => { - const clusterStore = ClusterStore.getInstance() - const storedClusterData = clusterStore.store.get('clusters')[0] - expect(storedClusterData.hasOwnProperty('online')).toBe(false) - expect(storedClusterData.hasOwnProperty('accessible')).toBe(false) - expect(storedClusterData.hasOwnProperty('failureReason')).toBe(false) - }) -}) - -describe("for a pre 2.6.0 config with a cluster that has arrays in auth config", () => { - beforeEach(() => { - ClusterStore.resetInstance() - const mockOpts = { - 'tmp': { - 'lens-cluster-store.json': JSON.stringify({ - __internal__: { - migrations: { - version: "2.4.1" - } - }, - cluster1: { - kubeConfig: "apiVersion: v1\nclusters:\n- cluster:\n server: https://10.211.55.6:8443\n name: minikube\ncontexts:\n- context:\n cluster: minikube\n user: minikube\n name: minikube\ncurrent-context: minikube\nkind: Config\npreferences: {}\nusers:\n- name: minikube\n user:\n client-certificate: /Users/kimmo/.minikube/client.crt\n client-key: /Users/kimmo/.minikube/client.key\n auth-provider:\n config:\n access-token:\n - should be string\n expiry:\n - should be string\n" - }, - }) - } - } - mockFs(mockOpts) - }) - - afterEach(() => { - mockFs.restore() - }) - - it("replaces array format access token and expiry into string", async () => { - const clusterStore = ClusterStore.getInstance() - const storedClusterData = clusterStore.store.get('clusters')[0] - const kc = yaml.safeLoad(fs.readFileSync(storedClusterData.kubeConfigPath).toString()) - expect(kc.users[0].user['auth-provider'].config['access-token']).toBe("should be string") - expect(kc.users[0].user['auth-provider'].config['expiry']).toBe("should be string") - expect(storedClusterData.contextName).toBe("minikube") - }) -}) - -describe("for a pre 2.6.0 config with a cluster icon", () => { - beforeEach(() => { - ClusterStore.resetInstance() - const mockOpts = { - 'tmp': { - 'lens-cluster-store.json': JSON.stringify({ - __internal__: { - migrations: { - version: "2.4.1" - } - }, - cluster1: { - kubeConfig: "foo", - icon: "icon path", - preferences: { - terminalCWD: "/tmp" - } - }, - }) - } - } - mockFs(mockOpts) - }) - - afterEach(() => { - mockFs.restore() - }) - - it("moves the icon into preferences", async () => { - const clusterStore = ClusterStore.getInstance() - const storedClusterData = clusterStore.store.get('clusters')[0] - expect(storedClusterData.hasOwnProperty('icon')).toBe(false) - expect(storedClusterData.preferences.hasOwnProperty('icon')).toBe(true) - expect(storedClusterData.preferences.icon).toBe("icon path") - }) -}) - -describe("for a pre 2.7.0-beta.0 config without a workspace", () => { - beforeEach(() => { - ClusterStore.resetInstance() - const mockOpts = { - 'tmp': { - 'lens-cluster-store.json': JSON.stringify({ - __internal__: { - migrations: { - version: "2.6.6" - } - }, - cluster1: { - kubeConfig: "foo", - icon: "icon path", - preferences: { - terminalCWD: "/tmp" - } - }, - }) - } - } - mockFs(mockOpts) - }) - - afterEach(() => { - mockFs.restore() - }) - - it("adds cluster to default workspace", async () => { - const clusterStore = ClusterStore.getInstance() - const storedClusterData = clusterStore.store.get("clusters")[0] - expect(storedClusterData.workspace).toBe('default') - }) -}) diff --git a/src/common/cluster-store_test.ts b/src/common/cluster-store_test.ts new file mode 100644 index 0000000000..3fc3e0b60e --- /dev/null +++ b/src/common/cluster-store_test.ts @@ -0,0 +1,333 @@ +import fs from "fs"; +import mockFs from "mock-fs"; +import yaml from "js-yaml"; +import { Cluster } from "../main/cluster"; +import { ClusterStore } from "./cluster-store"; +import { workspaceStore } from "./workspace-store"; +import { saveConfigToAppFiles } from "./kube-helpers"; + +let clusterStore: ClusterStore; + +describe("empty config", () => { + beforeAll(() => { + ClusterStore.resetInstance(); + const mockOpts = { + 'tmp': { + 'lens-cluster-store.json': JSON.stringify({}) + } + } + mockFs(mockOpts); + clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); + }) + + afterAll(() => { + mockFs.restore(); + }) + + it("adds new cluster to store", async () => { + const cluster = new Cluster({ + id: "foo", + preferences: { + terminalCWD: "/tmp", + icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", + clusterName: "minikube" + }, + kubeConfigPath: saveConfigToAppFiles("foo", "fancy foo config"), + workspace: workspaceStore.currentWorkspaceId + }); + clusterStore.addCluster(cluster); + const storedCluster = clusterStore.getById(cluster.id); + expect(storedCluster.id).toBe(cluster.id); + expect(storedCluster.preferences.terminalCWD).toBe(cluster.preferences.terminalCWD); + expect(storedCluster.preferences.icon).toBe(cluster.preferences.icon); + }) + + it("adds cluster to default workspace", () => { + const storedCluster = clusterStore.getById("foo"); + expect(storedCluster.workspace).toBe("default"); + }) + + it("check if store can contain multiple clusters", () => { + const prodCluster = new Cluster({ + id: "prod", + preferences: { + clusterName: "prod" + }, + kubeConfigPath: saveConfigToAppFiles("prod", "fancy config"), + workspace: "workstation" + }); + const devCluster = new Cluster({ + id: "dev", + preferences: { + clusterName: "dev" + }, + kubeConfigPath: saveConfigToAppFiles("dev", "fancy config"), + workspace: "workstation" + }); + clusterStore.addCluster(prodCluster); + clusterStore.addCluster(devCluster); + expect(clusterStore.hasClusters()).toBeTruthy(); + expect(clusterStore.clusters.size).toBe(3); + }); + + it("gets clusters by workspaces", () => { + const wsClusters = clusterStore.getByWorkspaceId("workstation"); + const defaultClusters = clusterStore.getByWorkspaceId("default"); + expect(defaultClusters.length).toBe(1); + expect(wsClusters.length).toBe(2); + expect(wsClusters[0].id).toBe("prod"); + expect(wsClusters[1].id).toBe("dev"); + }) + + it("checks if last added cluster becomes active", () => { + expect(clusterStore.activeCluster.id).toBe("dev"); + }) + + it("sets active cluster", () => { + clusterStore.setActive("foo"); + expect(clusterStore.activeCluster.id).toBe("foo"); + }) + + it("check if cluster's kubeconfig file saved", () => { + const file = saveConfigToAppFiles("boo", "kubeconfig"); + expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); + }) + + it("removes cluster from store", async () => { + await clusterStore.removeById("foo"); + expect(clusterStore.getById("foo")).toBeUndefined(); + }) +}) + +describe("config with existing clusters", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + const mockOpts = { + 'tmp': { + 'lens-cluster-store.json': JSON.stringify({ + __internal__: { + migrations: { + version: "99.99.99" + } + }, + clusters: [ + { + id: 'cluster1', + kubeConfig: 'foo', + preferences: { terminalCWD: '/foo' } + }, + { + id: 'cluster2', + kubeConfig: 'foo2', + preferences: { terminalCWD: '/foo2' } + } + ] + }) + } + } + mockFs(mockOpts); + clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); + }) + + afterEach(() => { + mockFs.restore(); + }) + + it("allows to retrieve a cluster", () => { + const storedCluster = clusterStore.getById('cluster1'); + expect(storedCluster.id).toBe('cluster1'); + expect(storedCluster.preferences.terminalCWD).toBe('/foo'); + }) + + it("allows to delete a cluster", () => { + clusterStore.removeById('cluster2'); + const storedCluster = clusterStore.getById('cluster1'); + expect(storedCluster).toBeTruthy(); + const storedCluster2 = clusterStore.getById('cluster2'); + expect(storedCluster2).toBeUndefined(); + }) + + it("allows getting all of the clusters", async () => { + const storedClusters = clusterStore.clustersList; + expect(storedClusters[0].id).toBe('cluster1') + expect(storedClusters[0].preferences.terminalCWD).toBe('/foo') + expect(storedClusters[1].id).toBe('cluster2') + expect(storedClusters[1].preferences.terminalCWD).toBe('/foo2') + }) +}) + +describe("pre 2.0 config with an existing cluster", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + const mockOpts = { + 'tmp': { + 'lens-cluster-store.json': JSON.stringify({ + __internal__: { + migrations: { + version: "1.0.0" + } + }, + cluster1: 'kubeconfig content' + }) + } + }; + mockFs(mockOpts); + clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); + }) + + afterEach(() => { + mockFs.restore(); + }) + + it("migrates to modern format with kubeconfig in a file", async () => { + const config = clusterStore.clustersList[0].kubeConfigPath; + expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); + }) +}) + +describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + const mockOpts = { + 'tmp': { + 'lens-cluster-store.json': JSON.stringify({ + __internal__: { + migrations: { + version: "2.4.1" + } + }, + cluster1: { + kubeConfig: "apiVersion: v1\nclusters:\n- cluster:\n server: https://10.211.55.6:8443\n name: minikube\ncontexts:\n- context:\n cluster: minikube\n user: minikube\n name: minikube\ncurrent-context: minikube\nkind: Config\npreferences: {}\nusers:\n- name: minikube\n user:\n client-certificate: /Users/kimmo/.minikube/client.crt\n client-key: /Users/kimmo/.minikube/client.key\n auth-provider:\n config:\n access-token:\n - should be string\n expiry:\n - should be string\n" + }, + }) + } + } + mockFs(mockOpts); + clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); + }) + + afterEach(() => { + mockFs.restore(); + }) + + it("replaces array format access token and expiry into string", async () => { + const file = clusterStore.clustersList[0].kubeConfigPath; + const config = fs.readFileSync(file, "utf8"); + const kc = yaml.safeLoad(config); + expect(kc.users[0].user['auth-provider'].config['access-token']).toBe("should be string"); + expect(kc.users[0].user['auth-provider'].config['expiry']).toBe("should be string"); + }) +}) + +describe("pre 2.6.0 config with a cluster icon", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + const mockOpts = { + 'tmp': { + 'lens-cluster-store.json': JSON.stringify({ + __internal__: { + migrations: { + version: "2.4.1" + } + }, + cluster1: { + kubeConfig: "foo", + icon: "icon path", + preferences: { + terminalCWD: "/tmp" + } + }, + }) + } + } + mockFs(mockOpts); + clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); + }) + + afterEach(() => { + mockFs.restore(); + }) + + it("moves the icon into preferences", async () => { + const storedClusterData = clusterStore.clustersList[0]; + expect(storedClusterData.hasOwnProperty('icon')).toBe(false); + expect(storedClusterData.preferences.hasOwnProperty('icon')).toBe(true); + expect(storedClusterData.preferences.icon).toBe("icon path"); + }) +}) + +describe("for a pre 2.7.0-beta.0 config without a workspace", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + const mockOpts = { + 'tmp': { + 'lens-cluster-store.json': JSON.stringify({ + __internal__: { + migrations: { + version: "2.6.6" + } + }, + cluster1: { + kubeConfig: "foo", + icon: "icon path", + preferences: { + terminalCWD: "/tmp" + } + }, + }) + } + } + mockFs(mockOpts); + clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); + }) + + afterEach(() => { + mockFs.restore(); + }) + + it("adds cluster to default workspace", async () => { + const storedClusterData = clusterStore.clustersList[0]; + expect(storedClusterData.workspace).toBe('default'); + }) +}) + +describe("pre 3.6.0-beta.1 config with an existing cluster", () => { + beforeEach(() => { + ClusterStore.resetInstance(); + const mockOpts = { + 'tmp': { + 'lens-cluster-store.json': JSON.stringify({ + __internal__: { + migrations: { + version: "2.7.0" + } + }, + clusters: [ + { + id: 'cluster1', + kubeConfig: 'kubeconfig content' + } + ] + }) + } + }; + mockFs(mockOpts); + clusterStore = ClusterStore.getInstance(); + return clusterStore.load(); + }) + + afterEach(() => { + mockFs.restore(); + }) + + it("migrates to modern format with kubeconfig in a file", async () => { + const config = clusterStore.clustersList[0].kubeConfigPath; + expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); + }) +}) \ No newline at end of file diff --git a/src/common/ipc.ts b/src/common/ipc.ts new file mode 100644 index 0000000000..8dfb2c45b2 --- /dev/null +++ b/src/common/ipc.ts @@ -0,0 +1,76 @@ +// Inter-protocol communications (main <-> renderer) +// https://www.electronjs.org/docs/api/ipc-main +// https://www.electronjs.org/docs/api/ipc-renderer + +import { ipcMain, ipcRenderer, WebContents, webContents } from "electron" +import logger from "../main/logger"; + +export type IpcChannel = string; + +export interface IpcChannelOptions { + channel: IpcChannel; // main <-> renderer communication channel name + handle?: (...args: any[]) => Promise | any; // message handler + autoBind?: boolean; // auto-bind message handler in main-process, default: true + timeout?: number; // timeout for waiting response from the sender + once?: boolean; // one-time event +} + +export function createIpcChannel({ autoBind = true, once, timeout = 0, handle, channel }: IpcChannelOptions) { + const ipcChannel = { + channel: channel, + handleInMain: () => { + logger.info(`[IPC]: setup channel "${channel}"`); + const ipcHandler = once ? ipcMain.handleOnce : ipcMain.handle; + ipcHandler(channel, async (event, ...args) => { + let timerId: any; + try { + if (timeout > 0) { + timerId = setTimeout(() => { + throw new Error(`[IPC]: response timeout in ${timeout}ms`) + }, timeout); + } + return await handle(...args); // todo: maybe exec in separate thread/worker + } catch (error) { + throw error + } finally { + clearTimeout(timerId); + } + }) + }, + removeHandler() { + ipcMain.removeHandler(channel); + }, + invokeFromRenderer: async (...args: any[]): Promise => { + return ipcRenderer.invoke(channel, ...args); + }, + } + if (autoBind && ipcMain) { + ipcChannel.handleInMain(); + } + return ipcChannel; +} + +export interface IpcBroadcastParams { + channel: IpcChannel + webContentId?: number; // send to single webContents view + frameId?: number; // send to inner frame of webContents + filter?: (webContent: WebContents) => boolean + timeout?: number; // todo: add support + args?: A; +} + +export function broadcastIpc({ channel, frameId, webContentId, filter, args = [] }: IpcBroadcastParams) { + const singleView = webContentId ? webContents.fromId(webContentId) : null; + let views = singleView ? [singleView] : webContents.getAllWebContents(); + if (filter) { + views = views.filter(filter); + } + views.forEach(webContent => { + const type = webContent.getType(); + logger.debug(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args }); + webContent.send(channel, ...args); + if (frameId) { + webContent.sendToFrame(frameId, channel, ...args) + } + }) +} diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts new file mode 100644 index 0000000000..26e73db4a8 --- /dev/null +++ b/src/common/kube-helpers.ts @@ -0,0 +1,167 @@ +import { app, remote } from "electron"; +import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node" +import fse, { ensureDirSync, readFile, writeFileSync } from "fs-extra"; +import path from "path" +import os from "os" +import yaml from "js-yaml" +import logger from "../main/logger"; + +function resolveTilde(filePath: string) { + if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { + return filePath.replace("~", os.homedir()); + } + return filePath; +} + +export function loadConfig(pathOrContent?: string): KubeConfig { + const kc = new KubeConfig(); + + if (fse.pathExistsSync(pathOrContent)) { + kc.loadFromFile(path.resolve(resolveTilde(pathOrContent))); + } else { + kc.loadFromString(pathOrContent); + } + + return kc +} + +/** + * KubeConfig is valid when there's at least one of each defined: + * - User + * - Cluster + * - Context + * @param config KubeConfig to check + */ +export function validateConfig(config: KubeConfig | string): KubeConfig { + if (typeof config == "string") { + config = loadConfig(config); + } + logger.debug(`validating kube config: ${JSON.stringify(config)}`) + if (!config.users || config.users.length == 0) { + throw new Error("No users provided in config") + } + if (!config.clusters || config.clusters.length == 0) { + throw new Error("No clusters provided in config") + } + if (!config.contexts || config.contexts.length == 0) { + throw new Error("No contexts provided in config") + } + + return config +} + +/** + * Breaks kube config into several configs. Each context as it own KubeConfig object + */ +export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] { + const configs: KubeConfig[] = [] + if (!kubeConfig.contexts) { + return configs; + } + kubeConfig.contexts.forEach(ctx => { + const kc = new KubeConfig(); + kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n); + kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n) + kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n) + kc.setCurrentContext(ctx.name); + + configs.push(kc); + }); + return configs; +} + +export function dumpConfigYaml(kubeConfig: Partial): string { + const config = { + apiVersion: "v1", + kind: "Config", + preferences: {}, + 'current-context': kubeConfig.currentContext, + clusters: kubeConfig.clusters.map(cluster => { + return { + name: cluster.name, + cluster: { + 'certificate-authority-data': cluster.caData, + 'certificate-authority': cluster.caFile, + server: cluster.server, + 'insecure-skip-tls-verify': cluster.skipTLSVerify + } + } + }), + contexts: kubeConfig.contexts.map(context => { + return { + name: context.name, + context: { + cluster: context.cluster, + user: context.user, + namespace: context.namespace + } + } + }), + users: kubeConfig.users.map(user => { + return { + name: user.name, + user: { + 'client-certificate-data': user.certData, + 'client-certificate': user.certFile, + 'client-key-data': user.keyData, + 'client-key': user.keyFile, + 'auth-provider': user.authProvider, + exec: user.exec, + token: user.token, + username: user.username, + password: user.password + } + } + }) + } + + logger.debug("Dumping KubeConfig:", config); + + // skipInvalid: true makes dump ignore undefined values + return yaml.safeDump(config, { skipInvalid: true }); +} + +export function podHasIssues(pod: V1Pod) { + // Logic adapted from dashboard + const notReady = !!pod.status.conditions.find(condition => { + return condition.type == "Ready" && condition.status !== "True" + }); + + return ( + notReady || + pod.status.phase !== "Running" || + pod.spec.priority > 500000 // We're interested in high prio pods events regardless of their running status + ) +} + +export function getNodeWarningConditions(node: V1Node) { + return node.status.conditions.filter(c => + c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades" + ) +} + +// Write kubeconfigs to "embedded" store, i.e. "/Users/ixrock/Library/Application Support/Lens/kubeconfigs" +export function saveConfigToAppFiles(clusterId: string, kubeConfig: KubeConfig | string): string { + const userData = (app || remote.app).getPath("userData"); + const kubeConfigFile = path.join(userData, `kubeconfigs/${clusterId}`) + const kubeConfigContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig); + + ensureDirSync(path.dirname(kubeConfigFile)); + writeFileSync(kubeConfigFile, kubeConfigContents); + return kubeConfigFile; +} + +export async function getKubeConfigLocal(): Promise { + try { + const configFile = path.join(os.homedir(), '.kube', 'config'); + const file = await readFile(configFile, "utf8"); + const obj = yaml.safeLoad(file); + if (obj.contexts) { + obj.contexts = obj.contexts.filter((ctx: any) => ctx?.context?.cluster && ctx?.name) + } + return yaml.safeDump(obj); + } catch (err) { + logger.debug(`Cannot read local kube-config: ${err}`) + return ""; + } +} diff --git a/src/common/rbac.ts b/src/common/rbac.ts new file mode 100644 index 0000000000..667e49c9bc --- /dev/null +++ b/src/common/rbac.ts @@ -0,0 +1,50 @@ +import { getHostedCluster } from "./cluster-store"; + +export type KubeResource = + "namespaces" | "nodes" | "events" | "resourcequotas" | + "services" | "secrets" | "configmaps" | "ingresses" | "networkpolicies" | "persistentvolumes" | "storageclasses" | + "pods" | "daemonsets" | "deployments" | "statefulsets" | "replicasets" | "jobs" | "cronjobs" | + "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" + +export interface KubeApiResource { + resource: KubeResource; // valid resource name + group?: string; // api-group +} + +// TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7) +export const apiResources: KubeApiResource[] = [ + { resource: "configmaps" }, + { resource: "cronjobs", group: "batch" }, + { resource: "customresourcedefinitions", group: "apiextensions.k8s.io" }, + { resource: "daemonsets", group: "apps" }, + { resource: "deployments", group: "apps" }, + { resource: "endpoints" }, + { resource: "events" }, + { resource: "horizontalpodautoscalers" }, + { resource: "ingresses", group: "networking.k8s.io" }, + { resource: "jobs", group: "batch" }, + { resource: "namespaces" }, + { resource: "networkpolicies", group: "networking.k8s.io" }, + { resource: "nodes" }, + { resource: "persistentvolumes" }, + { resource: "pods" }, + { resource: "podsecuritypolicies" }, + { resource: "resourcequotas" }, + { resource: "secrets" }, + { resource: "services" }, + { resource: "statefulsets", group: "apps" }, + { resource: "storageclasses", group: "storage.k8s.io" }, +]; + +export function isAllowedResource(resources: KubeResource | KubeResource[]) { + if (!Array.isArray(resources)) { + resources = [resources]; + } + const { allowedResources = [] } = getHostedCluster() || {}; + for (const resource of resources) { + if (!allowedResources.includes(resource)) { + return false; + } + } + return true; +} diff --git a/src/common/register-protocol.ts b/src/common/register-protocol.ts new file mode 100644 index 0000000000..ff9d310f57 --- /dev/null +++ b/src/common/register-protocol.ts @@ -0,0 +1,12 @@ +// Register custom protocols + +import { protocol } from "electron" +import path from "path"; + +export function registerFileProtocol(name: string, basePath: string) { + protocol.registerFileProtocol(name, (request, callback) => { + const filePath = request.url.replace(name + "://", ""); + const absPath = path.resolve(basePath, filePath); + callback({ path: absPath }); + }) +} diff --git a/src/common/register-static.ts b/src/common/register-static.ts deleted file mode 100644 index 9b68a611b3..0000000000 --- a/src/common/register-static.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Setup static folder for common assets - -import path from "path"; -import { protocol } from "electron" -import logger from "../main/logger"; -import { staticDir, staticProto, outDir } from "./vars"; - -export function registerStaticProtocol(rootFolder = staticDir) { - const scheme = staticProto.replace("://", ""); - protocol.registerFileProtocol(scheme, (request, callback) => { - const relativePath = request.url.replace(staticProto, ""); - const absPath = path.resolve(rootFolder, relativePath); - callback(absPath); - }, (error) => { - logger.debug(`Failed to register protocol "${scheme}"`, error); - }) -} - -export function getStaticUrl(filePath: string) { - return staticProto + filePath; -} - -export function getStaticPath(filePath: string) { - return path.resolve(staticDir, filePath); -} diff --git a/src/common/request.ts b/src/common/request.ts index 092b1a756a..536e2eccc9 100644 --- a/src/common/request.ts +++ b/src/common/request.ts @@ -1,12 +1,28 @@ import request from "request" +import requestPromise from "request-promise-native" import { userStore } from "./user-store" -export function globalRequestOpts(requestOpts: request.Options ) { - const userPrefs = userStore.getPreferences() - if (userPrefs.httpsProxy) { - requestOpts.proxy = userPrefs.httpsProxy - } - requestOpts.rejectUnauthorized = !userPrefs.allowUntrustedCAs; +// todo: get rid of "request" (deprecated) +// https://github.com/lensapp/lens/issues/459 - return requestOpts +function getDefaultRequestOpts(): Partial { + const { httpsProxy, allowUntrustedCAs } = userStore.preferences + return { + proxy: httpsProxy || undefined, + rejectUnauthorized: !allowUntrustedCAs, + } +} + +/** + * @deprecated + */ +export function customRequest(opts: request.Options) { + return request.defaults(getDefaultRequestOpts())(opts) +} + +/** + * @deprecated + */ +export function customRequestPromise(opts: requestPromise.Options) { + return requestPromise.defaults(getDefaultRequestOpts())(opts) } diff --git a/src/common/tracker.ts b/src/common/tracker.ts index 5b222c5bab..f2fcb9c1c9 100644 --- a/src/common/tracker.ts +++ b/src/common/tracker.ts @@ -1,10 +1,13 @@ +import { app, App, remote } from "electron" import ua from "universal-analytics" import { machineIdSync } from "node-machine-id" +import Singleton from "./utils/singleton"; import { userStore } from "./user-store" +import logger from "../main/logger"; -const GA_ID = "UA-159377374-1" +export class Tracker extends Singleton { + static readonly GA_ID = "UA-159377374-1" -export class Tracker { protected visitor: ua.Visitor protected machineId: string = null; protected ip: string = null; @@ -12,31 +15,35 @@ export class Tracker { protected locale: string; protected electronUA: string; - constructor(app: Electron.App) { + private constructor(app: App) { + super(); try { - this.visitor = ua(GA_ID, machineIdSync(), {strictCidFormat: false}) + this.visitor = ua(Tracker.GA_ID, machineIdSync(), { strictCidFormat: false }) } catch (error) { - this.visitor = ua(GA_ID) + this.visitor = ua(Tracker.GA_ID) } this.visitor.set("dl", "https://telemetry.k8slens.dev") } - public async event(eventCategory: string, eventAction: string) { - return new Promise(async (resolve, reject) => { - if (!this.telemetryAllowed()) { - resolve() - return + protected async isTelemetryAllowed(): Promise { + return userStore.preferences.allowTelemetry; + } + + async event(eventCategory: string, eventAction: string, otherParams = {}) { + try { + const allowed = await this.isTelemetryAllowed(); + if (!allowed) { + return; } this.visitor.event({ ec: eventCategory, - ea: eventAction + ea: eventAction, + ...otherParams, }).send() - resolve() - }) - } - - protected telemetryAllowed() { - const userPrefs = userStore.getPreferences() - return !!userPrefs.allowTelemetry + } catch (err) { + logger.error(`Failed to track "${eventCategory}:${eventAction}"`, err) + } } } + +export const tracker = Tracker.getInstance(app || remote.app); diff --git a/src/common/user-store.ts b/src/common/user-store.ts index fd3ea296fc..d88ee204cb 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -1,9 +1,16 @@ -import ElectronStore from "electron-store" -import * as version210Beta4 from "../migrations/user-store/2.1.0-beta.4" +import type { ThemeId } from "../renderer/theme.store"; +import semver from "semver" +import { action, observable, reaction, toJS } from "mobx"; +import { BaseStore } from "./base-store"; +import migrations from "../migrations/user-store" import { getAppVersion } from "./utils/app-version"; +import { getKubeConfigLocal, loadConfig } from "./kube-helpers"; +import { tracker } from "./tracker"; -export interface User { - id?: string; +export interface UserStoreModel { + lastSeenAppVersion: string; + seenContexts: string[]; + preferences: UserPreferences; } export interface UserPreferences { @@ -11,76 +18,93 @@ export interface UserPreferences { colorTheme?: string; allowUntrustedCAs?: boolean; allowTelemetry?: boolean; - downloadMirror?: string; + downloadMirror?: string | "default"; } -export class UserStore { - private static instance: UserStore; - public store: ElectronStore; +export class UserStore extends BaseStore { + static readonly defaultTheme: ThemeId = "kontena-dark" private constructor() { - this.store = new ElectronStore({ - // @ts-ignore - // fixme: tests are failed without "projectVersion" - projectVersion: getAppVersion(), - migrations: { - "2.1.0-beta.4": version210Beta4.migration, - } + super({ + // configName: "lens-user-store", // todo: migrate from default "config.json" + migrations: migrations, }); + + // track telemetry availability + reaction(() => this.preferences.allowTelemetry, allowed => { + tracker.event("telemetry", allowed ? "enabled" : "disabled"); + }); + + // refresh new contexts + this.whenLoaded.then(this.refreshNewContexts); + reaction(() => this.seenContexts.size, this.refreshNewContexts); } - public lastSeenAppVersion() { - return this.store.get('lastSeenAppVersion', "0.0.0") + @observable lastSeenAppVersion = "0.0.0" + @observable seenContexts = observable.set(); + @observable newContexts = observable.set(); + + @observable preferences: UserPreferences = { + allowTelemetry: true, + allowUntrustedCAs: false, + colorTheme: UserStore.defaultTheme, + downloadMirror: "default", + }; + + get isNewVersion() { + return semver.gt(getAppVersion(), this.lastSeenAppVersion); } - public setLastSeenAppVersion(version: string) { - this.store.set('lastSeenAppVersion', version) + @action + resetTheme() { + this.preferences.colorTheme = UserStore.defaultTheme; } - public getSeenContexts(): Array { - return this.store.get("seenContexts", []) + @action + saveLastSeenAppVersion() { + tracker.event("app", "whats-new-seen") + this.lastSeenAppVersion = getAppVersion(); } - public storeSeenContext(newContexts: string[]) { - const seenContexts = this.getSeenContexts().concat(newContexts) - // store unique contexts by casting array to set first - const newContextSet = new Set(seenContexts) - const allContexts = [...newContextSet] - this.store.set("seenContexts", allContexts) - return allContexts - } - - public setPreferences(preferences: UserPreferences) { - this.store.set('preferences', preferences) - } - - public getPreferences(): UserPreferences { - const prefs = this.store.get("preferences", {}) - if (!prefs.colorTheme) { - prefs.colorTheme = "dark" + protected refreshNewContexts = async () => { + const kubeConfig = await getKubeConfigLocal(); + if (kubeConfig) { + this.newContexts.clear(); + const localContexts = loadConfig(kubeConfig).getContexts(); + localContexts + .filter(ctx => ctx.cluster) + .filter(ctx => !this.seenContexts.has(ctx.name)) + .forEach(ctx => this.newContexts.add(ctx.name)); } - if (!prefs.downloadMirror) { - prefs.downloadMirror = "default" - } - if (prefs.allowTelemetry === undefined) { - prefs.allowTelemetry = true - } - - return prefs } - static getInstance(): UserStore { - if (!UserStore.instance) { - UserStore.instance = new UserStore(); - } - return UserStore.instance; + @action + markNewContextsAsSeen() { + const { seenContexts, newContexts } = this; + this.seenContexts.replace([...seenContexts, ...newContexts]); + this.newContexts.clear(); } - static resetInstance() { - UserStore.instance = null + @action + protected fromStore(data: Partial = {}) { + const { lastSeenAppVersion, seenContexts = [], preferences } = data + if (lastSeenAppVersion) { + this.lastSeenAppVersion = lastSeenAppVersion; + } + this.seenContexts.replace(seenContexts); + Object.assign(this.preferences, preferences); + } + + toJSON(): UserStoreModel { + const model: UserStoreModel = { + lastSeenAppVersion: this.lastSeenAppVersion, + seenContexts: Array.from(this.seenContexts), + preferences: this.preferences, + } + return toJS(model, { + recurseEverything: true, + }) } } -const userStore: UserStore = UserStore.getInstance(); - -export { userStore }; +export const userStore = UserStore.getInstance(); diff --git a/src/common/user-store_spec.ts b/src/common/user-store_spec.ts deleted file mode 100644 index 657dced485..0000000000 --- a/src/common/user-store_spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import mockFs from "mock-fs" -import { userStore, UserStore } from "./user-store" - -// Console.log needs to be called before fs-mocks, see https://github.com/tschaub/mock-fs/issues/234 -console.log(""); - -describe("for an empty config", () => { - beforeEach(() => { - UserStore.resetInstance() - const mockOpts = { - 'tmp': { - 'config.json': JSON.stringify({}) - } - } - mockFs(mockOpts) - }) - - afterEach(() => { - mockFs.restore() - }) - - it("allows setting and retrieving lastSeenAppVersion", async () => { - userStore.setLastSeenAppVersion("1.2.3"); - expect(userStore.lastSeenAppVersion()).toBe("1.2.3"); - }) - - it("allows adding and listing seen contexts", async () => { - userStore.storeSeenContext(['foo']) - expect(userStore.getSeenContexts().length).toBe(1) - userStore.storeSeenContext(['foo', 'bar']) - const seenContexts = userStore.getSeenContexts() - expect(seenContexts.length).toBe(2) // check 'foo' isn't added twice - expect(seenContexts[0]).toBe('foo') - expect(seenContexts[1]).toBe('bar') - }) - - it("allows setting and getting preferences", async () => { - userStore.setPreferences({ - httpsProxy: 'abcd://defg', - }) - const storedPreferences = userStore.getPreferences() - expect(storedPreferences.httpsProxy).toBe('abcd://defg') - expect(storedPreferences.colorTheme).toBe('dark') // defaults to dark - userStore.setPreferences({ - colorTheme: 'light' - }) - expect(userStore.getPreferences().colorTheme).toBe('light') - }) -}) - -describe("migrations", () => { - beforeEach(() => { - UserStore.resetInstance() - const mockOpts = { - 'tmp': { - 'config.json': JSON.stringify({ - user: { username: 'foobar' }, - preferences: { colorTheme: 'light' }, - }) - } - } - mockFs(mockOpts) - }) - - afterEach(() => { - mockFs.restore() - }) - - it("sets last seen app version to 0.0.0", async () => { - expect(userStore.lastSeenAppVersion()).toBe('0.0.0') - }) -}) diff --git a/src/common/user-store_test.ts b/src/common/user-store_test.ts new file mode 100644 index 0000000000..4e9efe97d8 --- /dev/null +++ b/src/common/user-store_test.ts @@ -0,0 +1,102 @@ +import mockFs from "mock-fs" + +jest.mock("electron", () => { + return { + app: { + getVersion: () => '99.99.99', + getPath: () => 'tmp', + getLocale: () => 'en' + } + } +}) + +import { UserStore } from "./user-store" +import { SemVer } from "semver" +import electron from "electron" + +describe("user store tests", () => { + describe("for an empty config", () => { + beforeEach(() => { + UserStore.resetInstance() + mockFs({ tmp: { 'config.json': "{}" } }) + }) + + afterEach(() => { + mockFs.restore() + }) + + it("allows setting and retrieving lastSeenAppVersion", () => { + const us = UserStore.getInstance(); + + us.lastSeenAppVersion = "1.2.3"; + expect(us.lastSeenAppVersion).toBe("1.2.3"); + }) + + it("allows adding and listing seen contexts", () => { + const us = UserStore.getInstance(); + + us.seenContexts.add('foo') + expect(us.seenContexts.size).toBe(1) + + us.seenContexts.add('foo') + us.seenContexts.add('bar') + expect(us.seenContexts.size).toBe(2) // check 'foo' isn't added twice + expect(us.seenContexts.has('foo')).toBe(true) + expect(us.seenContexts.has('bar')).toBe(true) + }) + + it("allows setting and getting preferences", () => { + const us = UserStore.getInstance(); + + us.preferences.httpsProxy = 'abcd://defg'; + + expect(us.preferences.httpsProxy).toBe('abcd://defg') + expect(us.preferences.colorTheme).toBe(UserStore.defaultTheme) + + us.preferences.colorTheme = "light"; + expect(us.preferences.colorTheme).toBe('light') + }) + + it("correctly resets theme to default value", () => { + const us = UserStore.getInstance(); + + us.preferences.colorTheme = "some other theme"; + us.resetTheme(); + expect(us.preferences.colorTheme).toBe(UserStore.defaultTheme); + }) + + it("correctly calculates if the last seen version is an old release", () => { + const us = UserStore.getInstance(); + + expect(us.isNewVersion).toBe(true); + + us.lastSeenAppVersion = (new SemVer(electron.app.getVersion())).inc("major").format(); + expect(us.isNewVersion).toBe(false); + }) + }) + + describe("migrations", () => { + beforeEach(() => { + UserStore.resetInstance() + mockFs({ + 'tmp': { + 'config.json': JSON.stringify({ + user: { username: 'foobar' }, + preferences: { colorTheme: 'light' }, + lastSeenAppVersion: '1.2.3' + }) + } + }) + }) + + afterEach(() => { + mockFs.restore() + }) + + it("sets last seen app version to 0.0.0", () => { + const us = UserStore.getInstance(); + + expect(us.lastSeenAppVersion).toBe('0.0.0') + }) + }) +}) \ No newline at end of file diff --git a/src/common/utils/cloneJson.ts b/src/common/utils/cloneJson.ts new file mode 100644 index 0000000000..d44f9c7898 --- /dev/null +++ b/src/common/utils/cloneJson.ts @@ -0,0 +1,5 @@ +// Clone json-serializable object + +export function cloneJsonObject(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} diff --git a/src/common/utils/defineGlobal.ts b/src/common/utils/defineGlobal.ts new file mode 100755 index 0000000000..29e2e60ea0 --- /dev/null +++ b/src/common/utils/defineGlobal.ts @@ -0,0 +1,12 @@ +// Setup variable in global scope (top-level object) +// Global type definition must be added separately to `mocks.d.ts` in form: +// declare const __globalName: any; + +export function defineGlobal(propName: string, descriptor: PropertyDescriptor) { + const scope = typeof global !== "undefined" ? global : window; + if (scope.hasOwnProperty(propName)) { + console.info(`Global variable "${propName}" already exists. Skipping.`) + return; + } + Object.defineProperty(scope, propName, descriptor); +} diff --git a/src/common/utils/getRandId.ts b/src/common/utils/getRandId.ts new file mode 100644 index 0000000000..afe075085d --- /dev/null +++ b/src/common/utils/getRandId.ts @@ -0,0 +1,6 @@ +// Create random system name + +export function getRandId({ prefix = "", suffix = "", sep = "_" } = {}) { + const randId = () => Math.random().toString(16).substr(2); + return [prefix, randId(), suffix].filter(s => s).join(sep); +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 35b65207ac..580a8f15c2 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -3,3 +3,5 @@ export * from "./base64" export * from "./camelCase" export * from "./splitArray" +export * from "./getRandId" +export * from "./cloneJson" diff --git a/src/common/utils/kubeconfig.ts b/src/common/utils/kubeconfig.ts deleted file mode 100644 index 1f6cf4a874..0000000000 --- a/src/common/utils/kubeconfig.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { app, remote } from "electron" -import { ensureDirSync, writeFileSync } from "fs-extra" -import * as path from "path" - -// Writes kubeconfigs to "embedded" store, i.e. .../Lens/kubeconfigs/ -export function writeEmbeddedKubeConfig(clusterId: string, kubeConfig: string): string { - // This can be called from main & renderer - const a = (app || remote.app) - const kubeConfigBase = path.join(a.getPath("userData"), "kubeconfigs") - ensureDirSync(kubeConfigBase) - - const kubeConfigFile = path.join(kubeConfigBase, clusterId) - writeFileSync(kubeConfigFile, kubeConfig) - - return kubeConfigFile -} diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts new file mode 100644 index 0000000000..70347f9b42 --- /dev/null +++ b/src/common/utils/singleton.ts @@ -0,0 +1,28 @@ +/** + * Narrowing class instances to the one. + * Use "private" or "protected" modifier for constructor (when overriding) to disallow "new" usage. + * + * @example + * const usersStore: UsersStore = UsersStore.getInstance(); + */ + +type Constructor = new (...args: any[]) => T; + +class Singleton { + private static instances = new WeakMap(); + + // todo: improve types inferring + static getInstance(...args: ConstructorParameters>): T { + if (!Singleton.instances.has(this)) { + Singleton.instances.set(this, Reflect.construct(this, args)); + } + return Singleton.instances.get(this) as T; + } + + static resetInstance() { + Singleton.instances.delete(this); + } +} + +export { Singleton } +export default Singleton; \ No newline at end of file diff --git a/src/common/vars.ts b/src/common/vars.ts index 753e96dbd2..1df1e0f5df 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -1,37 +1,39 @@ // App's common configuration for any process (main, renderer, build pipeline, etc.) import path from "path"; +import packageInfo from "../../package.json" +import { defineGlobal } from "./utils/defineGlobal"; -// Temp -export const reactAppName = "app_react" -export const vueAppName = "app_vue" - -// Flags export const isMac = process.platform === "darwin" export const isWindows = process.platform === "win32" export const isDebugging = process.env.DEBUG === "true"; export const isProduction = process.env.NODE_ENV === "production" export const isDevelopment = isDebugging || !isProduction; -export const buildVersion = process.env.BUILD_VERSION; export const isTestEnv = !!process.env.JEST_WORKER_ID; -// Paths +export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}` +export const publicPath = "/build/" + +// Webpack build paths export const contextDir = process.cwd(); -export const staticDir = path.join(contextDir, "static"); -export const outDir = path.join(contextDir, "out"); +export const buildDir = path.join(contextDir, "static", publicPath); export const mainDir = path.join(contextDir, "src/main"); export const rendererDir = path.join(contextDir, "src/renderer"); export const htmlTemplate = path.resolve(rendererDir, "template.html"); export const sassCommonVars = path.resolve(rendererDir, "components/vars.scss"); -// Apis -export const staticProto = "static://" +// Special runtime paths +defineGlobal("__static", { + get() { + if (isDevelopment) { + return path.resolve(contextDir, "static"); + } + return path.resolve(process.resourcesPath, "static") + } +}) -export const apiPrefix = { - BASE: '/api', - KUBE_BASE: '/api-kube', // kubernetes cluster api - KUBE_HELM: '/api-helm', // helm charts api - KUBE_RESOURCE_APPLIER: "/api-resource", -}; +// Apis +export const apiPrefix = "/api" // local router apis +export const apiKubePrefix = "/api-kube" // k8s cluster apis // Links export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts index daf0016b36..dd10a4c433 100644 --- a/src/common/workspace-store.ts +++ b/src/common/workspace-store.ts @@ -1,78 +1,109 @@ -import ElectronStore from "electron-store" +import { action, computed, observable, toJS } from "mobx"; +import { BaseStore } from "./base-store"; import { clusterStore } from "./cluster-store" -export interface WorkspaceData { - id: string; +export type WorkspaceId = string; + +export interface WorkspaceStoreModel { + currentWorkspace?: WorkspaceId; + workspaces: Workspace[] +} + +export interface Workspace { + id: WorkspaceId; name: string; description?: string; } -export class Workspace implements WorkspaceData { - public id: string - public name: string - public description?: string - - public constructor(data: WorkspaceData) { - Object.assign(this, data) - } -} - -export class WorkspaceStore { - public static defaultId = "default" - private static instance: WorkspaceStore; - public store: ElectronStore; +export class WorkspaceStore extends BaseStore { + static readonly defaultId: WorkspaceId = "default" private constructor() { - this.store = new ElectronStore({ - name: "lens-workspace-store" + super({ + configName: "lens-workspace-store", + }); + } + + @observable currentWorkspaceId = WorkspaceStore.defaultId; + + @observable workspaces = observable.map({ + [WorkspaceStore.defaultId]: { + id: WorkspaceStore.defaultId, + name: "default" + } + }); + + @computed get currentWorkspace(): Workspace { + return this.getById(this.currentWorkspaceId); + } + + @computed get workspacesList() { + return Array.from(this.workspaces.values()); + } + + isDefault(id: WorkspaceId) { + return id === WorkspaceStore.defaultId; + } + + getById(id: WorkspaceId): Workspace { + return this.workspaces.get(id); + } + + @action + setActive(id = WorkspaceStore.defaultId) { + if (!this.getById(id)) { + throw new Error(`workspace ${id} doesn't exist`); + } + + this.currentWorkspaceId = id; + } + + @action + saveWorkspace(workspace: Workspace) { + const id = workspace.id; + const existingWorkspace = this.getById(id); + if (existingWorkspace) { + Object.assign(existingWorkspace, workspace); + } else { + this.workspaces.set(id, workspace); + } + } + + @action + removeWorkspace(id: WorkspaceId) { + const workspace = this.getById(id); + if (!workspace) return; + if (this.isDefault(id)) { + throw new Error("Cannot remove default workspace"); + } + if (this.currentWorkspaceId === id) { + this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default + } + this.workspaces.delete(id); + clusterStore.removeByWorkspaceId(id) + } + + @action + protected fromStore({ currentWorkspace, workspaces = [] }: WorkspaceStoreModel) { + if (currentWorkspace) { + this.currentWorkspaceId = currentWorkspace + } + if (workspaces.length) { + this.workspaces.clear(); + workspaces.forEach(workspace => { + this.workspaces.set(workspace.id, workspace) + }) + } + } + + toJSON(): WorkspaceStoreModel { + return toJS({ + currentWorkspace: this.currentWorkspaceId, + workspaces: this.workspacesList, + }, { + recurseEverything: true }) } - - public storeWorkspace(workspace: WorkspaceData) { - const workspaces = this.getAllWorkspaces() - const index = workspaces.findIndex((w) => w.id === workspace.id) - if (index !== -1) { - workspaces[index] = workspace - } else { - workspaces.push(workspace) - } - this.store.set("workspaces", workspaces) - } - - public removeWorkspace(workspace: Workspace) { - if (workspace.id === WorkspaceStore.defaultId) { - throw new Error("Cannot remove default workspace") - } - const workspaces = this.getAllWorkspaces() - const index = workspaces.findIndex((w) => w.id === workspace.id) - if (index !== -1) { - clusterStore.removeClustersByWorkspace(workspace.id) - workspaces.splice(index, 1) - this.store.set("workspaces", workspaces) - } - } - - public getAllWorkspaces(): Array { - const workspacesData: WorkspaceData[] = this.store.get("workspaces", []) - - return workspacesData.map((wsd) => new Workspace(wsd)) - } - - static getInstance(): WorkspaceStore { - if (!WorkspaceStore.instance) { - WorkspaceStore.instance = new WorkspaceStore() - } - return WorkspaceStore.instance - } } -const workspaceStore: WorkspaceStore = WorkspaceStore.getInstance() - -if (!workspaceStore.getAllWorkspaces().find( ws => ws.id === WorkspaceStore.defaultId)) { - workspaceStore.storeWorkspace({ - id: WorkspaceStore.defaultId, - name: "default" - }) -} - -export { workspaceStore } +export const workspaceStore = WorkspaceStore.getInstance() diff --git a/src/common/workspace-store_test.ts b/src/common/workspace-store_test.ts new file mode 100644 index 0000000000..55e9672663 --- /dev/null +++ b/src/common/workspace-store_test.ts @@ -0,0 +1,128 @@ +import mockFs from "mock-fs" + +jest.mock("electron", () => { + return { + app: { + getVersion: () => '99.99.99', + getPath: () => 'tmp', + getLocale: () => 'en' + } + } +}) + +import { WorkspaceStore } from "./workspace-store" + +describe("workspace store tests", () => { + describe("for an empty config", () => { + beforeEach(async () => { + WorkspaceStore.resetInstance() + mockFs({ tmp: { 'lens-workspace-store.json': "{}" } }) + + await WorkspaceStore.getInstance().load(); + }) + + afterEach(() => { + mockFs.restore() + }) + + it("default workspace should always exist", () => { + const ws = WorkspaceStore.getInstance(); + + expect(ws.workspaces.size).toBe(1); + expect(ws.getById(WorkspaceStore.defaultId)).not.toBe(null); + }) + + it("cannot remove the default workspace", () => { + const ws = WorkspaceStore.getInstance(); + + expect(() => ws.removeWorkspace(WorkspaceStore.defaultId)).toThrowError("Cannot remove"); + }) + + it("can update default workspace name", () => { + const ws = WorkspaceStore.getInstance(); + + ws.saveWorkspace({ + id: WorkspaceStore.defaultId, + name: "foobar", + }); + + expect(ws.currentWorkspace.name).toBe("foobar"); + }) + + it("can add workspaces", () => { + const ws = WorkspaceStore.getInstance(); + + ws.saveWorkspace({ + id: "123", + name: "foobar", + }); + + expect(ws.getById("123").name).toBe("foobar"); + }) + + it("cannot set a non-existent workspace to be active", () => { + const ws = WorkspaceStore.getInstance(); + + expect(() => ws.setActive("abc")).toThrow("doesn't exist"); + }) + + it("can set a existent workspace to be active", () => { + const ws = WorkspaceStore.getInstance(); + + ws.saveWorkspace({ + id: "abc", + name: "foobar", + }); + + expect(() => ws.setActive("abc")).not.toThrowError(); + }) + + it("can remove a workspace", () => { + const ws = WorkspaceStore.getInstance(); + + ws.saveWorkspace({ + id: "123", + name: "foobar", + }); + ws.saveWorkspace({ + id: "1234", + name: "foobar 1", + }); + ws.removeWorkspace("123"); + + expect(ws.workspaces.size).toBe(2); + }) + }) + + describe("for a non-empty config", () => { + beforeEach(async () => { + WorkspaceStore.resetInstance() + mockFs({ + tmp: { + 'lens-workspace-store.json': JSON.stringify({ + currentWorkspace: "abc", + workspaces: [{ + id: "abc", + name: "test" + }, { + id: "default", + name: "default" + }] + }) + } + }) + + await WorkspaceStore.getInstance().load(); + }) + + afterEach(() => { + mockFs.restore() + }) + + it("doesn't revert to default workspace", async () => { + const ws = WorkspaceStore.getInstance(); + + expect(ws.currentWorkspaceId).toBe("abc"); + }) + }) +}) \ No newline at end of file diff --git a/src/features/metrics.ts b/src/features/metrics.ts index 98e71e50d2..0d30037b3f 100644 --- a/src/features/metrics.ts +++ b/src/features/metrics.ts @@ -27,7 +27,8 @@ export interface MetricsConfiguration { } export class MetricsFeature extends Feature { - name = 'metrics'; + static id = 'metrics' + name = MetricsFeature.id; latestVersion = "v2.17.2-lens1" config: MetricsConfiguration = { @@ -51,58 +52,49 @@ export class MetricsFeature extends Feature { storageClass: null, }; - async install(cluster: Cluster): Promise { + async install(cluster: Cluster): Promise { // Check if there are storageclasses - const storageClient = cluster.proxyKubeconfig().makeApiClient(k8s.StorageV1Api) + const storageClient = cluster.getProxyKubeconfig().makeApiClient(k8s.StorageV1Api) const scs = await storageClient.listStorageClass(); - scs.body.items.forEach(sc => { - if(sc.metadata.annotations && - (sc.metadata.annotations['storageclass.kubernetes.io/is-default-class'] === 'true' || sc.metadata.annotations['storageclass.beta.kubernetes.io/is-default-class'] === 'true')) { - this.config.persistence.enabled = true; - } - }); + + this.config.persistence.enabled = scs.body.items.some(sc => ( + sc.metadata?.annotations?.['storageclass.kubernetes.io/is-default-class'] === 'true' || + sc.metadata?.annotations?.['storageclass.beta.kubernetes.io/is-default-class'] === 'true' + )); return super.install(cluster) } - async upgrade(cluster: Cluster): Promise { + async upgrade(cluster: Cluster): Promise { return this.install(cluster) } async featureStatus(kc: KubeConfig): Promise { - return new Promise( async (resolve, reject) => { - const client = kc.makeApiClient(AppsV1Api) - const status: FeatureStatus = { - currentVersion: null, - installed: false, - latestVersion: this.latestVersion, - canUpgrade: false, // Dunno yet - }; - try { - - const prometheus = (await client.readNamespacedStatefulSet('prometheus', 'lens-metrics')).body; - status.installed = true; - status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; - status.canUpgrade = semver.lt(status.currentVersion, this.latestVersion, true); - resolve(status) - } catch(error) { - resolve(status) - } - }); + const client = kc.makeApiClient(AppsV1Api) + const status: FeatureStatus = { + currentVersion: null, + installed: false, + latestVersion: this.latestVersion, + canUpgrade: false, // Dunno yet + }; + + try { + const prometheus = (await client.readNamespacedStatefulSet('prometheus', 'lens-metrics')).body; + status.installed = true; + status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; + status.canUpgrade = semver.lt(status.currentVersion, this.latestVersion, true); + } catch { + // ignore error + } + + return status; } - async uninstall(cluster: Cluster): Promise { - return new Promise(async (resolve, reject) => { - const rbacClient = cluster.proxyKubeconfig().makeApiClient(RbacAuthorizationV1Api) - try { - await this.deleteNamespace(cluster.proxyKubeconfig(), "lens-metrics") - await rbacClient.deleteClusterRole("lens-prometheus"); - await rbacClient.deleteClusterRoleBinding("lens-prometheus"); - resolve(true); - } catch(error) { - reject(error); - } - }); + async uninstall(cluster: Cluster): Promise { + const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api) + + await this.deleteNamespace(cluster.getProxyKubeconfig(), "lens-metrics") + await rbacClient.deleteClusterRole("lens-prometheus"); + await rbacClient.deleteClusterRoleBinding("lens-prometheus"); } - } diff --git a/src/features/user-mode.ts b/src/features/user-mode.ts index d1f1dcf381..71e0652385 100644 --- a/src/features/user-mode.ts +++ b/src/features/user-mode.ts @@ -3,48 +3,42 @@ import {KubeConfig, RbacAuthorizationV1Api} from "@kubernetes/client-node" import { Cluster } from "../main/cluster" export class UserModeFeature extends Feature { - name = 'user-mode'; + static id = 'user-mode' + name = UserModeFeature.id; latestVersion = "v2.0.0" - async install(cluster: Cluster): Promise { + async install(cluster: Cluster): Promise { return super.install(cluster) } - async upgrade(cluster: Cluster): Promise { - return true + async upgrade(cluster: Cluster): Promise { + return; } async featureStatus(kc: KubeConfig): Promise { - return new Promise( async (resolve, reject) => { - const client = kc.makeApiClient(RbacAuthorizationV1Api) - const status: FeatureStatus = { - currentVersion: null, - installed: false, - latestVersion: this.latestVersion, - canUpgrade: false, // Dunno yet - }; - try { - await client.readClusterRoleBinding("lens-user") - status.installed = true; - status.currentVersion = this.latestVersion - status.canUpgrade = false - resolve(status) - } catch(error) { - resolve(status) - } - }); + const client = kc.makeApiClient(RbacAuthorizationV1Api) + const status: FeatureStatus = { + currentVersion: null, + installed: false, + latestVersion: this.latestVersion, + canUpgrade: false, // Dunno yet + }; + + try { + await client.readClusterRoleBinding("lens-user") + status.installed = true; + status.currentVersion = this.latestVersion; + status.canUpgrade = false; + } catch { + // ignore error + } + + return status; } - async uninstall(cluster: Cluster): Promise { - return new Promise(async (resolve, reject) => { - const rbacClient = cluster.proxyKubeconfig().makeApiClient(RbacAuthorizationV1Api) - try { - await rbacClient.deleteClusterRole("lens-user"); - await rbacClient.deleteClusterRoleBinding("lens-user"); - resolve(true); - } catch(error) { - reject(error); - } - }); + async uninstall(cluster: Cluster): Promise { + const rbacClient = cluster.getProxyKubeconfig().makeApiClient(RbacAuthorizationV1Api) + await rbacClient.deleteClusterRole("lens-user"); + await rbacClient.deleteClusterRoleBinding("lens-user"); } } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 2be043fefe..af1e0d5657 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -1,281 +1,65 @@ -import { KubeConfig } from "@kubernetes/client-node" -import { PromiseIpc } from "electron-promise-ipc" -import http from "http" -import { Cluster, ClusterBaseInfo } from "./cluster" -import { clusterStore } from "../common/cluster-store" -import * as k8s from "./k8s" -import logger from "./logger" -import { LensProxy } from "./proxy" -import { app } from "electron" -import path from "path" -import { promises } from "fs" -import { ensureDir } from "fs-extra" -import filenamify from "filenamify" -import { v4 as uuid } from "uuid" -import { apiPrefix } from "../common/vars"; - -export type FeatureInstallRequest = { - name: string; - clusterId: string; - config: any; -} - -export type FeatureInstallResponse = { - success: boolean; - message: string; -} - -export type ClusterIconUpload = { - path: string; - name: string; - clusterId: string; -} +import "../common/cluster-ipc"; +import type http from "http" +import { autorun } from "mobx"; +import { ClusterId, clusterStore } from "../common/cluster-store" +import { Cluster } from "./cluster" +import logger from "./logger"; +import { apiKubePrefix } from "../common/vars"; export class ClusterManager { - public static readonly clusterIconDir = path.join(app.getPath("userData"), "icons") - protected promiseIpc: any - protected proxyServer: LensProxy - protected port: number - protected clusters: Map; - - constructor(clusters: Cluster[], port: number) { - this.promiseIpc = new PromiseIpc({ timeout: 2000 }) - this.port = port - this.clusters = new Map() - clusters.forEach((clusterInfo) => { - try { - const kc = this.loadKubeConfig(clusterInfo.kubeConfigPath) - const cluster = new Cluster({ - id: clusterInfo.id, - port: this.port, - kubeConfigPath: clusterInfo.kubeConfigPath, - contextName: clusterInfo.contextName, - preferences: clusterInfo.preferences, - workspace: clusterInfo.workspace - }) - cluster.init(kc) - logger.debug(`Created cluster[id: ${ cluster.id }] for context ${ cluster.contextName }`) - this.clusters.set(cluster.id, cluster) - } catch(error) { - logger.error(`Error while initializing ${clusterInfo.contextName}`) - } + constructor(public readonly port: number) { + // auto-init clusters + autorun(() => { + clusterStore.clusters.forEach(cluster => { + if (!cluster.initialized) { + logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta()); + cluster.init(port); + } + }); }); - logger.debug("clusters after constructor:" + this.clusters.size) - this.listenEvents() - } - public getClusters() { - return this.clusters.values() - } - - public getCluster(id: string) { - return this.clusters.get(id) - } - - public stop() { - const clusters = Array.from(this.getClusters()) - clusters.map(cluster => cluster.stopServer()) - } - - protected loadKubeConfig(configPath: string): KubeConfig { - const kc = new KubeConfig(); - kc.loadFromFile(configPath) - return kc; - } - - protected async addNewCluster(clusterData: ClusterBaseInfo): Promise { - return new Promise(async (resolve, reject) => { - try { - const kc = this.loadKubeConfig(clusterData.kubeConfigPath) - k8s.validateConfig(kc) - kc.setCurrentContext(clusterData.contextName) - const cluster = new Cluster({ - id: uuid(), - port: this.port, - kubeConfigPath: clusterData.kubeConfigPath, - contextName: clusterData.contextName, - preferences: clusterData.preferences, - workspace: clusterData.workspace - }) - cluster.init(kc) - cluster.save() - this.clusters.set(cluster.id, cluster) - resolve(cluster) - - } catch(error) { - logger.error(error) - reject(error) + // auto-stop removed clusters + autorun(() => { + const removedClusters = Array.from(clusterStore.removedClusters.values()); + if (removedClusters.length > 0) { + const meta = removedClusters.map(cluster => cluster.getMeta()); + logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta); + removedClusters.forEach(cluster => cluster.disconnect()); + clusterStore.removedClusters.clear(); } + }, { + delay: 250 }); } - protected listenEvents() { - this.promiseIpc.on("addCluster", async (clusterData: ClusterBaseInfo) => { - logger.debug(`IPC: addCluster`) - const cluster = await this.addNewCluster(clusterData) - return { - addedCluster: cluster.toClusterInfo(), - allClusters: Array.from(this.getClusters()).map((cluster: Cluster) => cluster.toClusterInfo()) - } - }); - - this.promiseIpc.on("getClusters", async (workspaceId: string) => { - logger.debug(`IPC: getClusters, workspace ${workspaceId}`) - const workspaceClusters = Array.from(this.getClusters()).filter((cluster) => cluster.workspace === workspaceId) - return workspaceClusters.map((cluster: Cluster) => cluster.toClusterInfo()) - }); - - this.promiseIpc.on("getCluster", async (id: string) => { - logger.debug(`IPC: getCluster`) - const cluster = this.getCluster(id) - if (cluster) { - await cluster.refreshCluster() - return cluster.toClusterInfo() - } else { - return null - } - }); - - this.promiseIpc.on("installFeature", async (installReq: FeatureInstallRequest) => { - logger.debug(`IPC: installFeature for ${installReq.name}`) - const cluster = this.clusters.get(installReq.clusterId) - try { - await cluster.installFeature(installReq.name, installReq.config) - return {success: true, message: ""} - } catch(error) { - return {success: false, message: error} - } - }); - - this.promiseIpc.on("upgradeFeature", async (installReq: FeatureInstallRequest) => { - logger.debug(`IPC: upgradeFeature for ${installReq.name}`) - const cluster = this.clusters.get(installReq.clusterId) - try { - await cluster.upgradeFeature(installReq.name, installReq.config) - return {success: true, message: ""} - } catch(error) { - return {success: false, message: error} - } - }); - - this.promiseIpc.on("uninstallFeature", async (installReq: FeatureInstallRequest) => { - logger.debug(`IPC: uninstallFeature for ${installReq.name}`) - const cluster = this.clusters.get(installReq.clusterId) - - await cluster.uninstallFeature(installReq.name) - return {success: true, message: ""} - }); - - this.promiseIpc.on("saveClusterIcon", async (fileUpload: ClusterIconUpload) => { - logger.debug(`IPC: saveClusterIcon for ${fileUpload.clusterId}`) - const cluster = this.getCluster(fileUpload.clusterId) - if (!cluster) { - return {success: false, message: "Cluster not found"} - } - try { - const clusterIcon = await this.uploadClusterIcon(cluster, fileUpload.name, fileUpload.path) - clusterStore.reloadCluster(cluster); - if(!cluster.preferences) cluster.preferences = {}; - cluster.preferences.icon = clusterIcon - clusterStore.storeCluster(cluster); - return {success: true, cluster: cluster.toClusterInfo(), message: ""} - } catch(error) { - return {success: false, message: error} - } - }); - - this.promiseIpc.on("resetClusterIcon", async (id: string) => { - logger.debug(`IPC: resetClusterIcon`) - const cluster = this.getCluster(id) - if (cluster && cluster.preferences) { - cluster.preferences.icon = null; - clusterStore.storeCluster(cluster) - return {success: true, cluster: cluster.toClusterInfo(), message: ""} - } else { - return {success: false, message: "Cluster not found"} - } - }); - - this.promiseIpc.on("refreshCluster", async (clusterId: string) => { - const cluster = this.clusters.get(clusterId) - await cluster.refreshCluster() - return cluster.toClusterInfo() - }); - - this.promiseIpc.on("stopCluster", (clusterId: string) => { - logger.debug(`IPC: stopCluster: ${clusterId}`) - const cluster = this.clusters.get(clusterId) - if (cluster) { - cluster.stopServer() - return true - } - return false - }); - - this.promiseIpc.on("removeCluster", (ctx: string) => { - logger.debug(`IPC: removeCluster: ${ctx}`) - return this.removeCluster(ctx).map((cluster: Cluster) => cluster.toClusterInfo()) - }); - - this.promiseIpc.on("clusterStored", (clusterId: string) => { - logger.debug(`IPC: clusterStored: ${clusterId}`) - const cluster = this.clusters.get(clusterId) - if (cluster) { - clusterStore.reloadCluster(cluster); - cluster.stopServer() - } - }); - - this.promiseIpc.on("preferencesSaved", () => { - logger.debug(`IPC: preferencesSaved`) - this.clusters.forEach((cluster) => { - cluster.stopServer() - }) - }); - - this.promiseIpc.on("getClusterEvents", async (clusterId: string) => { - const cluster = this.clusters.get(clusterId) - return cluster.getEventCount(); - }); - + stop() { + clusterStore.clusters.forEach((cluster: Cluster) => { + cluster.disconnect(); + }) } - public removeCluster(id: string): Cluster[] { - const cluster = this.clusters.get(id) - if (cluster) { - cluster.stopServer() - clusterStore.removeCluster(cluster.id); - this.clusters.delete(cluster.id) - } - return Array.from(this.clusters.values()) + protected getCluster(id: ClusterId) { + return clusterStore.getById(id); } - public getClusterForRequest(req: http.IncomingMessage): Cluster { + getClusterForRequest(req: http.IncomingMessage): Cluster { let cluster: Cluster = null // lens-server is connecting to 127.0.0.1:/ if (req.headers.host.startsWith("127.0.0.1")) { const clusterId = req.url.split("/")[1] if (clusterId) { - cluster = this.clusters.get(clusterId) + cluster = this.getCluster(clusterId) if (cluster) { // we need to swap path prefix so that request is proxied to kube api - req.url = req.url.replace(`/${clusterId}`, apiPrefix.KUBE_BASE) + req.url = req.url.replace(`/${clusterId}`, apiKubePrefix) } } } else { const id = req.headers.host.split(".")[0] - cluster = this.clusters.get(id) + cluster = this.getCluster(id) } return cluster; } - - protected async uploadClusterIcon(cluster: Cluster, fileName: string, src: string): Promise { - await ensureDir(ClusterManager.clusterIconDir) - fileName = filenamify(cluster.contextName + "-" + fileName) - const dest = path.join(ClusterManager.clusterIconDir, fileName) - await promises.copyFile(src, dest) - return "store:///icons/" + fileName - } } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 4ab24adf5e..6ba0fb1f48 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -1,217 +1,267 @@ +import type { ClusterId, ClusterModel, ClusterPreferences } from "../common/cluster-store" +import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; +import type { WorkspaceId } from "../common/workspace-store"; +import type { FeatureStatusMap } from "./feature" +import { action, computed, observable, reaction, toJS, when } from "mobx"; +import { apiKubePrefix } from "../common/vars"; +import { broadcastIpc } from "../common/ipc"; import { ContextHandler } from "./context-handler" -import { FeatureStatusMap } from "./feature" -import * as k8s from "./k8s" -import { clusterStore } from "../common/cluster-store" -import logger from "./logger" import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node" -import * as fm from "./feature-manager"; import { Kubectl } from "./kubectl"; import { KubeconfigManager } from "./kubeconfig-manager" -import { PromiseIpc } from "electron-promise-ipc" -import request from "request-promise-native" -import { apiPrefix } from "../common/vars"; +import { getNodeWarningConditions, loadConfig, podHasIssues } from "../common/kube-helpers" +import { getFeatures, installFeature, uninstallFeature, upgradeFeature } from "./feature-manager"; +import request, { RequestPromiseOptions } from "request-promise-native" +import { apiResources } from "../common/rbac"; +import logger from "./logger" -enum ClusterStatus { +export enum ClusterStatus { AccessGranted = 2, AccessDenied = 1, Offline = 0 } -export interface ClusterBaseInfo { - id: string; - kubeConfigPath: string; - contextName: string; - preferences?: ClusterPreferences; - port?: number; - workspace?: string; -} - -export interface ClusterInfo extends ClusterBaseInfo { - url: string; +export interface ClusterState extends ClusterModel { + initialized: boolean; apiUrl: string; - online?: boolean; - accessible?: boolean; - failureReason?: string; - nodes?: number; - version?: string; - distribution?: string; - isAdmin?: boolean; - features?: FeatureStatusMap; - kubeCtl?: Kubectl; - contextName: string; + online: boolean; + disconnected: boolean; + accessible: boolean; + failureReason: string; + nodes: number; + eventCount: number; + version: string; + distribution: string; + isAdmin: boolean; + allowedNamespaces: string[] + allowedResources: string[] + features: FeatureStatusMap; } -export type ClusterPreferences = { - terminalCWD?: string; - clusterName?: string; - prometheus?: { - namespace: string; - service: string; - port: number; - prefix: string; - }; - prometheusProvider?: { - type: string; - }; - icon?: string; - httpsProxy?: string; -} - -export class Cluster implements ClusterInfo { - public id: string; - public workspace: string; - public contextHandler: ContextHandler; - public contextName: string; - public url: string; - public port: number; - public apiUrl: string; - public online: boolean; - public accessible: boolean; - public failureReason: string; - public nodes: number; - public version: string; - public distribution: string; - public isAdmin: boolean; - public features: FeatureStatusMap; +export class Cluster implements ClusterModel { + public id: ClusterId; + public frameId: number; public kubeCtl: Kubectl - public kubeConfigPath: string; - public eventCount: number; - public preferences: ClusterPreferences; - - protected eventPoller: NodeJS.Timeout; - protected promiseIpc = new PromiseIpc({ timeout: 2000 }) - + public contextHandler: ContextHandler; protected kubeconfigManager: KubeconfigManager; + protected eventDisposers: Function[] = []; - constructor(clusterInfo: ClusterBaseInfo) { - if (clusterInfo) Object.assign(this, clusterInfo) - if (!this.preferences) this.preferences = {} + whenInitialized = when(() => this.initialized); + + @observable initialized = false; + @observable contextName: string; + @observable workspace: WorkspaceId; + @observable kubeConfigPath: string; + @observable apiUrl: string; // cluster server url + @observable kubeProxyUrl: string; // lens-proxy to kube-api url + @observable online: boolean; + @observable accessible: boolean; + @observable disconnected: boolean; + @observable failureReason: string; + @observable nodes = 0; + @observable version: string; + @observable distribution = "unknown"; + @observable isAdmin = false; + @observable eventCount = 0; + @observable preferences: ClusterPreferences = {}; + @observable features: FeatureStatusMap = {}; + @observable allowedNamespaces: string[] = []; + @observable allowedResources: string[] = []; + + @computed get available() { + return this.accessible && !this.disconnected; } - public proxyKubeconfigPath() { - return this.kubeconfigManager.getPath() + constructor(model: ClusterModel) { + this.updateModel(model); } - public proxyKubeconfig() { - const kc = new KubeConfig() - kc.loadFromFile(this.proxyKubeconfigPath()) - return kc + @action + updateModel(model: ClusterModel) { + Object.assign(this, model); + this.apiUrl = this.getKubeconfig().getCurrentCluster()?.server; + this.contextName = this.contextName || this.preferences.clusterName; } - public async init(kc: KubeConfig) { - this.apiUrl = kc.getCurrentCluster().server - this.contextHandler = new ContextHandler(kc, this) - await this.contextHandler.init() // So we get the proxy port reserved - this.kubeconfigManager = new KubeconfigManager(this) - - this.url = this.contextHandler.url - } - - public stopServer() { - this.contextHandler.stopServer() - clearInterval(this.eventPoller); - } - - public async installFeature(name: string, config: any) { - await fm.installFeature(name, this, config) - return this.refreshCluster() - } - - public async upgradeFeature(name: string, config: any) { - await fm.upgradeFeature(name, this, config) - return this.refreshCluster() - } - - public async uninstallFeature(name: string) { - await fm.uninstallFeature(name, this) - return this.refreshCluster() - } - - public async refreshCluster() { - clusterStore.reloadCluster(this) - this.contextHandler.setClusterPreferences(this.preferences) - - const connectionStatus = await this.getConnectionStatus() - this.accessible = connectionStatus == ClusterStatus.AccessGranted; - this.online = connectionStatus > ClusterStatus.Offline; - - if (this.accessible) { - this.distribution = this.detectKubernetesDistribution(this.version) - this.features = await fm.getFeatures(this) - this.isAdmin = await this.isClusterAdmin() - this.nodes = await this.getNodeCount() - this.kubeCtl = new Kubectl(this.version) - this.kubeCtl.ensureKubectl() + @action + async init(port: number) { + try { + this.contextHandler = new ContextHandler(this); + this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler); + this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`; + this.initialized = true; + logger.info(`[CLUSTER]: "${this.contextName}" init success`, { + id: this.id, + context: this.contextName, + apiUrl: this.apiUrl + }); + } catch (err) { + logger.error(`[CLUSTER]: init failed: ${err}`, { + id: this.id, + error: err, + }); } + } + + protected bindEvents() { + logger.info(`[CLUSTER]: bind events`, this.getMeta()); + const refreshTimer = setInterval(() => this.online && this.refresh(), 30000); // every 30s + const refreshEventsTimer = setInterval(() => this.online && this.refreshEvents(), 3000); // every 3s + + this.eventDisposers.push( + reaction(this.getState, this.pushState), + () => clearInterval(refreshTimer), + () => clearInterval(refreshEventsTimer), + ); + } + + protected unbindEvents() { + logger.info(`[CLUSTER]: unbind events`, this.getMeta()); + this.eventDisposers.forEach(dispose => dispose()); + this.eventDisposers.length = 0; + } + + async activate() { + logger.info(`[CLUSTER]: activate`, this.getMeta()); + await this.whenInitialized; + if (!this.eventDisposers.length) { + this.bindEvents(); + } + if (this.disconnected) { + await this.reconnect(); + } + await this.refresh(); + return this.pushState(); + } + + async reconnect() { + logger.info(`[CLUSTER]: reconnect`, this.getMeta()); + this.contextHandler.stopServer(); + await this.contextHandler.ensureServer(); + this.disconnected = false; + } + + @action + disconnect() { + logger.info(`[CLUSTER]: disconnect`, this.getMeta()); + this.unbindEvents(); + this.contextHandler.stopServer(); + this.disconnected = true; + this.online = false; + this.accessible = false; + this.pushState(); + } + + @action + async refresh() { + logger.info(`[CLUSTER]: refresh`, this.getMeta()); + await this.refreshConnectionStatus(); // refresh "version", "online", etc. + if (this.accessible) { + this.kubeCtl = new Kubectl(this.version) + this.distribution = this.detectKubernetesDistribution(this.version) + const [features, isAdmin, nodesCount] = await Promise.all([ + getFeatures(this), + this.isClusterAdmin(), + this.getNodeCount(), + this.kubeCtl.ensureKubectl() + ]); + this.features = features; + this.isAdmin = isAdmin; + this.nodes = nodesCount; + await Promise.all([ + this.refreshEvents(), + this.refreshAllowedResources(), + ]); + } + } + + @action + async refreshConnectionStatus() { + const connectionStatus = await this.getConnectionStatus(); + this.online = connectionStatus > ClusterStatus.Offline; + this.accessible = connectionStatus == ClusterStatus.AccessGranted; + } + + @action + async refreshAllowedResources() { + this.allowedNamespaces = await this.getAllowedNamespaces(); + this.allowedResources = await this.getAllowedResources(); + } + + @action + async refreshEvents() { this.eventCount = await this.getEventCount(); } - public getPrometheusApiPrefix() { - if (!this.preferences.prometheus?.prefix) { - return "" - } - return this.preferences.prometheus.prefix + protected getKubeconfig(): KubeConfig { + return loadConfig(this.kubeConfigPath); } - public save() { - clusterStore.storeCluster(this) + getProxyKubeconfig(): KubeConfig { + return loadConfig(this.getProxyKubeconfigPath()); } - public toClusterInfo(): ClusterInfo { - return { - id: this.id, - workspace: this.workspace, - url: this.url, - contextName: this.contextName, - apiUrl: this.apiUrl, - online: this.online, - accessible: this.accessible, - failureReason: this.failureReason, - nodes: this.nodes, - version: this.version, - distribution: this.distribution, - isAdmin: this.isAdmin, - features: this.features, - kubeCtl: this.kubeCtl, - kubeConfigPath: this.kubeConfigPath, - preferences: this.preferences - } + getProxyKubeconfigPath(): string { + return this.kubeconfigManager.getPath() } - protected async k8sRequest(path: string, opts?: request.RequestPromiseOptions) { - const options = Object.assign({ + async installFeature(name: string, config: any) { + return installFeature(name, this, config) + } + + async upgradeFeature(name: string, config: any) { + return upgradeFeature(name, this, config) + } + + async uninstallFeature(name: string) { + return uninstallFeature(name, this) + } + + protected async k8sRequest(path: string, options: RequestPromiseOptions = {}): Promise { + const apiUrl = this.kubeProxyUrl + path; + return request(apiUrl, { json: true, - timeout: 10000 - }, (opts || {})) - if (!options.headers) { options.headers = {} } - options.headers.host = `${this.id}.localhost:${this.port}` - return request(`http://127.0.0.1:${this.port}${apiPrefix.KUBE_BASE}${path}`, options) + timeout: 5000, + ...options, + headers: { + Host: `${this.id}.${new URL(this.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest() + ...(options.headers || {}), + }, + }) } - protected async getConnectionStatus() { + getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) { + const prometheusPrefix = this.preferences.prometheus?.prefix || ""; + const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; + return this.k8sRequest(metricsPath, { + timeout: 0, + resolveWithFullResponse: false, + json: true, + qs: queryParams, + }) + } + + protected async getConnectionStatus(): Promise { try { const response = await this.k8sRequest("/version") this.version = response.gitVersion this.failureReason = null return ClusterStatus.AccessGranted; } catch (error) { - logger.error(`Failed to connect to cluster ${this.contextName}: ${JSON.stringify(error)}`) + logger.error(`Failed to connect cluster "${this.contextName}": ${error}`) if (error.statusCode) { if (error.statusCode >= 400 && error.statusCode < 500) { this.failureReason = "Invalid credentials"; return ClusterStatus.AccessDenied; - } - else { + } else { this.failureReason = error.error || error.message; return ClusterStatus.Offline; } - } - else if (error.failed === true) { + } else if (error.failed === true) { if (error.timedOut === true) { this.failureReason = "Connection timed out"; return ClusterStatus.Offline; - } - else { + } else { this.failureReason = "Failed to fetch credentials"; return ClusterStatus.AccessDenied; } @@ -221,22 +271,22 @@ export class Cluster implements ClusterInfo { } } - public async canI(resourceAttributes: V1ResourceAttributes): Promise { - const authApi = this.proxyKubeconfig().makeApiClient(AuthorizationV1Api) + async canI(resourceAttributes: V1ResourceAttributes): Promise { + const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api) try { const accessReview = await authApi.createSelfSubjectAccessReview({ apiVersion: "authorization.k8s.io/v1", kind: "SelfSubjectAccessReview", spec: { resourceAttributes } }) - return accessReview.body.status.allowed === true + return accessReview.body.status.allowed } catch (error) { - logger.error(`failed to request selfSubjectAccessReview: ${error.message}`) + logger.error(`failed to request selfSubjectAccessReview: ${error}`) return false } } - protected async isClusterAdmin(): Promise { + async isClusterAdmin(): Promise { return this.canI({ namespace: "kube-system", resource: "*", @@ -245,32 +295,17 @@ export class Cluster implements ClusterInfo { } protected detectKubernetesDistribution(kubernetesVersion: string): string { - if (kubernetesVersion.includes("gke")) { - return "gke" - } - else if (kubernetesVersion.includes("eks")) { - return "eks" - } - else if (kubernetesVersion.includes("IKS")) { - return "iks" - } - else if (this.apiUrl.endsWith("azmk8s.io")) { - return "aks" - } - else if (this.apiUrl.endsWith("k8s.ondigitalocean.com")) { - return "digitalocean" - } - else if (this.contextHandler.contextName.startsWith("minikube")) { - return "minikube" - } - else if (kubernetesVersion.includes("+")) { - return "custom" - } - + if (kubernetesVersion.includes("gke")) return "gke" + if (kubernetesVersion.includes("eks")) return "eks" + if (kubernetesVersion.includes("IKS")) return "iks" + if (this.apiUrl.endsWith("azmk8s.io")) return "aks" + if (this.apiUrl.endsWith("k8s.ondigitalocean.com")) return "digitalocean" + if (this.contextName.startsWith("minikube")) return "minikube" + if (kubernetesVersion.includes("+")) return "custom" return "vanilla" } - protected async getNodeCount() { + protected async getNodeCount(): Promise { try { const response = await this.k8sRequest("/api/v1/nodes") return response.items.length @@ -280,11 +315,11 @@ export class Cluster implements ClusterInfo { } } - public async getEventCount(): Promise { + protected async getEventCount(): Promise { if (!this.isAdmin) { return 0; } - const client = this.proxyKubeconfig().makeApiClient(CoreV1Api); + const client = this.getProxyKubeconfig().makeApiClient(CoreV1Api); try { const response = await client.listEventForAllNamespaces(false, null, null, null, 1000); const uniqEventSources = new Set(); @@ -294,20 +329,19 @@ export class Cluster implements ClusterInfo { try { const pod = (await client.readNamespacedPod(w.involvedObject.name, w.involvedObject.namespace)).body; logger.debug(`checking pod ${w.involvedObject.namespace}/${w.involvedObject.name}`) - if (k8s.podHasIssues(pod)) { + if (podHasIssues(pod)) { uniqEventSources.add(w.involvedObject.uid); } } catch (err) { } - } - else { + } else { uniqEventSources.add(w.involvedObject.uid); } } let nodeNotificationCount = 0; const nodes = (await client.listNode()).body.items; nodes.map(n => { - nodeNotificationCount = nodeNotificationCount + k8s.getNodeWarningConditions(n).length + nodeNotificationCount = nodeNotificationCount + getNodeWarningConditions(n).length }); return uniqEventSources.size + nodeNotificationCount; } catch (error) { @@ -315,4 +349,105 @@ export class Cluster implements ClusterInfo { return 0; } } + + toJSON(): ClusterModel { + const model: ClusterModel = { + id: this.id, + contextName: this.contextName, + kubeConfigPath: this.kubeConfigPath, + workspace: this.workspace, + preferences: this.preferences, + }; + return toJS(model, { + recurseEverything: true + }) + } + + // serializable cluster-state used for sync btw main <-> renderer + getState = (): ClusterState => { + const state: ClusterState = { + ...this.toJSON(), + initialized: this.initialized, + apiUrl: this.apiUrl, + online: this.online, + disconnected: this.disconnected, + accessible: this.accessible, + failureReason: this.failureReason, + nodes: this.nodes, + version: this.version, + distribution: this.distribution, + isAdmin: this.isAdmin, + features: this.features, + eventCount: this.eventCount, + allowedNamespaces: this.allowedNamespaces, + allowedResources: this.allowedResources, + }; + return toJS(state, { + recurseEverything: true + }) + } + + pushState = (state = this.getState()): ClusterState => { + logger.debug(`[CLUSTER]: push-state`, state); + broadcastIpc({ + channel: "cluster:state", + frameId: this.frameId, + args: [state], + }); + return state; + } + + // get cluster system meta, e.g. use in "logger" + getMeta() { + return { + id: this.id, + name: this.contextName, + initialized: this.initialized, + online: this.online, + accessible: this.accessible, + disconnected: this.disconnected, + } + } + + protected async getAllowedNamespaces() { + const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api) + try { + const namespaceList = await api.listNamespace() + const nsAccessStatuses = await Promise.all( + namespaceList.body.items.map(ns => this.canI({ + namespace: ns.metadata.name, + resource: "pods", + verb: "list", + })) + ) + return namespaceList.body.items + .filter((ns, i) => nsAccessStatuses[i]) + .map(ns => ns.metadata.name) + } catch (error) { + const ctx = this.getProxyKubeconfig().getContextObject(this.contextName) + if (ctx.namespace) return [ctx.namespace] + return [] + } + } + + protected async getAllowedResources() { + try { + if (!this.allowedNamespaces.length) { + return []; + } + const resourceAccessStatuses = await Promise.all( + apiResources.map(apiResource => this.canI({ + resource: apiResource.resource, + group: apiResource.group, + verb: "list", + namespace: this.allowedNamespaces[0] + })) + ) + return apiResources + .filter((resource, i) => resourceAccessStatuses[i]) + .map(apiResource => apiResource.resource) + } catch (error) { + return [] + } + } } diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index 6dd8daa0f4..32e933d8d4 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -1,76 +1,42 @@ -import { CoreV1Api, KubeConfig } from "@kubernetes/client-node" -import { ServerOptions } from "http-proxy" -import * as url from "url" +import type { PrometheusProvider, PrometheusService } from "./prometheus/provider-registry" +import type { ClusterPreferences } from "../common/cluster-store"; +import type { Cluster } from "./cluster" +import type httpProxy from "http-proxy" +import url, { UrlWithStringQuery } from "url"; +import { CoreV1Api } from "@kubernetes/client-node" +import { prometheusProviders } from "../common/prometheus-providers" import logger from "./logger" import { getFreePort } from "./port" import { KubeAuthProxy } from "./kube-auth-proxy" -import { Cluster, ClusterPreferences } from "./cluster" -import { prometheusProviders } from "../common/prometheus-providers" -import { PrometheusService, PrometheusProvider } from "./prometheus/provider-registry" export class ContextHandler { - public contextName: string - public id: string - public url: string - public clusterUrl: url.UrlWithStringQuery - public proxyServer: KubeAuthProxy - public proxyPort: number - public certData: string - public authCertData: string - public cluster: Cluster - - protected apiTarget: ServerOptions - protected proxyTarget: ServerOptions - protected clientCert: string - protected clientKey: string - protected secureApiConnection = true - protected defaultNamespace: string - protected kubernetesApi: string + public proxyPort: number; + public clusterUrl: UrlWithStringQuery; + protected kubeAuthProxy: KubeAuthProxy + protected apiTarget: httpProxy.ServerOptions protected prometheusProvider: string protected prometheusPath: string - protected clusterName: string - constructor(kc: KubeConfig, cluster: Cluster) { - this.id = cluster.id - - this.cluster = cluster - this.clusterUrl = url.parse(cluster.apiUrl) - this.contextName = cluster.contextName; - this.defaultNamespace = kc.getContextObject(cluster.contextName).namespace - this.url = `http://${this.id}.localhost:${cluster.port}/` - this.kubernetesApi = `http://127.0.0.1:${cluster.port}/${this.id}` - - this.setClusterPreferences(cluster.preferences) + constructor(protected cluster: Cluster) { + this.clusterUrl = url.parse(cluster.apiUrl); + this.setupPrometheus(cluster.preferences); } - public async init() { - await this.resolveProxyPort() - } - - public setClusterPreferences(clusterPreferences?: ClusterPreferences) { - this.prometheusProvider = clusterPreferences.prometheusProvider?.type - - if (clusterPreferences && clusterPreferences.prometheus) { - const prom = clusterPreferences.prometheus - this.prometheusPath = `${prom.namespace}/services/${prom.service}:${prom.port}` - } - else { - this.prometheusPath = null - } - if (clusterPreferences && clusterPreferences.clusterName) { - this.clusterName = clusterPreferences.clusterName; - } - else { - this.clusterName = this.contextName; + protected setupPrometheus(preferences: ClusterPreferences = {}) { + this.prometheusProvider = preferences.prometheusProvider?.type; + this.prometheusPath = null; + if (preferences.prometheus) { + const { namespace, service, port } = preferences.prometheus + this.prometheusPath = `${namespace}/services/${service}:${port}` } } protected async resolvePrometheusPath(): Promise { - const {service, namespace, port} = await this.getPrometheusService() + const { service, namespace, port } = await this.getPrometheusService() return `${namespace}/services/${service}:${port}` } - public async getPrometheusProvider() { + async getPrometheusProvider() { if (!this.prometheusProvider) { const service = await this.getPrometheusService() logger.info(`using ${service.id} as prometheus provider`) @@ -79,36 +45,35 @@ export class ContextHandler { return prometheusProviders.find(p => p.id === this.prometheusProvider) } - public async getPrometheusService(): Promise { - const providers = this.prometheusProvider ? prometheusProviders.filter((p, _) => p.id == this.prometheusProvider) : prometheusProviders + async getPrometheusService(): Promise { + const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders; const prometheusPromises: Promise[] = providers.map(async (provider: PrometheusProvider): Promise => { - const apiClient = this.cluster.proxyKubeconfig().makeApiClient(CoreV1Api) + const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api) return await provider.getPrometheusService(apiClient) }) const resolvedPrometheusServices = await Promise.all(prometheusPromises) - const service = resolvedPrometheusServices.filter(n => n)[0] - if (service) { - return service - } - else { - return { - id: "lens", - namespace: "lens-metrics", - service: "prometheus", - port: 80 - } + const service = resolvedPrometheusServices.filter(n => n)[0]; + return service || { + id: "lens", + namespace: "lens-metrics", + service: "prometheus", + port: 80 } } - public async getPrometheusPath(): Promise { - if (this.prometheusPath) return this.prometheusPath - - this.prometheusPath = await this.resolvePrometheusPath() - - return this.prometheusPath + async getPrometheusPath(): Promise { + if (!this.prometheusPath) { + this.prometheusPath = await this.resolvePrometheusPath() + } + return this.prometheusPath; } - public async getApiTarget(isWatchRequest = false): Promise { + async resolveAuthProxyUrl() { + const proxyPort = await this.ensurePort(); + return `http://127.0.0.1:${proxyPort}`; + } + + async getApiTarget(isWatchRequest = false): Promise { if (this.apiTarget && !isWatchRequest) { return this.apiTarget } @@ -120,65 +85,45 @@ export class ContextHandler { return apiTarget } - protected async newApiTarget(timeout: number): Promise { + protected async newApiTarget(timeout: number): Promise { + const proxyUrl = await this.resolveAuthProxyUrl(); return { + target: proxyUrl + this.clusterUrl.path, changeOrigin: true, timeout: timeout, headers: { - "Host": this.clusterUrl.hostname - }, - target: { - port: await this.resolveProxyPort(), - protocol: "http://", - host: "localhost", - path: this.clusterUrl.path + "Host": this.clusterUrl.hostname, }, } } - protected async resolveProxyPort(): Promise { - if (this.proxyPort) return this.proxyPort - - let serverPort: number = null - try { - serverPort = await getFreePort() - } catch (error) { - logger.error(error) - throw(error) + async ensurePort(): Promise { + if (!this.proxyPort) { + this.proxyPort = await getFreePort(); } - this.proxyPort = serverPort - - return serverPort + return this.proxyPort } - public async withTemporaryKubeconfig(callback: (kubeconfig: string) => Promise) { - try { - await callback(this.cluster.proxyKubeconfigPath()) - } catch (error) { - throw(error) - } - } - - public async ensureServer() { - if (!this.proxyServer) { - const proxyPort = await this.resolveProxyPort() + async ensureServer() { + if (!this.kubeAuthProxy) { + await this.ensurePort(); const proxyEnv = Object.assign({}, process.env) - if (this.cluster?.preferences.httpsProxy) { + if (this.cluster.preferences.httpsProxy) { proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy } - this.proxyServer = new KubeAuthProxy(this.cluster, proxyPort, proxyEnv) - await this.proxyServer.run() + this.kubeAuthProxy = new KubeAuthProxy(this.cluster, this.proxyPort, proxyEnv) + await this.kubeAuthProxy.run() } } - public stopServer() { - if (this.proxyServer) { - this.proxyServer.exit() - this.proxyServer = null + stopServer() { + if (this.kubeAuthProxy) { + this.kubeAuthProxy.exit() + this.kubeAuthProxy = null } } - public proxyServerError() { - return this.proxyServer?.lastError || "" + get proxyLastError(): string { + return this.kubeAuthProxy?.lastError || "" } } diff --git a/src/main/feature-manager.ts b/src/main/feature-manager.ts index 6796f5d662..375872929e 100644 --- a/src/main/feature-manager.ts +++ b/src/main/feature-manager.ts @@ -1,55 +1,44 @@ import { KubeConfig } from "@kubernetes/client-node" import logger from "./logger"; import { Cluster } from "./cluster"; -import { Feature, FeatureStatusMap } from "./feature" +import { Feature, FeatureStatusMap, FeatureMap } from "./feature" import { MetricsFeature } from "../features/metrics" import { UserModeFeature } from "../features/user-mode" -const ALL_FEATURES: any = { - 'metrics': new MetricsFeature(null), - 'user-mode': new UserModeFeature(null), -} +const ALL_FEATURES: Map = new Map([ + [MetricsFeature.id, new MetricsFeature(null)], + [UserModeFeature.id, new UserModeFeature(null)], +]); export async function getFeatures(cluster: Cluster): Promise { - return new Promise(async (resolve, reject) => { - const result: FeatureStatusMap = {}; - logger.debug(`features for ${cluster.contextName}`); - for (const key in ALL_FEATURES) { - logger.debug(`feature ${key}`); - if (ALL_FEATURES.hasOwnProperty(key)) { - logger.debug("getting feature status..."); - const feature = ALL_FEATURES[key] as Feature; - const kc = new KubeConfig() - kc.loadFromFile(cluster.proxyKubeconfigPath()) - - const status = await feature.featureStatus(kc); - result[feature.name] = status + const result: FeatureStatusMap = {}; + logger.debug(`features for ${cluster.contextName}`); - } else { - logger.error("ALL_FEATURES.hasOwnProperty(key) returned FALSE ?!?!?!?!") + for (const [key, feature] of ALL_FEATURES) { + logger.debug(`feature ${key}`); + logger.debug("getting feature status..."); - } - } - logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`); - resolve(result); - }); + const kc = new KubeConfig(); + kc.loadFromFile(cluster.getProxyKubeconfigPath()); + + result[feature.name] = await feature.featureStatus(kc); + } + + logger.debug(`getFeatures resolving with features: ${JSON.stringify(result)}`); + return result; } -export async function installFeature(name: string, cluster: Cluster, config: any) { - const feature = ALL_FEATURES[name] as Feature +export async function installFeature(name: string, cluster: Cluster, config: any): Promise { // TODO Figure out how to handle config stuff - await feature.install(cluster) + return ALL_FEATURES.get(name).install(cluster) } -export async function upgradeFeature(name: string, cluster: Cluster, config: any) { - const feature = ALL_FEATURES[name] as Feature +export async function upgradeFeature(name: string, cluster: Cluster, config: any): Promise { // TODO Figure out how to handle config stuff - await feature.upgrade(cluster) + return ALL_FEATURES.get(name).upgrade(cluster) } -export async function uninstallFeature(name: string, cluster: Cluster) { - const feature = ALL_FEATURES[name] as Feature - - await feature.uninstall(cluster) +export async function uninstallFeature(name: string, cluster: Cluster): Promise { + return ALL_FEATURES.get(name).uninstall(cluster) } diff --git a/src/main/feature.ts b/src/main/feature.ts index eece69d2b1..a62012af5e 100644 --- a/src/main/feature.ts +++ b/src/main/feature.ts @@ -1,96 +1,83 @@ import fs from "fs"; import path from "path" -import * as hb from "handlebars" +import hb from "handlebars" import { ResourceApplier } from "./resource-applier" -import { KubeConfig, CoreV1Api, Watch } from "@kubernetes/client-node" -import logger from "./logger"; +import { CoreV1Api, KubeConfig, Watch } from "@kubernetes/client-node" import { Cluster } from "./cluster"; +import logger from "./logger"; -export type FeatureStatus = { +export type FeatureStatusMap = Record +export type FeatureMap = Record + +export interface FeatureInstallRequest { + clusterId: string; + name: string; + config?: any; +} + +export interface FeatureStatus { currentVersion: string; installed: boolean; latestVersion: string; canUpgrade: boolean; - // TODO We need bunch of other stuff too: upgradeable, latestVersion, ... -}; - -export type FeatureStatusMap = { - [name: string]: FeatureStatus; } export abstract class Feature { name: string; - config: any; latestVersion: string; - constructor(config: any) { - if(config) { - this.config = config; - } - } + abstract async upgrade(cluster: Cluster): Promise; - // TODO Return types for these? - async install(cluster: Cluster): Promise { - return new Promise((resolve, reject) => { - // Read and process yamls through handlebar - const resources = this.renderTemplates(); - - // Apply processed manifests - cluster.contextHandler.withTemporaryKubeconfig(async (kubeconfigPath) => { - const resourceApplier = new ResourceApplier(cluster, kubeconfigPath) - try { - await resourceApplier.kubectlApplyAll(resources) - resolve(true) - } catch(error) { - reject(error) - } - }); - }); - } - - abstract async upgrade(cluster: Cluster): Promise; - - abstract async uninstall(cluster: Cluster): Promise; + abstract async uninstall(cluster: Cluster): Promise; abstract async featureStatus(kc: KubeConfig): Promise; + constructor(public config: any) { + } + + async install(cluster: Cluster): Promise { + const resources = this.renderTemplates(); + try { + await new ResourceApplier(cluster).kubectlApplyAll(resources); + } catch (err) { + logger.error("Installing feature error", { err, cluster }); + throw err; + } + } + protected async deleteNamespace(kc: KubeConfig, name: string) { return new Promise(async (resolve, reject) => { const client = kc.makeApiClient(CoreV1Api) const result = await client.deleteNamespace("lens-metrics", 'false', undefined, undefined, undefined, "Foreground"); const nsVersion = result.body.metadata.resourceVersion; const nsWatch = new Watch(kc); - const req = await nsWatch.watch('/api/v1/namespaces', {resourceVersion: nsVersion, fieldSelector: "metadata.name=lens-metrics"}, - (type, obj) => { - if(type === 'DELETED') { + const query: Record = { + resourceVersion: nsVersion, + fieldSelector: "metadata.name=lens-metrics", + } + const req = await nsWatch.watch('/api/v1/namespaces', query, + (phase, obj) => { + if (phase === 'DELETED') { logger.debug(`namespace ${name} finally gone`) req.abort(); resolve() } }, - (err) => { - if(err) { - reject(err) - } + (err?: any) => { + if (err) reject(err); }); }); } protected renderTemplates(): string[] { - console.log("starting to render resources..."); const resources: string[] = []; - fs.readdirSync(this.manifestPath()).forEach((f) => { - const file = path.join(this.manifestPath(), f); - console.log("processing file:", file) + fs.readdirSync(this.manifestPath()).forEach(filename => { + const file = path.join(this.manifestPath(), filename); const raw = fs.readFileSync(file); - console.log("raw file loaded"); - if(f.endsWith('.hb')) { - console.log("processing HB template"); + if (filename.endsWith('.hb')) { const template = hb.compile(raw.toString()); resources.push(template(this.config)); - console.log("HB template done"); } else { - console.log("using as raw, no HB detected"); resources.push(raw.toString()); } }); @@ -100,7 +87,7 @@ export abstract class Feature { protected manifestPath() { const devPath = path.join(__dirname, "..", 'src/features', this.name); - if(fs.existsSync(devPath)) { + if (fs.existsSync(devPath)) { return devPath; } return path.join(__dirname, "..", 'features', this.name); diff --git a/src/main/file-helpers.ts b/src/main/file-helpers.ts deleted file mode 100644 index 927c1b98a6..0000000000 --- a/src/main/file-helpers.ts +++ /dev/null @@ -1,11 +0,0 @@ -import fs from "fs" - -export function ensureDir(dirname: string) { - if (!fs.existsSync(dirname)) { - fs.mkdirSync(dirname) - } -} - -export function randomFileName(name: string) { - return `${Math.random().toString(36).substring(2, 15)}-${Math.random().toString(36).substring(2, 15)}-${name}` -} diff --git a/src/main/helm-repo-manager.ts b/src/main/helm-repo-manager.ts deleted file mode 100644 index 849b60db67..0000000000 --- a/src/main/helm-repo-manager.ts +++ /dev/null @@ -1,155 +0,0 @@ -import fs from "fs"; -import logger from "./logger"; -import * as yaml from "js-yaml"; -import { promiseExec } from "./promise-exec"; -import { helmCli } from "./helm-cli"; - -type HelmEnv = { - [key: string]: string | undefined; -} - -export type HelmRepo = { - name: string; - url: string; - cacheFilePath?: string; -} - -export class HelmRepoManager { - private static instance: HelmRepoManager; - public static cache = {} - protected helmEnv: HelmEnv - protected initialized: boolean - - static getInstance(): HelmRepoManager { - if(!HelmRepoManager.instance) { - HelmRepoManager.instance = new HelmRepoManager() - } - return HelmRepoManager.instance; - } - - private constructor() { - // use singleton getInstance() - } - - public async init() { - const helm = await helmCli.binaryPath() - if (!this.initialized) { - this.helmEnv = await this.parseHelmEnv() - await this.update() - this.initialized = true - } - } - - protected async parseHelmEnv() { - const helm = await helmCli.binaryPath() - const { stdout } = await promiseExec(`"${helm}" env`).catch((error) => { throw(error.stderr)}) - const lines = stdout.split(/\r?\n/) // split by new line feed - const env: HelmEnv = {} - lines.forEach((line: string) => { - const [key, value] = line.split("=") - if (key && value) { - env[key] = value.replace(/"/g, "") // strip quotas - } - }) - return env - } - - public async repositories(): Promise> { - if(!this.initialized) { - await this.init() - } - const repositoryFilePath = this.helmEnv.HELM_REPOSITORY_CONFIG - const repoFile = await fs.promises.readFile(repositoryFilePath, 'utf8').catch(async (error) => { - return null - }) - if(!repoFile) { - await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" }) - return await this.repositories() - } - try { - const repositories = yaml.safeLoad(repoFile) - const result = repositories['repositories'].map((repository: HelmRepo) => { - return { - name: repository.name, - url: repository.url, - cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repository['name']}-index.yaml` - } - }); - if (result.length == 0) { - await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" }) - return await this.repositories() - } - return result - } catch (error) { - logger.debug(error) - return [] - } - } - - public async repository(name: string) { - const repositories = await this.repositories() - return repositories.find((repo: HelmRepo) => { - return repo.name == name - }) - } - - public async update() { - const helm = await helmCli.binaryPath() - logger.debug(`${helm} repo update`) - - const {stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => { return { stdout: error.stdout } }) - return stdout - } - - protected async addRepositories(repositories: HelmRepo[]){ - const currentRepositories = await this.repositories() - repositories.forEach(async (repo: HelmRepo) => { - try { - const repoExists = currentRepositories.find((currentRepo: HelmRepo) => { - return currentRepo.url == repo.url - }) - if(!repoExists) { - await this.addRepo(repo) - } - } - catch(error) { - logger.error(JSON.stringify(error)) - } - }); - } - - protected async pruneRepositories(repositoriesToKeep: HelmRepo[]) { - const repositories = await this.repositories() - repositories.filter((repo: HelmRepo) => { - return repositoriesToKeep.find((repoToKeep: HelmRepo) => { - return repo.name == repoToKeep.name - }) === undefined - }).forEach(async (repo: HelmRepo) => { - try { - const output = await this.removeRepo(repo) - logger.debug(output) - } catch(error) { - logger.error(error) - } - }) - - } - - public async addRepo(repository: HelmRepo) { - const helm = await helmCli.binaryPath() - logger.debug(`${helm} repo add ${repository.name} ${repository.url}`) - - const {stdout } = await promiseExec(`"${helm}" repo add ${repository.name} ${repository.url}`).catch((error) => { throw(error.stderr)}) - return stdout - } - - public async removeRepo(repository: HelmRepo): Promise { - const helm = await helmCli.binaryPath() - logger.debug(`${helm} repo remove ${repository.name} ${repository.url}`) - - const { stdout, stderr } = await promiseExec(`"${helm}" repo remove ${repository.name}`).catch((error) => { throw(error.stderr)}) - return stdout - } -} - -export const repoManager = HelmRepoManager.getInstance() diff --git a/src/main/helm-chart-manager.ts b/src/main/helm/helm-chart-manager.ts similarity index 94% rename from src/main/helm-chart-manager.ts rename to src/main/helm/helm-chart-manager.ts index ed2bea5537..e42f7a1aaf 100644 --- a/src/main/helm-chart-manager.ts +++ b/src/main/helm/helm-chart-manager.ts @@ -1,17 +1,18 @@ import fs from "fs"; import * as yaml from "js-yaml"; import { HelmRepo, HelmRepoManager } from "./helm-repo-manager" -import logger from "./logger"; -import { promiseExec } from "./promise-exec" +import logger from "../logger"; +import { promiseExec } from "../promise-exec" import { helmCli } from "./helm-cli" type CachedYaml = { - entries: any; + entries: any; // todo: types } export class HelmChartManager { - protected cache: any + protected cache: any = {} protected repo: HelmRepo + constructor(repo: HelmRepo){ this.cache = HelmRepoManager.cache this.repo = repo diff --git a/src/main/helm-cli.ts b/src/main/helm/helm-cli.ts similarity index 86% rename from src/main/helm-cli.ts rename to src/main/helm/helm-cli.ts index 40856b80dc..1484ceacf1 100644 --- a/src/main/helm-cli.ts +++ b/src/main/helm/helm-cli.ts @@ -1,7 +1,7 @@ -import packageInfo from "../../package.json" +import packageInfo from "../../../package.json" import path from "path" -import { LensBinary, LensBinaryOpts } from "./lens-binary" -import { isProduction } from "../common/vars"; +import { LensBinary, LensBinaryOpts } from "../lens-binary" +import { isProduction } from "../../common/vars"; export class HelmCli extends LensBinary { diff --git a/src/main/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts similarity index 91% rename from src/main/helm-release-manager.ts rename to src/main/helm/helm-release-manager.ts index e1e9137f3f..80be023227 100644 --- a/src/main/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -1,10 +1,10 @@ import * as tempy from "tempy"; import fs from "fs"; import * as yaml from "js-yaml"; -import { promiseExec} from "./promise-exec" +import { promiseExec} from "../promise-exec" import { helmCli } from "./helm-cli"; -import { Cluster } from "./cluster"; -import { toCamelCase } from "../common/utils/camelCase"; +import { Cluster } from "../cluster"; +import { toCamelCase } from "../../common/utils/camelCase"; export class HelmReleaseManager { @@ -54,7 +54,7 @@ export class HelmReleaseManager { await fs.promises.writeFile(fileName, yaml.safeDump(values)) try { - const { stdout, stderr } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.proxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)}) + const { stdout, stderr } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)}) return { log: stdout, release: this.getRelease(name, namespace, cluster) @@ -66,7 +66,7 @@ export class HelmReleaseManager { public async getRelease(name: string, namespace: string, cluster: Cluster) { const helm = await helmCli.binaryPath() - const {stdout, stderr} = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.proxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)}) + const {stdout, stderr} = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr)}) const release = JSON.parse(stdout) release.resources = await this.getResources(name, namespace, cluster) return release @@ -99,8 +99,8 @@ export class HelmReleaseManager { protected async getResources(name: string, namespace: string, cluster: Cluster) { const helm = await helmCli.binaryPath() - const kubectl = await cluster.kubeCtl.kubectlPath() - const pathToKubeconfig = cluster.proxyKubeconfigPath() + const kubectl = await cluster.kubeCtl.getPath() + const pathToKubeconfig = cluster.getProxyKubeconfigPath() const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch((error) => { return { stdout: JSON.stringify({items: []})} }) diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts new file mode 100644 index 0000000000..2c132311c3 --- /dev/null +++ b/src/main/helm/helm-repo-manager.ts @@ -0,0 +1,130 @@ +import yaml from "js-yaml"; +import { readFile } from "fs-extra"; +import { promiseExec } from "../promise-exec"; +import { helmCli } from "./helm-cli"; +import { Singleton } from "../../common/utils/singleton"; +import { customRequestPromise } from "../../common/request"; +import orderBy from "lodash/orderBy"; +import logger from "../logger"; + +export type HelmEnv = Record & { + HELM_REPOSITORY_CACHE?: string; + HELM_REPOSITORY_CONFIG?: string; +} + +export interface HelmRepoConfig { + repositories: HelmRepo[] +} + +export interface HelmRepo { + name: string; + url: string; + cacheFilePath?: string + caFile?: string, + certFile?: string, + insecure_skip_tls_verify?: boolean, + keyFile?: string, + username?: string, + password?: string, +} + +export class HelmRepoManager extends Singleton { + static cache = {} // todo: remove implicit updates in helm-chart-manager.ts + + protected repos: HelmRepo[]; + protected helmEnv: HelmEnv + protected initialized: boolean + + async loadAvailableRepos(): Promise { + const res = await customRequestPromise({ + uri: "https://hub.helm.sh/assets/js/repos.json", + json: true, + resolveWithFullResponse: true, + timeout: 10000, + }); + return orderBy(res.body.data, repo => repo.name); + } + + async init() { + await helmCli.ensureBinary(); + if (!this.initialized) { + this.helmEnv = await this.parseHelmEnv() + await this.update() + this.initialized = true + } + } + + protected async parseHelmEnv() { + const helm = await helmCli.binaryPath() + const { stdout } = await promiseExec(`"${helm}" env`).catch((error) => { + throw(error.stderr) + }) + const lines = stdout.split(/\r?\n/) // split by new line feed + const env: HelmEnv = {} + lines.forEach((line: string) => { + const [key, value] = line.split("=") + if (key && value) { + env[key] = value.replace(/"/g, "") // strip quotas + } + }) + return env + } + + public async repositories(): Promise { + if (!this.initialized) { + await this.init() + } + try { + const repoConfigFile = this.helmEnv.HELM_REPOSITORY_CONFIG; + const { repositories }: HelmRepoConfig = await readFile(repoConfigFile, 'utf8') + .then((yamlContent: string) => yaml.safeLoad(yamlContent)) + .catch(() => ({ + repositories: [] + })); + if (!repositories.length) { + await this.addRepo({ name: "stable", url: "https://kubernetes-charts.storage.googleapis.com/" }); + return await this.repositories(); + } + return repositories.map(repo => ({ + ...repo, + cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml` + })); + } catch (error) { + logger.error(`[HELM]: repositories listing error "${error}"`) + return [] + } + } + + public async repository(name: string) { + const repositories = await this.repositories() + return repositories.find(repo => repo.name == name); + } + + public async update() { + const helm = await helmCli.binaryPath() + const { stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => { + return { stdout: error.stdout } + }) + return stdout + } + + public async addRepo({ name, url }: HelmRepo) { + logger.info(`[HELM]: adding repo "${name}" from ${url}`); + const helm = await helmCli.binaryPath() + const { stdout } = await promiseExec(`"${helm}" repo add ${name} ${url}`).catch((error) => { + throw(error.stderr) + }) + return stdout + } + + public async removeRepo({ name, url }: HelmRepo): Promise { + logger.info(`[HELM]: removing repo "${name}" from ${url}`); + const helm = await helmCli.binaryPath() + const { stdout, stderr } = await promiseExec(`"${helm}" repo remove ${name}`).catch((error) => { + throw(error.stderr) + }) + return stdout + } +} + +export const repoManager = HelmRepoManager.getInstance() diff --git a/src/main/helm-service.ts b/src/main/helm/helm-service.ts similarity index 62% rename from src/main/helm-service.ts rename to src/main/helm/helm-service.ts index ee76e814b8..664a30358c 100644 --- a/src/main/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -1,13 +1,12 @@ -import { Cluster } from "./cluster"; -import logger from "./logger"; +import { Cluster } from "../cluster"; +import logger from "../logger"; import { repoManager } from "./helm-repo-manager"; import { HelmChartManager } from "./helm-chart-manager"; import { releaseManager } from "./helm-release-manager"; class HelmService { - public async installChart(cluster: Cluster, data: {chart: string; values: {}; name: string; namespace: string; version: string}) { - const installResult = await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, cluster.proxyKubeconfigPath()) - return installResult + public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) { + return await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, cluster.getProxyKubeconfigPath()) } public async listCharts() { @@ -19,7 +18,7 @@ class HelmService { const manager = new HelmChartManager(repo) let entries = await manager.charts() entries = this.excludeDeprecated(entries) - for(const key in entries) { + for (const key in entries) { entries[key] = entries[key][0] } charts[repo.name] = entries @@ -48,50 +47,44 @@ class HelmService { public async listReleases(cluster: Cluster, namespace: string = null) { await repoManager.init() - const releases = await releaseManager.listReleases(cluster.proxyKubeconfigPath(), namespace) - return releases + return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace) } - public async getRelease(cluster: Cluster, releaseName: string, namespace: string) { + public async getRelease(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Fetch release") - const release = await releaseManager.getRelease(releaseName, namespace, cluster) - return release + return await releaseManager.getRelease(releaseName, namespace, cluster) } public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Fetch release values") - const values = await releaseManager.getValues(releaseName, namespace, cluster.proxyKubeconfigPath()) - return values + return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath()) } public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Fetch release history") - const history = await releaseManager.getHistory(releaseName, namespace, cluster.proxyKubeconfigPath()) - return(history) + return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath()) } public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) { logger.debug("Delete release") - const release = await releaseManager.deleteRelease(releaseName, namespace, cluster.proxyKubeconfigPath()) - return release + return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath()) } - public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: {chart: string; values: {}; version: string}) { + public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) { logger.debug("Upgrade release") - const release = await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster) - return release + return await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster) } public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) { logger.debug("Rollback release") - const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.proxyKubeconfigPath()) - return({ message: output }) + const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath()) + return { message: output } } protected excludeDeprecated(entries: any) { - for(const key in entries) { + for (const key in entries) { entries[key] = entries[key].filter((entry: any) => { - if(Array.isArray(entry)) { + if (Array.isArray(entry)) { return entry[0]['deprecated'] != true } return entry["deprecated"] != true diff --git a/src/main/index.ts b/src/main/index.ts index fec7f3e553..3014b78c41 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,141 +1,86 @@ // Main process import "../common/system-ca" -import { app, dialog, protocol } from "electron" -import { isMac, vueAppName, isDevelopment } from "../common/vars"; -if (isDevelopment) { - const appName = 'LensDev'; - app.setName(appName); - const appData = app.getPath('appData'); - app.setPath('userData', path.join(appData, appName)); -} import "../common/prometheus-providers" -import { PromiseIpc } from "electron-promise-ipc" +import { app, dialog } from "electron" +import { appName } from "../common/vars"; import path from "path" -import { format as formatUrl } from "url" -import logger from "./logger" -import initMenu from "./menu" -import * as proxy from "./proxy" +import { LensProxy } from "./lens-proxy" import { WindowManager } from "./window-manager"; -import { clusterStore } from "../common/cluster-store" -import { tracker } from "./tracker" import { ClusterManager } from "./cluster-manager"; import AppUpdater from "./app-updater" import { shellSync } from "./shell-sync" import { getFreePort } from "./port" import { mangleProxyEnv } from "./proxy-env" -import { findMainWebContents } from "./webcontents" -import { registerStaticProtocol } from "../common/register-static"; +import { registerFileProtocol } from "../common/register-protocol"; +import { clusterStore } from "../common/cluster-store" +import { userStore } from "../common/user-store"; +import { workspaceStore } from "../common/workspace-store"; +import { tracker } from "../common/tracker"; +import logger from "./logger" + +const workingDir = path.join(app.getPath("appData"), appName); +app.setName(appName); +if(!process.env.CICD) { + app.setPath("userData", workingDir); +} + +let windowManager: WindowManager; +let clusterManager: ClusterManager; +let proxyServer: LensProxy; mangleProxyEnv() if (app.commandLine.getSwitchValue("proxy-server") !== "") { process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server") } -const promiseIpc = new PromiseIpc({ timeout: 2000 }) -let windowManager: WindowManager = null; -let clusterManager: ClusterManager = null; -let proxyServer: proxy.LensProxy = null; - -const vmURL = formatUrl({ - pathname: path.join(__dirname, `${vueAppName}.html`), - protocol: "file", - slashes: true, -}) - async function main() { - shellSync(app.getLocale()) + await shellSync(); + logger.info(`🚀 Starting Lens from "${workingDir}"`) + tracker.event("app", "start"); const updater = new AppUpdater() updater.start(); - tracker.event("app", "start"); + registerFileProtocol("static", __static); - registerStaticProtocol(); - protocol.registerFileProtocol('store', (request, callback) => { - const url = request.url.substr(8) - callback(path.normalize(`${app.getPath("userData")}/${url}`)) - }, (error) => { - if (error) console.error('Failed to register protocol') - }) - - let port: number = null // find free port + let proxyPort: number try { - port = await getFreePort() + proxyPort = await getFreePort() } catch (error) { - logger.error(error) await dialog.showErrorBox("Lens Error", "Could not find a free port for the cluster proxy") app.quit(); } + // preload configuration from stores + await Promise.all([ + userStore.load(), + clusterStore.load(), + workspaceStore.load(), + ]); + // create cluster manager - clusterManager = new ClusterManager(clusterStore.getAllClusterObjects(), port) + clusterManager = new ClusterManager(proxyPort); + // run proxy try { - proxyServer = proxy.listen(port, clusterManager) + proxyServer = LensProxy.create(proxyPort, clusterManager); } catch (error) { - logger.error(`Could not start proxy (127.0.0:${port}): ${error.message}`) - await dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${port}): ${error.message || "unknown error"}`) + logger.error(`Could not start proxy (127.0.0:${proxyPort}): ${error.message}`) + await dialog.showErrorBox("Lens Error", `Could not start proxy (127.0.0:${proxyPort}): ${error.message || "unknown error"}`) app.quit(); } - // boot windowmanager - windowManager = new WindowManager(); - windowManager.showMain(vmURL) - - initMenu({ - logoutHook: async () => { - // IPC send needs webContents as we're sending it to renderer - promiseIpc.send('logout', findMainWebContents(), {}).then((data: any) => { - logger.debug("logout IPC sent"); - }) - }, - showPreferencesHook: async () => { - // IPC send needs webContents as we're sending it to renderer - promiseIpc.send('navigate', findMainWebContents(), { name: 'preferences-page' }).then((data: any) => { - logger.debug("navigate: preferences IPC sent"); - }) - }, - addClusterHook: async () => { - promiseIpc.send('navigate', findMainWebContents(), { name: "add-cluster-page" }).then((data: any) => { - logger.debug("navigate: add-cluster-page IPC sent"); - }) - }, - showWhatsNewHook: async () => { - promiseIpc.send('navigate', findMainWebContents(), { name: "whats-new-page" }).then((data: any) => { - logger.debug("navigate: whats-new-page IPC sent"); - }) - }, - clusterSettingsHook: async () => { - promiseIpc.send('navigate', findMainWebContents(), { name: "cluster-settings-page" }).then((data: any) => { - logger.debug("navigate: cluster-settings-page IPC sent"); - }) - }, - }, promiseIpc) + // create window manager and open app + windowManager = new WindowManager(proxyPort); } -app.on("ready", main) -app.on('window-all-closed', function () { - // On OS X it is common for applications and their menu bar - // to stay active until the user quits explicitly with Cmd + Q - if (!isMac) { - app.quit(); - } else { - windowManager = null - if (clusterManager) clusterManager.stop() - } -}) -app.on("activate", () => { - if (!windowManager) { - logger.debug("activate main window") - windowManager = new WindowManager({ showSplash: false }) - windowManager.showMain(vmURL) - } -}) +app.on("ready", main); + app.on("will-quit", async (event) => { event.preventDefault(); // To allow mixpanel sending to be executed - if (clusterManager) clusterManager.stop() if (proxyServer) proxyServer.close() - app.exit(0); + if (clusterManager) clusterManager.stop() + app.exit(); }) diff --git a/src/main/k8s.ts b/src/main/k8s.ts deleted file mode 100644 index c89e82fb4b..0000000000 --- a/src/main/k8s.ts +++ /dev/null @@ -1,153 +0,0 @@ -import * as k8s from "@kubernetes/client-node" -import * as os from "os" -import * as yaml from "js-yaml" -import logger from "./logger"; - -const kc = new k8s.KubeConfig() - -function resolveTilde(filePath: string) { - if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { - return filePath.replace("~", os.homedir()); - } - return filePath; -} - -export function loadConfig(kubeconfig: string): k8s.KubeConfig { - if (kubeconfig) { - kc.loadFromFile(resolveTilde(kubeconfig)) - } else { - kc.loadFromDefault(); - } - return kc -} - -/** - * KubeConfig is valid when there's atleast one of each defined: - * - User - * - Cluster - * - Context - * - * @param config KubeConfig to check - */ -export function validateConfig(config: k8s.KubeConfig): boolean { - logger.debug(`validating kube config: ${JSON.stringify(config)}`) - if(!config.users || config.users.length == 0) { - throw new Error("No users provided in config") - } - - if(!config.clusters || config.clusters.length == 0) { - throw new Error("No clusters provided in config") - } - - if(!config.contexts || config.contexts.length == 0) { - throw new Error("No contexts provided in config") - } - - return true -} - - -/** - * Breaks kube config into several configs. Each context as it own KubeConfig object - * - * @param configString yaml string of kube config - */ -export function splitConfig(kubeConfig: k8s.KubeConfig): k8s.KubeConfig[] { - const configs: k8s.KubeConfig[] = [] - if(!kubeConfig.contexts) { - return configs; - } - kubeConfig.contexts.forEach(ctx => { - const kc = new k8s.KubeConfig(); - kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n); - kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n) - kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n) - kc.setCurrentContext(ctx.name); - - configs.push(kc); - }); - return configs; -} - -/** - * Loads KubeConfig from a yaml and breaks it into several configs. Each context per KubeConfig object - * - * @param configPath path to kube config yaml file - */ -export function loadAndSplitConfig(configPath: string): k8s.KubeConfig[] { - const allConfigs = new k8s.KubeConfig(); - allConfigs.loadFromFile(configPath); - return splitConfig(allConfigs); -} - -export function dumpConfigYaml(kc: k8s.KubeConfig): string { - const config = { - apiVersion: "v1", - kind: "Config", - preferences: {}, - 'current-context': kc.currentContext, - clusters: kc.clusters.map(c => { - return { - name: c.name, - cluster: { - 'certificate-authority-data': c.caData, - 'certificate-authority': c.caFile, - server: c.server, - 'insecure-skip-tls-verify': c.skipTLSVerify - } - } - }), - contexts: kc.contexts.map(c => { - return { - name: c.name, - context: { - cluster: c.cluster, - user: c.user, - namespace: c.namespace - } - } - }), - users: kc.users.map(u => { - return { - name: u.name, - user: { - 'client-certificate-data': u.certData, - 'client-certificate': u.certFile, - 'client-key-data': u.keyData, - 'client-key': u.keyFile, - 'auth-provider': u.authProvider, - exec: u.exec, - token: u.token, - username: u.username, - password: u.password - } - } - }) - } - - console.log("dumping kc:", config); - - // skipInvalid: true makes dump ignore undefined values - return yaml.safeDump(config, {skipInvalid: true}); -} - -export function podHasIssues(pod: k8s.V1Pod) { - // Logic adapted from dashboard - const notReady = !!pod.status.conditions.find(condition => { - return condition.type == "Ready" && condition.status !== "True" - }); - - return ( - notReady || - pod.status.phase !== "Running" || - pod.spec.priority > 500000 // We're interested in high prio pods events regardless of their running status - ) -} - -// Logic adapted from dashboard -// see: https://github.com/kontena/kontena-k8s-dashboard/blob/7d8f9cb678cc817a22dd1886c5e79415b212b9bf/client/api/endpoints/nodes.api.ts#L147 -export function getNodeWarningConditions(node: k8s.V1Node) { - return node.status.conditions.filter(c => - c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades" - ) -} diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index 743bac88f5..0db75a8a67 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -1,10 +1,14 @@ -import { spawn, ChildProcess } from "child_process" +import { ChildProcess, spawn } from "child_process" +import { waitUntilUsed } from "tcp-port-used"; +import { broadcastIpc } from "../common/ipc"; +import type { Cluster } from "./cluster" +import { bundledKubectl, Kubectl } from "./kubectl" import logger from "./logger" -import * as tcpPortUsed from "tcp-port-used" -import { Kubectl, bundledKubectl } from "./kubectl" -import { Cluster } from "./cluster" -import { PromiseIpc } from "electron-promise-ipc" -import { findMainWebContents } from "./webcontents" + +export interface KubeAuthProxyLog { + data: string; + error?: boolean; // stream=stderr +} export class KubeAuthProxy { public lastError: string @@ -14,58 +18,52 @@ export class KubeAuthProxy { protected proxyProcess: ChildProcess protected port: number protected kubectl: Kubectl - protected promiseIpc: any constructor(cluster: Cluster, port: number, env: NodeJS.ProcessEnv) { this.env = env this.port = port this.cluster = cluster this.kubectl = bundledKubectl - this.promiseIpc = new PromiseIpc({ timeout: 2000 }) } public async run(): Promise { if (this.proxyProcess) { return; } - const proxyBin = await this.kubectl.kubectlPath() - let args = [ + const proxyBin = await this.kubectl.getPath() + const args = [ "proxy", - "-p", this.port.toString(), - "--kubeconfig", this.cluster.kubeConfigPath, - "--context", this.cluster.contextName, + "-p", `${this.port}`, + "--kubeconfig", `${this.cluster.kubeConfigPath}`, + "--context", `${this.cluster.contextName}`, "--accept-hosts", ".*", "--reject-paths", "^[^/]" ] if (process.env.DEBUG_PROXY === "true") { - args = args.concat(["-v", "9"]) + args.push("-v", "9") } logger.debug(`spawning kubectl proxy with args: ${args}`) - this.proxyProcess = spawn(proxyBin, args, { - env: this.env - }) + this.proxyProcess = spawn(proxyBin, args, { env: this.env, }) + this.proxyProcess.on("exit", (code) => { - logger.error(`proxy ${this.cluster.contextName} exited with code ${code}`) - this.sendIpcLogMessage( `proxy exited with code ${code}`, "stderr").catch((err: Error) => { - logger.debug("failed to send IPC log message: " + err.message) - }) - this.proxyProcess = null + this.sendIpcLogMessage({ data: `proxy exited with code: ${code}`, error: code > 0 }) + this.exit(); }) + this.proxyProcess.stdout.on('data', (data) => { let logItem = data.toString() if (logItem.startsWith("Starting to serve on")) { logItem = "Authentication proxy started\n" } - logger.debug(`proxy ${this.cluster.contextName} stdout: ${logItem}`) - this.sendIpcLogMessage(logItem, "stdout") - }) - this.proxyProcess.stderr.on('data', (data) => { - this.lastError = this.parseError(data.toString()) - logger.debug(`proxy ${this.cluster.contextName} stderr: ${data}`) - this.sendIpcLogMessage(data.toString(), "stderr") + this.sendIpcLogMessage({ data: logItem }) }) - return tcpPortUsed.waitUntilUsed(this.port, 500, 10000) + this.proxyProcess.stderr.on('data', (data) => { + this.lastError = this.parseError(data.toString()) + this.sendIpcLogMessage({ data: data.toString(), error: true }) + }) + + return waitUntilUsed(this.port, 500, 10000) } protected parseError(data: string) { @@ -76,21 +74,30 @@ export class KubeAuthProxy { try { const parsedError = JSON.parse(jsonError) errorMsg = parsedError.error_description || parsedError.error || jsonError - } catch(_) { + } catch (_) { errorMsg = jsonError.trim() } } return errorMsg } - protected async sendIpcLogMessage(data: string, stream: string) { - await this.promiseIpc.send(`kube-auth:${this.cluster.id}`, findMainWebContents(), { data, stream }) + protected async sendIpcLogMessage(res: KubeAuthProxyLog) { + const channel = `kube-auth:${this.cluster.id}` + logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() }); + broadcastIpc({ + // webContentId: null, // todo: send a message only to single cluster's window + channel: channel, + args: [res], + }); } public exit() { - if (this.proxyProcess) { - logger.debug(`Stopping local proxy: ${this.cluster.contextName}`) - this.proxyProcess.kill() - } + if (!this.proxyProcess) return; + logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()) + this.proxyProcess.kill() + this.proxyProcess.removeAllListeners(); + this.proxyProcess.stderr.removeAllListeners(); + this.proxyProcess.stdout.removeAllListeners(); + this.proxyProcess = null; } } diff --git a/src/main/kubeconfig-manager.ts b/src/main/kubeconfig-manager.ts index 7123b0158a..5f75899389 100644 --- a/src/main/kubeconfig-manager.ts +++ b/src/main/kubeconfig-manager.ts @@ -1,62 +1,75 @@ +import type { KubeConfig } from "@kubernetes/client-node"; +import type { Cluster } from "./cluster" +import type { ContextHandler } from "./context-handler"; import { app } from "electron" -import fs from "fs" -import { ensureDir, randomFileName} from "./file-helpers" +import path from "path" +import fs from "fs-extra" +import { dumpConfigYaml, loadConfig } from "../common/kube-helpers" import logger from "./logger" -import { Cluster } from "./cluster" -import * as k8s from "./k8s" -import { KubeConfig } from "@kubernetes/client-node" export class KubeconfigManager { protected configDir = app.getPath("temp") - protected tempFile: string - protected cluster: Cluster + protected tempFile: string; - constructor(cluster: Cluster) { - this.cluster = cluster - this.tempFile = this.createTemporaryKubeconfig() + constructor(protected cluster: Cluster, protected contextHandler: ContextHandler) { + this.init(); } - public getPath() { - return this.tempFile + protected async init() { + try { + await this.contextHandler.ensurePort(); + await this.createProxyKubeconfig(); + } catch (err) { + logger.error(`Failed to created temp config for auth-proxy`, { err }) + } + } + + getPath() { + return this.tempFile; } /** - * Creates new "temporary" kubeconfig that point to the kubectl-proxy. + * Creates new "temporary" kubeconfig that point to the kubectl-proxy. * This way any user of the config does not need to know anything about the auth etc. details. */ - protected createTemporaryKubeconfig(): string { - ensureDir(this.configDir) - const path = `${this.configDir}/${randomFileName("kubeconfig")}` - const originalKc = new KubeConfig() - originalKc.loadFromFile(this.cluster.kubeConfigPath) - const kc = { + protected async createProxyKubeconfig(): Promise { + const { configDir, cluster, contextHandler } = this; + const { contextName, kubeConfigPath, id } = cluster; + const tempFile = path.join(configDir, `kubeconfig-${id}`); + const kubeConfig = loadConfig(kubeConfigPath); + const proxyConfig: Partial = { + currentContext: contextName, clusters: [ { - name: this.cluster.contextName, - server: `http://127.0.0.1:${this.cluster.contextHandler.proxyPort}` + name: contextName, + server: await contextHandler.resolveAuthProxyUrl(), + skipTLSVerify: undefined, } ], users: [ - { - name: "proxy" - } + { name: "proxy" }, ], contexts: [ { - name: this.cluster.contextName, - cluster: this.cluster.contextName, - namespace: originalKc.getContextObject(this.cluster.contextName).namespace, - user: "proxy" + user: "proxy", + name: contextName, + cluster: contextName, + namespace: kubeConfig.getContextObject(contextName).namespace, } - ], - currentContext: this.cluster.contextName - } as KubeConfig - fs.writeFileSync(path, k8s.dumpConfigYaml(kc)) - return path + ] + }; + + // write + const configYaml = dumpConfigYaml(proxyConfig); + fs.ensureDir(path.dirname(tempFile)); + fs.writeFileSync(tempFile, configYaml); + this.tempFile = tempFile; + logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`); + return tempFile; } - public unlink() { - logger.debug('Deleting temporary kubeconfig: ' + this.tempFile) + unlink() { + logger.info('Deleting temporary kubeconfig: ' + this.tempFile) fs.unlinkSync(this.tempFile) } } diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index a4c21095d6..ee1c0efdc7 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -1,15 +1,15 @@ import { app, remote } from "electron" import path from "path" import fs from "fs" -import request from "request" -import { promiseExec} from "./promise-exec" +import { promiseExec } from "./promise-exec" import logger from "./logger" import { ensureDir, pathExists } from "fs-extra" -import { globalRequestOpts } from "../common/request" import * as lockFile from "proper-lockfile" -import { helmCli } from "./helm-cli" +import { helmCli } from "./helm/helm-cli" import { userStore } from "../common/user-store" -import { getBundledKubectlVersion} from "../common/utils/app-version" +import { customRequest } from "../common/request"; +import { getBundledKubectlVersion } from "../common/utils/app-version" +import { isDevelopment, isWindows } from "../common/vars"; const bundledVersion = getBundledKubectlVersion() const kubectlMap: Map = new Map([ @@ -32,35 +32,37 @@ const packageMirrors: Map = new Map([ ["china", "https://mirror.azure.cn/kubernetes/kubectl"] ]) +let bundledPath: string const initScriptVersionString = "# lens-initscript v3\n" -const isDevelopment = process.env.NODE_ENV !== "production" -let bundledPath: string = null - -if(isDevelopment) { +if (isDevelopment) { bundledPath = path.join(process.cwd(), "binaries", "client", process.platform, process.arch, "kubectl") } else { bundledPath = path.join(process.resourcesPath, process.arch, "kubectl") } -if(process.platform === "win32") bundledPath = `${bundledPath}.exe` +if (isWindows) { + bundledPath = `${bundledPath}.exe` +} export class Kubectl { - public kubectlVersion: string protected directory: string protected url: string protected path: string protected dirname: string - public static readonly kubectlDir = path.join((app || remote.app).getPath("userData"), "binaries", "kubectl") + static get kubectlDir() { + return path.join((app || remote.app).getPath("userData"), "binaries", "kubectl") + } + public static readonly bundledKubectlPath = bundledPath public static readonly bundledKubectlVersion: string = bundledVersion private static bundledInstance: Kubectl; // Returns the single bundled Kubectl instance public static bundled() { - if(!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion) + if (!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion) return Kubectl.bundledInstance } @@ -69,7 +71,7 @@ export class Kubectl { const minorVersion = versionParts[1] /* minorVersion is the first two digits of kube server version if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */ - if(kubectlMap.has(minorVersion)) { + if (kubectlMap.has(minorVersion)) { this.kubectlVersion = kubectlMap.get(minorVersion) logger.debug("Set kubectl version " + this.kubectlVersion + " for cluster version " + clusterVersion + " using version map") } else { @@ -79,16 +81,16 @@ export class Kubectl { let arch = null - if(process.arch == "x64") { + if (process.arch == "x64") { arch = "amd64" - } else if(process.arch == "x86" || process.arch == "ia32") { + } else if (process.arch == "x86" || process.arch == "ia32") { arch = "386" } else { arch = process.arch } - const platformName = process.platform === "win32" ? "windows" : process.platform - const binaryName = process.platform === "win32" ? "kubectl.exe" : "kubectl" + const platformName = isWindows ? "windows" : process.platform + const binaryName = isWindows ? "kubectl.exe" : "kubectl" this.url = `${this.getDownloadMirror()}/v${this.kubectlVersion}/bin/${platformName}/${arch}/${binaryName}` @@ -96,11 +98,11 @@ export class Kubectl { this.path = path.join(this.dirname, binaryName) } - public async kubectlPath(): Promise { + public async getPath(): Promise { try { await this.ensureKubectl() return this.path - } catch(err) { + } catch (err) { logger.error("Failed to ensure kubectl, fallback to the bundled version") logger.error(err) return Kubectl.bundledKubectlPath @@ -111,7 +113,7 @@ export class Kubectl { try { await this.ensureKubectl() return this.dirname - } catch(err) { + } catch (err) { logger.error(err) return "" } @@ -136,8 +138,7 @@ export class Kubectl { return true } logger.error(`Local kubectl is version ${version}, expected ${this.kubectlVersion}, unlinking`) - } - catch(err) { + } catch (err) { logger.error(`Local kubectl failed to run properly (${err.message}), unlinking`) } await fs.promises.unlink(this.path) @@ -146,7 +147,7 @@ export class Kubectl { } protected async checkBundled(): Promise { - if(this.kubectlVersion === Kubectl.bundledKubectlVersion) { + if (this.kubectlVersion === Kubectl.bundledKubectlVersion) { try { const exist = await pathExists(this.path) if (!exist) { @@ -154,7 +155,7 @@ export class Kubectl { await fs.promises.chmod(this.path, 0o755) } return true - } catch(err) { + } catch (err) { logger.error("Could not copy the bundled kubectl to app-data: " + err) return false } @@ -169,10 +170,15 @@ export class Kubectl { logger.debug(`Acquired a lock for ${this.kubectlVersion}`) const bundled = await this.checkBundled() const isValid = await this.checkBinary(!bundled) - if(!isValid) { - await this.downloadKubectl().catch((error) => { logger.error(error) }); + if (!isValid) { + await this.downloadKubectl().catch((error) => { + logger.error(error) + }); } - await this.writeInitScripts().catch((error) => { logger.error("Failed to write init scripts"); logger.error(error) }) + await this.writeInitScripts().catch((error) => { + logger.error("Failed to write init scripts"); + logger.error(error) + }) logger.debug(`Releasing lock for ${this.kubectlVersion}`) release() return true @@ -188,10 +194,10 @@ export class Kubectl { logger.info(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`) return new Promise((resolve, reject) => { - const stream = request({ + const stream = customRequest({ + url: this.url, gzip: true, - ...this.getRequestOpts() - }) + }); const file = fs.createWriteStream(this.path) stream.on("complete", () => { logger.debug("kubectl binary download finished") @@ -199,12 +205,16 @@ export class Kubectl { }) stream.on("error", (error) => { logger.error(error) - fs.unlink(this.path, null) + fs.unlink(this.path, () => { + // do nothing + }) reject(error) }) file.on("close", () => { logger.debug("kubectl binary download closed") - fs.chmod(this.path, 0o755, null) + fs.chmod(this.path, 0o755, (err) => { + if (err) reject(err); + }) resolve() }) stream.pipe(file) @@ -213,7 +223,7 @@ export class Kubectl { protected async scriptIsLatest(scriptPath: string) { const scriptExists = await pathExists(scriptPath) - if(!scriptExists) return false + if (!scriptExists) return false try { const filehandle = await fs.promises.open(scriptPath, 'r') @@ -232,7 +242,7 @@ export class Kubectl { const fsPromises = fs.promises; const bashScriptPath = path.join(this.dirname, '.bash_set_path') const bashScriptIsLatest = await this.scriptIsLatest(bashScriptPath) - if(!bashScriptIsLatest) { + if (!bashScriptIsLatest) { let bashScript = "" + initScriptVersionString bashScript += "tempkubeconfig=\"$KUBECONFIG\"\n" bashScript += "test -f \"/etc/profile\" && . \"/etc/profile\"\n" @@ -251,7 +261,7 @@ export class Kubectl { const zshScriptPath = path.join(this.dirname, '.zlogin') const zshScriptIsLatest = await this.scriptIsLatest(zshScriptPath) - if(!zshScriptIsLatest) { + if (!zshScriptIsLatest) { let zshScript = "" + initScriptVersionString zshScript += "tempkubeconfig=\"$KUBECONFIG\"\n" @@ -278,14 +288,8 @@ export class Kubectl { } } - protected getRequestOpts() { - return globalRequestOpts({ - url: this.url - }) - } - protected getDownloadMirror() { - const mirror = packageMirrors.get(userStore.getPreferences().downloadMirror) + const mirror = packageMirrors.get(userStore.preferences?.downloadMirror) if (mirror) { return mirror } diff --git a/src/main/kubectl_spec.ts b/src/main/kubectl_spec.ts index 25d1da4676..005361dfa3 100644 --- a/src/main/kubectl_spec.ts +++ b/src/main/kubectl_spec.ts @@ -1,19 +1,7 @@ import packageInfo from "../../package.json" import { bundledKubectl, Kubectl } from "../../src/main/kubectl"; -import { UserStore } from "../common/user-store"; -jest.mock("../common/user-store", () => { - const userStoreMock: Partial = { - getPreferences() { - return { - downloadMirror: "default" - } - } - } - return { - userStore: userStoreMock, - } -}) +jest.mock("../common/user-store"); describe("kubectlVersion", () => { it("returns bundled version if exactly same version used", async () => { diff --git a/src/main/lens-binary.ts b/src/main/lens-binary.ts index beeeda3704..0b0a9e1933 100644 --- a/src/main/lens-binary.ts +++ b/src/main/lens-binary.ts @@ -164,13 +164,17 @@ export class LensBinary { stream.on("error", (error) => { logger.error(error) - fs.unlink(binaryPath, null) + fs.unlink(binaryPath, () => { + // do nothing + }) throw(error) }) return new Promise((resolve, reject) => { file.on("close", () => { logger.debug(`${this.originalBinaryName} binary download closed`) - if (!this.tarPath) fs.chmod(binaryPath, 0o755, null) + if (!this.tarPath) fs.chmod(binaryPath, 0o755, (err) => { + if (err) reject(err); + }) resolve() }) stream.pipe(file) diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts new file mode 100644 index 0000000000..7cc4584750 --- /dev/null +++ b/src/main/lens-proxy.ts @@ -0,0 +1,141 @@ +import net from "net"; +import http from "http"; +import httpProxy from "http-proxy"; +import url from "url"; +import * as WebSocket from "ws" +import { openShell } from "./node-shell-session"; +import { Router } from "./router" +import { ClusterManager } from "./cluster-manager" +import { ContextHandler } from "./context-handler"; +import { apiKubePrefix } from "../common/vars"; +import logger from "./logger" + +export class LensProxy { + protected origin: string + protected proxyServer: http.Server + protected router: Router + protected closed = false + protected retryCounters = new Map() + + static create(port: number, clusterManager: ClusterManager) { + return new LensProxy(port, clusterManager).listen(); + } + + private constructor(protected port: number, protected clusterManager: ClusterManager) { + this.origin = `http://localhost:${port}` + this.router = new Router(); + } + + listen(port = this.port): this { + this.proxyServer = this.buildCustomProxy().listen(port); + logger.info(`LensProxy server has started at ${this.origin}`); + return this; + } + + close() { + logger.info("Closing proxy server"); + this.proxyServer.close() + this.closed = true + } + + protected buildCustomProxy(): http.Server { + const proxy = this.createProxy(); + const customProxy = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => { + this.handleRequest(proxy, req, res); + }); + customProxy.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { + this.handleWsUpgrade(req, socket, head) + }); + customProxy.on("error", (err) => { + logger.error("proxy error", err) + }); + return customProxy; + } + + protected createProxy(): httpProxy { + const proxy = httpProxy.createProxyServer(); + proxy.on("proxyRes", (proxyRes, req, res) => { + if (req.method !== "GET") { + return; + } + if (proxyRes.statusCode === 502) { + const cluster = this.clusterManager.getClusterForRequest(req) + const proxyError = cluster?.contextHandler.proxyLastError; + if (proxyError) { + return res.writeHead(502).end(proxyError); + } + } + const reqId = this.getRequestId(req); + if (this.retryCounters.has(reqId)) { + logger.debug(`Resetting proxy retry cache for url: ${reqId}`); + this.retryCounters.delete(reqId) + } + }) + proxy.on("error", (error, req, res, target) => { + if (this.closed) { + return; + } + if (target) { + logger.debug("Failed proxy to target: " + JSON.stringify(target, null, 2)); + if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) { + const reqId = this.getRequestId(req); + const retryCount = this.retryCounters.get(reqId) || 0 + const timeoutMs = retryCount * 250 + if (retryCount < 20) { + logger.debug(`Retrying proxy request to url: ${reqId}`) + setTimeout(() => { + this.retryCounters.set(reqId, retryCount + 1) + this.handleRequest(proxy, req, res) + }, timeoutMs) + } + } + } + res.writeHead(500).end("Oops, something went wrong.") + }) + + return proxy; + } + + protected createWsListener(): WebSocket.Server { + const ws = new WebSocket.Server({ noServer: true }) + return ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { + const cluster = this.clusterManager.getClusterForRequest(req); + const nodeParam = url.parse(req.url, true).query["node"]?.toString(); + openShell(socket, cluster, nodeParam); + })); + } + + protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise { + if (req.url.startsWith(apiKubePrefix)) { + delete req.headers.authorization + req.url = req.url.replace(apiKubePrefix, "") + const isWatchRequest = req.url.includes("watch=") + return await contextHandler.getApiTarget(isWatchRequest) + } + } + + protected getRequestId(req: http.IncomingMessage) { + return req.headers.host + req.url; + } + + protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) { + const cluster = this.clusterManager.getClusterForRequest(req) + if (cluster) { + await cluster.contextHandler.ensureServer(); + const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler) + if (proxyTarget) { + // allow to fetch apis in "clusterId.localhost:port" from "localhost:port" + res.setHeader("Access-Control-Allow-Origin", this.origin); + return proxy.web(req, res, proxyTarget); + } + } + this.router.route(cluster, req, res); + } + + protected async handleWsUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer) { + const wsServer = this.createWsListener(); + wsServer.handleUpgrade(req, socket, head, (con) => { + wsServer.emit("connection", con, req); + }); + } +} diff --git a/src/main/menu.ts b/src/main/menu.ts index 52a6628998..1ae32994d0 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,60 +1,75 @@ import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, shell, webContents } from "electron" -import { isDevelopment, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars"; +import { autorun } from "mobx"; +import { WindowManager } from "./window-manager"; +import { appName, isMac, issuesTrackerUrl, isWindows, slackUrl } from "../common/vars"; +import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route"; +import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; +import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; +import { clusterSettingsURL } from "../renderer/components/+cluster-settings/cluster-settings.route"; +import logger from "./logger"; -// todo: refactor + split menu sections to separated files, e.g. menus/file.menu.ts - -export interface MenuOptions { - logoutHook: any; - addClusterHook: any; - clusterSettingsHook: any; - showWhatsNewHook: any; - showPreferencesHook: any; - // all the above are really () => void type functions +export function initMenu(windowManager: WindowManager) { + autorun(() => buildMenu(windowManager), { + delay: 100 + }); } -function setClusterSettingsEnabled(enabled: boolean) { - const menuIndex = isMac ? 1 : 0 - Menu.getApplicationMenu().items[menuIndex].submenu.items[1].enabled = enabled -} - -function showAbout(_menuitem: MenuItem, browserWindow: BrowserWindow) { - const appDetails = [ - `Version: ${app.getVersion()}`, - ] - appDetails.push(`Copyright 2020 Mirantis, Inc.`) - let title = "Lens" - if (isWindows) { - title = ` ${title}` +export function buildMenu(windowManager: WindowManager) { + function ignoreOnMac(menuItems: MenuItemConstructorOptions[]) { + if (isMac) return []; + return menuItems; + } + + function activeClusterOnly(menuItems: MenuItemConstructorOptions[]) { + if (!windowManager.activeClusterId) { + menuItems.forEach(item => { + item.enabled = false + }); + } + return menuItems; + } + + function navigate(url: string) { + logger.info(`[MENU]: navigating to ${url}`); + windowManager.navigate({ + channel: "menu:navigate", + url: url, + }) + } + + function showAbout(browserWindow: BrowserWindow) { + const appInfo = [ + `${appName}: ${app.getVersion()}`, + `Electron: ${process.versions.electron}`, + `Chrome: ${process.versions.chrome}`, + `Copyright 2020 Copyright 2020 Mirantis, Inc.`, + ] + dialog.showMessageBoxSync(browserWindow, { + title: `${isWindows ? " ".repeat(2) : ""}${appName}`, + type: "info", + buttons: ["Close"], + message: `Lens`, + detail: appInfo.join("\r\n") + }) } - dialog.showMessageBoxSync(browserWindow, { - title, - type: "info", - buttons: ["Close"], - message: `Lens`, - detail: appDetails.join("\r\n") - }) -} -/** - * Constructs the menu based on the example at: https://electronjs.org/docs/api/menu#main-process - * Menu items are constructed piece-by-piece to have slightly better control on individual sub-menus - * - * @param ipc the main promiceIpc handle. Needed to be able to hook IPC sending into logout click handler. - */ -export default function initMenu(opts: MenuOptions, promiseIpc: any) { const mt: MenuItemConstructorOptions[] = []; + const macAppMenu: MenuItemConstructorOptions = { label: app.getName(), submenu: [ { label: "About Lens", - click: showAbout + click(menuItem: MenuItem, browserWindow: BrowserWindow) { + showAbout(browserWindow) + } }, { type: 'separator' }, { label: 'Preferences', - click: opts.showPreferencesHook, - enabled: true + click() { + navigate(preferencesURL()) + } }, { type: 'separator' }, { role: 'services' }, @@ -66,51 +81,46 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) { { role: 'quit' } ] }; + if (isMac) { mt.push(macAppMenu); } - let fileMenu: MenuItemConstructorOptions; - if (isMac) { - fileMenu = { - label: 'File', - submenu: [{ - label: 'Add Cluster...', - click: opts.addClusterHook, - }, + const fileMenu: MenuItemConstructorOptions = { + label: "File", + submenu: [ { - label: 'Cluster Settings', - click: opts.clusterSettingsHook, - enabled: false - } - ] - } - } - else { - fileMenu = { - label: 'File', - submenu: [ - { - label: 'Add Cluster...', - click: opts.addClusterHook, - }, + label: 'Add Cluster', + click() { + navigate(addClusterURL()) + } + }, + ...activeClusterOnly([ { label: 'Cluster Settings', - click: opts.clusterSettingsHook, - enabled: false - }, + click() { + navigate(clusterSettingsURL({ + params: { + clusterId: windowManager.activeClusterId + } + })) + } + } + ]), + ...ignoreOnMac([ { type: 'separator' }, { label: 'Preferences', - click: opts.showPreferencesHook, - enabled: true + click() { + navigate(preferencesURL()) + } }, { type: 'separator' }, { role: 'quit' } - ] - } - } - mt.push(fileMenu); + ]) + ] + }; + mt.push(fileMenu) const editMenu: MenuItemConstructorOptions = { label: 'Edit', @@ -126,8 +136,7 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) { { role: 'selectAll' }, ] }; - mt.push(editMenu); - + mt.push(editMenu) const viewMenu: MenuItemConstructorOptions = { label: 'View', submenu: [ @@ -135,21 +144,21 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) { label: 'Back', accelerator: 'CmdOrCtrl+[', click() { - webContents.getFocusedWebContents().executeJavaScript('window.history.back()') + webContents.getFocusedWebContents()?.goBack(); } }, { label: 'Forward', accelerator: 'CmdOrCtrl+]', click() { - webContents.getFocusedWebContents().executeJavaScript('window.history.forward()') + webContents.getFocusedWebContents()?.goForward(); } }, { label: 'Reload', accelerator: 'CmdOrCtrl+R', click() { - webContents.getFocusedWebContents().reload() + webContents.getFocusedWebContents()?.reload(); } }, { role: 'toggleDevTools' }, @@ -161,19 +170,19 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) { { role: 'togglefullscreen' } ] }; - mt.push(viewMenu); + mt.push(viewMenu) const helpMenu: MenuItemConstructorOptions = { role: 'help', submenu: [ { - label: 'License', + label: "License", click: async () => { shell.openExternal('https://k8slens.dev/licenses/eula.md'); }, }, { - label: 'Community Slack', + label: "Community Slack", click: async () => { shell.openExternal(slackUrl); }, @@ -186,24 +195,22 @@ export default function initMenu(opts: MenuOptions, promiseIpc: any) { }, { label: "What's new?", - click: opts.showWhatsNewHook, + click() { + navigate(whatsNewURL()) + }, }, - ...(!isMac ? [{ - label: "About Lens", - click: showAbout - } as MenuItemConstructorOptions] : []) + ...ignoreOnMac([ + { + label: "About Lens", + click(menuItem: MenuItem, browserWindow: BrowserWindow) { + showAbout(browserWindow) + } + } + ]) ] }; - mt.push(helpMenu); - const menu = Menu.buildFromTemplate(mt); - Menu.setApplicationMenu(menu); + mt.push(helpMenu) - promiseIpc.on("enableClusterSettingsMenuItem", (clusterId: string) => { - setClusterSettingsEnabled(true) - }); - - promiseIpc.on("disableClusterSettingsMenuItem", () => { - setClusterSettingsEnabled(false) - }); + Menu.setApplicationMenu(Menu.buildFromTemplate(mt)); } diff --git a/src/main/node-shell-session.ts b/src/main/node-shell-session.ts index 6369b6bc8b..b669c262eb 100644 --- a/src/main/node-shell-session.ts +++ b/src/main/node-shell-session.ts @@ -3,25 +3,25 @@ import * as pty from "node-pty" import { ShellSession } from "./shell-session"; import { v4 as uuid } from "uuid" import * as k8s from "@kubernetes/client-node" +import { KubeConfig } from "@kubernetes/client-node" +import { Cluster } from "./cluster" import logger from "./logger"; -import { KubeConfig, V1Pod } from "@kubernetes/client-node"; -import { tracker } from "./tracker" -import { Cluster, ClusterPreferences } from "./cluster" +import { tracker } from "../common/tracker"; export class NodeShellSession extends ShellSession { protected nodeName: string; protected podId: string protected kc: KubeConfig - constructor(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster, nodeName: string) { - super(socket, pathToKubeconfig, cluster) + constructor(socket: WebSocket, cluster: Cluster, nodeName: string) { + super(socket, cluster) this.nodeName = nodeName this.podId = `node-shell-${uuid()}` - this.kc = cluster.proxyKubeconfig() + this.kc = cluster.getProxyKubeconfig() } public async open() { - const shell = await this.kubectl.kubectlPath() + const shell = await this.kubectl.getPath() let args = [] if (this.createNodeShellPod(this.podId, this.nodeName)) { await this.waitForRunningPod(this.podId).catch((error) => { @@ -107,7 +107,7 @@ export class NodeShellSession extends ShellSession { const watch = new k8s.Watch(kc); const req = await watch.watch(`/api/v1/namespaces/kube-system/pods`, {}, - // callback is called for each received object. + // callback is called for each received object. (_type, obj) => { if (obj.metadata.name == podId && obj.status.phase === "Running") { resolve(true) @@ -119,9 +119,13 @@ export class NodeShellSession extends ShellSession { reject(false) } ); - setTimeout(() => { req.abort(); reject(false); }, 120 * 1000); + setTimeout(() => { + req.abort(); + reject(false); + }, 120 * 1000); }) } + protected deleteNodeShellPod() { const kc = this.getKubeConfig(); const k8sApi = kc.makeApiClient(k8s.CoreV1Api); @@ -129,16 +133,13 @@ export class NodeShellSession extends ShellSession { } } -export async function open(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster, nodeName?: string): Promise { - return new Promise(async(resolve, reject) => { - let shell = null - if (nodeName) { - shell = new NodeShellSession(socket, pathToKubeconfig, cluster, nodeName) - } - else { - shell = new ShellSession(socket, pathToKubeconfig, cluster) - } - shell.open() - resolve(shell) - }) +export async function openShell(socket: WebSocket, cluster: Cluster, nodeName?: string): Promise { + let shell: ShellSession; + if (nodeName) { + shell = new NodeShellSession(socket, cluster, nodeName) + } else { + shell = new ShellSession(socket, cluster); + } + shell.open() + return shell; } diff --git a/src/main/port.ts b/src/main/port.ts index bd8b49b94a..b253d3590a 100644 --- a/src/main/port.ts +++ b/src/main/port.ts @@ -1,29 +1,22 @@ +import net, { AddressInfo } from "net" import logger from "./logger" -import { createServer, AddressInfo } from "net" -const getNextAvailablePort = () => { - logger.debug("getNextAvailablePort() start") - const server = createServer() - server.unref() - return new Promise((resolve, reject) => - server - .on('error', (error: any) => reject(error)) - .on('listening', () => { - logger.debug("*** server listening event ***") - const _port = (server.address() as AddressInfo).port - server.close(() => resolve(_port)) - }) - .listen({host: "127.0.0.1", port: 0})) -} +// todo: check https://github.com/http-party/node-portfinder ? -export const getFreePort = async () => { - logger.debug("getFreePort() start") - let freePort: number = null - try { - freePort = await getNextAvailablePort() - logger.debug("got port : " + freePort) - } catch(error) { - throw("getNextAvailablePort() threw: '" + error + "'") - } - return freePort +export async function getFreePort(): Promise { + logger.debug("Lookup new free port.."); + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on("listening", () => { + const port = (server.address() as AddressInfo).port + server.close(() => resolve(port)); + logger.debug(`New port found: ${port}`); + }); + server.on("error", error => { + logger.error(`Can't resolve new port: "${error}"`); + reject(error); + }); + server.listen({ host: "127.0.0.1", port: 0 }) + }) } diff --git a/src/main/port_spec.ts b/src/main/port_spec.ts index bce8cce413..c9be25e514 100644 --- a/src/main/port_spec.ts +++ b/src/main/port_spec.ts @@ -1,6 +1,8 @@ import { EventEmitter } from 'events' import { getFreePort } from "./port" +let newPort = 0; + jest.mock("net", () => { return { createServer() { @@ -10,7 +12,10 @@ jest.mock("net", () => { return this }) address = () => { - return { port: 12345 } + newPort = Math.round(Math.random() * 10000) + return { + port: newPort + } } unref = jest.fn() close = jest.fn(cb => cb()) @@ -21,6 +26,6 @@ jest.mock("net", () => { describe("getFreePort", () => { it("finds the next free port", async () => { - return expect(getFreePort()).resolves.toEqual(expect.any(Number)) + return expect(getFreePort()).resolves.toEqual(newPort); }) }) diff --git a/src/main/proxy.ts b/src/main/proxy.ts deleted file mode 100644 index 2fc28a0adc..0000000000 --- a/src/main/proxy.ts +++ /dev/null @@ -1,167 +0,0 @@ -import http from "http"; -import httpProxy from "http-proxy"; -import { Socket } from "net"; -import * as url from "url"; -import * as WebSocket from "ws" -import { ContextHandler } from "./context-handler"; -import logger from "./logger" -import * as shell from "./node-shell-session" -import { ClusterManager } from "./cluster-manager" -import { Router } from "./router" -import { apiPrefix } from "../common/vars"; - -export class LensProxy { - public static readonly localShellSessions = true - - public port: number; - protected clusterUrl: url.UrlWithStringQuery - protected clusterManager: ClusterManager - protected retryCounters: Map = new Map() - protected router: Router - protected proxyServer: http.Server - protected closed = false - - constructor(port: number, clusterManager: ClusterManager) { - this.port = port - this.clusterManager = clusterManager - this.router = new Router() - } - - public run() { - const proxyServer = this.buildProxyServer(); - proxyServer.listen(this.port, "127.0.0.1") - this.proxyServer = proxyServer - } - - public close() { - logger.info("Closing proxy server") - this.proxyServer.close() - this.closed = true - } - - protected buildProxyServer() { - const proxy = this.createProxy(); - const proxyServer = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => { - this.handleRequest(proxy, req, res); - }); - proxyServer.on("upgrade", (req: http.IncomingMessage, socket: Socket, head: Buffer) => { - this.handleWsUpgrade(req, socket, head) - }); - proxyServer.on("error", (err) => { - logger.error(err) - }); - return proxyServer; - } - - protected createProxy() { - const proxy = httpProxy.createProxyServer(); - - proxy.on("proxyRes", (proxyRes, req, res) => { - if (proxyRes.statusCode === 502) { - const cluster = this.clusterManager.getClusterForRequest(req) - if (cluster && cluster.contextHandler.proxyServerError()) { - res.writeHead(proxyRes.statusCode, { - "Content-Type": "text/plain" - }) - res.end(cluster.contextHandler.proxyServerError()) - return - } - } - if (req.method !== "GET") { - return - } - const key = `${req.headers.host}${req.url}` - if (this.retryCounters.has(key)) { - logger.debug("Resetting proxy retry cache for url: " + key) - this.retryCounters.delete(key) - } - }) - proxy.on("error", (error, req, res, target) => { - if(this.closed) { - return - } - if (target) { - logger.debug("Failed proxy to target: " + JSON.stringify(target)) - if (req.method === "GET" && (!res.statusCode || res.statusCode >= 500)) { - const retryCounterKey = `${req.headers.host}${req.url}` - const retryCount = this.retryCounters.get(retryCounterKey) || 0 - if (retryCount < 20) { - logger.debug("Retrying proxy request to url: " + retryCounterKey) - setTimeout(() => { - this.retryCounters.set(retryCounterKey, retryCount + 1) - this.handleRequest(proxy, req, res) - }, (250 * retryCount)) - } - } - } - res.writeHead(500, { - 'Content-Type': 'text/plain' - }) - res.end('Oops, something went wrong.') - }) - - return proxy; - } - - protected createWsListener() { - const ws = new WebSocket.Server({ noServer: true }) - ws.on("connection", ((con: WebSocket, req: http.IncomingMessage) => { - const cluster = this.clusterManager.getClusterForRequest(req) - const contextHandler = cluster.contextHandler - const nodeParam = url.parse(req.url, true).query["node"]?.toString(); - - contextHandler.withTemporaryKubeconfig((kubeconfigPath) => { - return new Promise(async (resolve, reject) => { - const shellSession = await shell.open(con, kubeconfigPath, cluster, nodeParam) - shellSession.on("exit", () => { - resolve(true) - }) - }) - }) - })) - return ws - } - - protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ContextHandler): Promise { - const prefix = apiPrefix.KUBE_BASE; - if (req.url.startsWith(prefix)) { - delete req.headers.authorization - req.url = req.url.replace(prefix, "") - const isWatchRequest = req.url.includes("watch=") - return await contextHandler.getApiTarget(isWatchRequest) - } - } - - protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) { - const cluster = this.clusterManager.getClusterForRequest(req) - if (!cluster) { - logger.error("Got request to unknown cluster") - logger.debug(req.headers.host + req.url) - res.statusCode = 503 - res.end() - return - } - const contextHandler = cluster.contextHandler - contextHandler.ensureServer().then(async () => { - const proxyTarget = await this.getProxyTarget(req, contextHandler) - if (proxyTarget) { - proxy.web(req, res, proxyTarget) - } else { - this.router.route(cluster, req, res) - } - }) - } - - protected async handleWsUpgrade(req: http.IncomingMessage, socket: Socket, head: Buffer) { - const wsServer = this.createWsListener(); - wsServer.handleUpgrade(req, socket, head, (con) => { - wsServer.emit("connection", con, req); - }); - } -} - -export function listen(port: number, clusterManager: ClusterManager) { - const proxyServer = new LensProxy(port, clusterManager) - proxyServer.run(); - return proxyServer; -} diff --git a/src/main/resource-applier-api.ts b/src/main/resource-applier-api.ts deleted file mode 100644 index 04fa9f117a..0000000000 --- a/src/main/resource-applier-api.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { LensApiRequest } from "./router" -import * as resourceApplier from "./resource-applier" -import { LensApi } from "./lens-api" - -class ResourceApplierApi extends LensApi { - public async applyResource(request: LensApiRequest) { - const { response, cluster, payload } = request - try { - const resource = await resourceApplier.apply(cluster, cluster.proxyKubeconfigPath(), payload) - this.respondJson(response, [resource], 200) - } catch(error) { - this.respondText(response, error, 422) - } - } -} - -export const resourceApplierApi = new ResourceApplierApi() diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 5e13d36c1b..7628166a26 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -1,47 +1,31 @@ +import type { Cluster } from "./cluster"; +import { KubernetesObject } from "@kubernetes/client-node" import { exec } from "child_process"; import fs from "fs"; import * as yaml from "js-yaml"; import path from "path"; import * as tempy from "tempy"; import logger from "./logger" -import { Cluster } from "./cluster"; -import { tracker } from "./tracker"; - -type KubeObject = { - status: {}; - metadata?: { - resourceVersion: number; - annotations?: { - "kubectl.kubernetes.io/last-applied-configuration": string; - }; - }; -} +import { tracker } from "../common/tracker"; +import { cloneJsonObject } from "../common/utils"; export class ResourceApplier { - protected kubeconfigPath: string; - protected cluster: Cluster - - constructor(cluster: Cluster, pathToKubeconfig: string) { - this.kubeconfigPath = pathToKubeconfig - this.cluster = cluster + constructor(protected cluster: Cluster) { } - public async apply(resource: any): Promise { - this.sanitizeObject((resource as KubeObject)) - try { - tracker.event("resource", "apply") - return await this.kubectlApply(yaml.safeDump(resource)) - } catch(error) { - throw (error) - } + async apply(resource: KubernetesObject | any): Promise { + resource = this.sanitizeObject(resource); + tracker.event("resource", "apply") + return await this.kubectlApply(yaml.safeDump(resource)); } protected async kubectlApply(content: string): Promise { - const kubectl = await this.cluster.kubeCtl.kubectlPath() + const { kubeCtl, kubeConfigPath } = this.cluster; + const kubectlPath = await kubeCtl.getPath() return new Promise((resolve, reject) => { - const fileName = tempy.file({name: "resource.yaml"}) + const fileName = tempy.file({ name: "resource.yaml" }) fs.writeFileSync(fileName, content) - const cmd = `"${kubectl}" apply --kubeconfig ${this.kubeconfigPath} -o json -f ${fileName}` + const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${fileName}"` logger.debug("shooting manifests with: " + cmd); const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env) const httpsProxy = this.cluster.preferences?.httpsProxy @@ -62,17 +46,18 @@ export class ResourceApplier { } public async kubectlApplyAll(resources: string[]): Promise { - const kubectl = await this.cluster.kubeCtl.kubectlPath() - return new Promise((resolve, reject) => { + const { kubeCtl, kubeConfigPath } = this.cluster; + const kubectlPath = await kubeCtl.getPath() + return new Promise((resolve, reject) => { const tmpDir = tempy.directory() // Dump each resource into tmpDir - for (const i in resources) { - fs.writeFileSync(path.join(tmpDir, `${i}.yaml`), resources[i]) - } - const cmd = `"${kubectl}" apply --kubeconfig ${this.kubeconfigPath} -o json -f ${tmpDir}` + resources.forEach((resource, index) => { + fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource); + }) + const cmd = `"${kubectlPath}" apply --kubeconfig "${kubeConfigPath}" -o json -f "${tmpDir}"` console.log("shooting manifests with:", cmd); exec(cmd, (error, stdout, stderr) => { - if(error) { + if (error) { reject("Error applying manifests:" + error); } if (stderr != "") { @@ -84,18 +69,14 @@ export class ResourceApplier { }) } - protected sanitizeObject(resource: KubeObject) { - delete resource['status'] - if (resource['metadata']) { - if (resource['metadata']['annotations'] && resource['metadata']['annotations']['kubectl.kubernetes.io/last-applied-configuration']) { - delete resource['metadata']['annotations']['kubectl.kubernetes.io/last-applied-configuration'] - } - delete resource['metadata']['resourceVersion'] + protected sanitizeObject(resource: KubernetesObject | any) { + resource = cloneJsonObject(resource); + delete resource.status; + delete resource.metadata?.resourceVersion; + const annotations = resource.metadata?.annotations; + if (annotations) { + delete annotations['kubectl.kubernetes.io/last-applied-configuration']; } + return resource; } } - -export async function apply(cluster: Cluster, pathToKubeconfig: string, resource: any) { - const resourceApplier = new ResourceApplier(cluster, pathToKubeconfig) - return await resourceApplier.apply(resource) -} diff --git a/src/main/router.ts b/src/main/router.ts index 3381fa139d..976d7cac9c 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -4,71 +4,68 @@ import http from "http" import path from "path" import { readFile } from "fs-extra" import { Cluster } from "./cluster" -import { configRoute } from "./routes/config" -import { helmApi } from "./helm-api" -import { resourceApplierApi } from "./resource-applier-api" -import { kubeconfigRoute } from "./routes/kubeconfig" -import { metricsRoute } from "./routes/metrics" -import { watchRoute } from "./routes/watch" -import { portForwardRoute } from "./routes/port-forward" -import { apiPrefix, outDir, reactAppName } from "../common/vars"; +import { apiPrefix, appName, publicPath } from "../common/vars"; +import { helmRoute, kubeconfigRoute, metricsRoute, portForwardRoute, resourceApplierRoute, watchRoute } from "./routes"; -const mimeTypes: Record = { - "html": "text/html", - "txt": "text/plain", - "css": "text/css", - "gif": "image/gif", - "jpg": "image/jpeg", - "png": "image/png", - "svg": "image/svg+xml", - "js": "application/javascript", - "woff2": "font/woff2", - "ttf": "font/ttf" -}; - -interface RouteParams { - [key: string]: string | undefined; +export interface RouterRequestOpts { + req: http.IncomingMessage; + res: http.ServerResponse; + cluster: Cluster; + params: RouteParams; + url: URL; } -export type LensApiRequest = { - cluster: Cluster; - payload: any; - raw: { - req: http.IncomingMessage; - }; +export interface RouteParams extends Record { + path?: string; // *-route + namespace?: string; + service?: string; + account?: string; + release?: string; + repo?: string; + chart?: string; +} + +export interface LensApiRequest

{ + path: string; + payload: P; params: RouteParams; + cluster: Cluster; response: http.ServerResponse; query: URLSearchParams; - path: string; + raw: { + req: http.IncomingMessage; + } } export class Router { - protected router: any + protected router: any; public constructor() { this.router = new Call.Router(); this.addRoutes() } - public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse) { - const url = new URL(req.url, "http://localhost") + public async route(cluster: Cluster, req: http.IncomingMessage, res: http.ServerResponse): Promise { + const url = new URL(req.url, "http://localhost"); const path = url.pathname const method = req.method.toLowerCase() - const matchingRoute = this.router.route(method, path) - - if (matchingRoute.isBoom !== true) { // route() returns error if route not found -> object.isBoom === true - const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params }) + const matchingRoute = this.router.route(method, path); + const routeFound = !matchingRoute.isBoom; + if (routeFound) { + const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params }); await matchingRoute.route(request) return true - } else { - return false } + return false; } - protected async getRequest(opts: { req: http.IncomingMessage; res: http.ServerResponse; cluster: Cluster; url: URL; params: RouteParams }) { + protected async getRequest(opts: RouterRequestOpts): Promise { const { req, res, url, cluster, params } = opts - const { payload } = await Subtext.parse(req, null, { parse: true, output: 'data' }); - const request: LensApiRequest = { + const { payload } = await Subtext.parse(req, null, { + parse: true, + output: "data", + }); + return { cluster: cluster, path: url.pathname, raw: { @@ -79,67 +76,68 @@ export class Router { payload: payload, params: params } - return request } protected getMimeType(filename: string) { + const mimeTypes: Record = { + html: "text/html", + txt: "text/plain", + css: "text/css", + gif: "image/gif", + jpg: "image/jpeg", + png: "image/png", + svg: "image/svg+xml", + js: "application/javascript", + woff2: "font/woff2", + ttf: "font/ttf" + }; return mimeTypes[path.extname(filename).slice(1)] || "text/plain" } - protected async handleStaticFile(filePath: string, response: http.ServerResponse) { - const asset = path.resolve(outDir, filePath); + async handleStaticFile(filePath: string, res: http.ServerResponse) { + const asset = path.join(__static, filePath); try { const data = await readFile(asset); - response.setHeader("Content-Type", this.getMimeType(asset)); - response.write(data) - response.end() + res.setHeader("Content-Type", this.getMimeType(asset)); + res.write(data) + res.end() } catch (err) { - // default to index.html so that react routes work when page is refreshed - this.handleStaticFile(`${reactAppName}.html`, response) + this.handleStaticFile(`${publicPath}/${appName}.html`, res); } } protected addRoutes() { - const { - BASE: apiBase, - KUBE_HELM: apiHelm, - KUBE_RESOURCE_APPLIER: apiResource, - } = apiPrefix; - // Static assets - this.router.add({ method: 'get', path: '/{path*}' }, (request: LensApiRequest) => { - const { response, params } = request - const file = params.path || "/index.html" - this.handleStaticFile(file, response) - }) + this.router.add({ method: 'get', path: '/{path*}' }, ({ params, response }: LensApiRequest) => { + this.handleStaticFile(params.path, response); + }); - this.router.add({ method: "get", path: `${apiBase}/config` }, configRoute.routeConfig.bind(configRoute)) - this.router.add({ method: "get", path: `${apiBase}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute)) + this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute)) // Watch API - this.router.add({ method: "get", path: `${apiBase}/watch` }, watchRoute.routeWatch.bind(watchRoute)) + this.router.add({ method: "get", path: `${apiPrefix}/watch` }, watchRoute.routeWatch.bind(watchRoute)) // Metrics API - this.router.add({ method: "post", path: `${apiBase}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)) + this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)) // Port-forward API - this.router.add({ method: "post", path: `${apiBase}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, portForwardRoute.routePortForward.bind(portForwardRoute)) + this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, portForwardRoute.routePortForward.bind(portForwardRoute)) // Helm API - this.router.add({ method: "get", path: `${apiHelm}/v2/charts` }, helmApi.listCharts.bind(helmApi)) - this.router.add({ method: "get", path: `${apiHelm}/v2/charts/{repo}/{chart}` }, helmApi.getChart.bind(helmApi)) - this.router.add({ method: "get", path: `${apiHelm}/v2/charts/{repo}/{chart}/values` }, helmApi.getChartValues.bind(helmApi)) + this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, helmRoute.listCharts.bind(helmRoute)) + this.router.add({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}` }, helmRoute.getChart.bind(helmRoute)) + this.router.add({ method: "get", path: `${apiPrefix}/v2/charts/{repo}/{chart}/values` }, helmRoute.getChartValues.bind(helmRoute)) - this.router.add({ method: "post", path: `${apiHelm}/v2/releases` }, helmApi.installChart.bind(helmApi)) - this.router.add({ method: `put`, path: `${apiHelm}/v2/releases/{namespace}/{release}` }, helmApi.updateRelease.bind(helmApi)) - this.router.add({ method: `put`, path: `${apiHelm}/v2/releases/{namespace}/{release}/rollback` }, helmApi.rollbackRelease.bind(helmApi)) - this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace?}` }, helmApi.listReleases.bind(helmApi)) - this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace}/{release}` }, helmApi.getRelease.bind(helmApi)) - this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace}/{release}/values` }, helmApi.getReleaseValues.bind(helmApi)) - this.router.add({ method: "get", path: `${apiHelm}/v2/releases/{namespace}/{release}/history` }, helmApi.getReleaseHistory.bind(helmApi)) - this.router.add({ method: "delete", path: `${apiHelm}/v2/releases/{namespace}/{release}` }, helmApi.deleteRelease.bind(helmApi)) + this.router.add({ method: "post", path: `${apiPrefix}/v2/releases` }, helmRoute.installChart.bind(helmRoute)) + this.router.add({ method: `put`, path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, helmRoute.updateRelease.bind(helmRoute)) + this.router.add({ method: `put`, path: `${apiPrefix}/v2/releases/{namespace}/{release}/rollback` }, helmRoute.rollbackRelease.bind(helmRoute)) + this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace?}` }, helmRoute.listReleases.bind(helmRoute)) + this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, helmRoute.getRelease.bind(helmRoute)) + this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/values` }, helmRoute.getReleaseValues.bind(helmRoute)) + this.router.add({ method: "get", path: `${apiPrefix}/v2/releases/{namespace}/{release}/history` }, helmRoute.getReleaseHistory.bind(helmRoute)) + this.router.add({ method: "delete", path: `${apiPrefix}/v2/releases/{namespace}/{release}` }, helmRoute.deleteRelease.bind(helmRoute)) // Resource Applier API - this.router.add({ method: "post", path: `${apiResource}/stack` }, resourceApplierApi.applyResource.bind(resourceApplierApi)) + this.router.add({ method: "post", path: `${apiPrefix}/stack` }, resourceApplierRoute.applyResource.bind(resourceApplierRoute)) } } diff --git a/src/main/routes/config.ts b/src/main/routes/config.ts deleted file mode 100644 index d726a928f8..0000000000 --- a/src/main/routes/config.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { app } from "electron" -import { CoreV1Api } from "@kubernetes/client-node" -import { LensApiRequest } from "../router" -import { LensApi } from "../lens-api" -import { userStore } from "../../common/user-store" -import { Cluster } from "../cluster" - -export interface IConfigRoutePayload { - kubeVersion?: string; - clusterName?: string; - lensVersion?: string; - lensTheme?: string; - username?: string; - token?: string; - allowedNamespaces?: string[]; - allowedResources?: string[]; - isClusterAdmin?: boolean; - chartsEnabled: boolean; - kubectlAccess?: boolean; // User accessed via kubectl-lens plugin -} - -// TODO: auto-populate all resources dynamically -const apiResources = [ - { resource: "configmaps" }, - { resource: "cronjobs", group: "batch" }, - { resource: "customresourcedefinitions", group: "apiextensions.k8s.io" }, - { resource: "daemonsets", group: "apps" }, - { resource: "deployments", group: "apps" }, - { resource: "endpoints" }, - { resource: "events" }, - { resource: "horizontalpodautoscalers" }, - { resource: "ingresses", group: "networking.k8s.io" }, - { resource: "jobs", group: "batch" }, - { resource: "namespaces" }, - { resource: "networkpolicies", group: "networking.k8s.io" }, - { resource: "nodes" }, - { resource: "persistentvolumes" }, - { resource: "pods" }, - { resource: "podsecuritypolicies" }, - { resource: "resourcequotas" }, - { resource: "secrets" }, - { resource: "services" }, - { resource: "statefulsets", group: "apps" }, - { resource: "storageclasses", group: "storage.k8s.io" }, -] - -async function getAllowedNamespaces(cluster: Cluster) { - const api = cluster.proxyKubeconfig().makeApiClient(CoreV1Api) - try { - const namespaceList = await api.listNamespace() - const nsAccessStatuses = await Promise.all( - namespaceList.body.items.map(ns => cluster.canI({ - namespace: ns.metadata.name, - resource: "pods", - verb: "list", - })) - ) - return namespaceList.body.items - .filter((ns, i) => nsAccessStatuses[i]) - .map(ns => ns.metadata.name) - } catch(error) { - const ctx = cluster.proxyKubeconfig().getContextObject(cluster.contextName) - if (ctx.namespace) { - return [ctx.namespace] - } - else { - return [] - } - } -} - -async function getAllowedResources(cluster: Cluster, namespaces: string[]) { - try { - const resourceAccessStatuses = await Promise.all( - apiResources.map(apiResource => cluster.canI({ - resource: apiResource.resource, - group: apiResource.group, - verb: "list", - namespace: namespaces[0] - })) - ) - return apiResources - .filter((resource, i) => resourceAccessStatuses[i]).map(apiResource => apiResource.resource) - } catch (error) { - return [] - } -} - -class ConfigRoute extends LensApi { - public async routeConfig(request: LensApiRequest) { - const { params, response, cluster } = request - - const namespaces = await getAllowedNamespaces(cluster) - const data: IConfigRoutePayload = { - clusterName: cluster.contextName, - lensVersion: app.getVersion(), - lensTheme: `kontena-${userStore.getPreferences().colorTheme}`, - kubeVersion: cluster.version, - chartsEnabled: true, - isClusterAdmin: cluster.isAdmin, - allowedResources: await getAllowedResources(cluster, namespaces), - allowedNamespaces: namespaces - }; - - this.respondJson(response, data) - } -} - -export const configRoute = new ConfigRoute() diff --git a/src/main/helm-api.ts b/src/main/routes/helm-route.ts similarity index 93% rename from src/main/helm-api.ts rename to src/main/routes/helm-route.ts index 266d8b0dd6..0ffd7252d5 100644 --- a/src/main/helm-api.ts +++ b/src/main/routes/helm-route.ts @@ -1,9 +1,9 @@ -import { LensApiRequest } from "./router" -import { helmService } from "./helm-service" -import { LensApi } from "./lens-api" -import logger from "./logger" +import { LensApiRequest } from "../router" +import { helmService } from "../helm/helm-service" +import { LensApi } from "../lens-api" +import logger from "../logger" -class HelmApi extends LensApi { +class HelmApiRoute extends LensApi { public async listCharts(request: LensApiRequest) { const { response } = request const charts = await helmService.listCharts() @@ -111,4 +111,4 @@ class HelmApi extends LensApi { } } -export const helmApi = new HelmApi() +export const helmRoute = new HelmApiRoute() diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts new file mode 100644 index 0000000000..60a0423de4 --- /dev/null +++ b/src/main/routes/index.ts @@ -0,0 +1,6 @@ +export * from "./kubeconfig-route" +export * from "./metrics-route" +export * from "./port-forward-route" +export * from "./watch-route" +export * from "./helm-route" +export * from "./resource-applier-route" diff --git a/src/main/routes/kubeconfig.ts b/src/main/routes/kubeconfig-route.ts similarity index 91% rename from src/main/routes/kubeconfig.ts rename to src/main/routes/kubeconfig-route.ts index 2d77c4faa2..09f1f061cf 100644 --- a/src/main/routes/kubeconfig.ts +++ b/src/main/routes/kubeconfig-route.ts @@ -4,7 +4,7 @@ import { Cluster } from "../cluster" import { CoreV1Api, V1Secret } from "@kubernetes/client-node" function generateKubeConfig(username: string, secret: V1Secret, cluster: Cluster) { - const tokenData = new Buffer(secret.data["token"], "base64") + const tokenData = Buffer.from(secret.data["token"], "base64") return { 'apiVersion': 'v1', 'kind': 'Config', @@ -44,7 +44,7 @@ class KubeconfigRoute extends LensApi { public async routeServiceAccountRoute(request: LensApiRequest) { const { params, response, cluster} = request - const client = cluster.proxyKubeconfig().makeApiClient(CoreV1Api); + const client = cluster.getProxyKubeconfig().makeApiClient(CoreV1Api); const secretList = await client.listNamespacedSecret(params.namespace) const secret = secretList.body.items.find(secret => { const { annotations } = secret.metadata; diff --git a/src/main/routes/metrics.ts b/src/main/routes/metrics-route.ts similarity index 51% rename from src/main/routes/metrics.ts rename to src/main/routes/metrics-route.ts index ea5fb54c7c..2665fca5f2 100644 --- a/src/main/routes/metrics.ts +++ b/src/main/routes/metrics-route.ts @@ -1,34 +1,25 @@ import { LensApiRequest } from "../router" import { LensApi } from "../lens-api" -import requestPromise from "request-promise-native" -import { PrometheusProviderRegistry, PrometheusProvider, PrometheusNodeQuery, PrometheusClusterQuery, PrometheusPodQuery, PrometheusPvcQuery, PrometheusIngressQuery, PrometheusQueryOpts} from "../prometheus/provider-registry" -import { apiPrefix } from "../../common/vars"; +import { PrometheusClusterQuery, PrometheusIngressQuery, PrometheusNodeQuery, PrometheusPodQuery, PrometheusProvider, PrometheusPvcQuery, PrometheusQueryOpts } from "../prometheus/provider-registry" export type IMetricsQuery = string | string[] | { [metricName: string]: string; } class MetricsRoute extends LensApi { - - public async routeMetrics(request: LensApiRequest) { - const { response, cluster} = request - const query: IMetricsQuery = request.payload; - const serverUrl = `http://127.0.0.1:${cluster.port}${apiPrefix.KUBE_BASE}` - const headers = { - "Host": `${cluster.id}.localhost:${cluster.port}`, - "Content-type": "application/json", - } + async routeMetrics(request: LensApiRequest) { + const { response, cluster, payload } = request const queryParams: IMetricsQuery = {} request.query.forEach((value: string, key: string) => { queryParams[key] = value }) - - let metricsUrl: string + let prometheusPath: string let prometheusProvider: PrometheusProvider try { - const prometheusPath = await cluster.contextHandler.getPrometheusPath() - metricsUrl = `${serverUrl}/api/v1/namespaces/${prometheusPath}/proxy${cluster.getPrometheusApiPrefix()}/api/v1/query_range` - prometheusProvider = await cluster.contextHandler.getPrometheusProvider() + [prometheusPath, prometheusProvider] = await Promise.all([ + cluster.contextHandler.getPrometheusPath(), + cluster.contextHandler.getPrometheusProvider() + ]) } catch { this.respondJson(response, {}) return @@ -36,18 +27,10 @@ class MetricsRoute extends LensApi { // prometheus metrics loader const attempts: { [query: string]: number } = {}; const maxAttempts = 5; - const loadMetrics = (orgQuery: string): Promise => { - const query = orgQuery.trim() + const loadMetrics = (promQuery: string): Promise => { + const query = promQuery.trim() const attempt = attempts[query] = (attempts[query] || 0) + 1; - return requestPromise(metricsUrl, { - resolveWithFullResponse: false, - headers: headers, - json: true, - qs: { - query: query, - ...queryParams - } - }).catch(async (error) => { + return cluster.getMetrics(prometheusPath, { query, ...queryParams }).catch(async error => { if (attempt < maxAttempts && (error.statusCode && error.statusCode != 404)) { await new Promise(resolve => setTimeout(resolve, attempt * 1000)); // add delay before repeating request return loadMetrics(query); @@ -63,16 +46,14 @@ class MetricsRoute extends LensApi { // return data in same structure as query let data: any; - if (typeof query === "string") { - data = await loadMetrics(query) - } - else if (Array.isArray(query)) { - data = await Promise.all(query.map(loadMetrics)); - } - else { + if (typeof payload === "string") { + data = await loadMetrics(payload) + } else if (Array.isArray(payload)) { + data = await Promise.all(payload.map(loadMetrics)); + } else { data = {}; const result = await Promise.all( - Object.entries(query).map((queryEntry: any) => { + Object.entries(payload).map((queryEntry: any) => { const queryName: string = queryEntry[0] const queryOpts: PrometheusQueryOpts = queryEntry[1] const queries = prometheusProvider.getQueries(queryOpts) @@ -80,7 +61,7 @@ class MetricsRoute extends LensApi { return loadMetrics(q) }) ); - Object.keys(query).forEach((metricName, index) => { + Object.keys(payload).forEach((metricName, index) => { data[metricName] = result[index]; }); } diff --git a/src/main/routes/port-forward.ts b/src/main/routes/port-forward-route.ts similarity index 96% rename from src/main/routes/port-forward.ts rename to src/main/routes/port-forward-route.ts index ca222596a1..86cf4f0917 100644 --- a/src/main/routes/port-forward.ts +++ b/src/main/routes/port-forward-route.ts @@ -37,7 +37,7 @@ class PortForward { public async start() { this.localPort = await getFreePort() - const kubectlBin = await bundledKubectl.kubectlPath() + const kubectlBin = await bundledKubectl.getPath() const args = [ "--kubeconfig", this.kubeConfig, "port-forward", @@ -88,7 +88,7 @@ class PortForwardRoute extends LensApi { namespace: namespace, name: resourceName, port: port, - kubeConfig: cluster.proxyKubeconfigPath() + kubeConfig: cluster.getProxyKubeconfigPath() }) const started = await portForward.start() if (!started) { diff --git a/src/main/routes/resource-applier-route.ts b/src/main/routes/resource-applier-route.ts new file mode 100644 index 0000000000..56125af8f3 --- /dev/null +++ b/src/main/routes/resource-applier-route.ts @@ -0,0 +1,17 @@ +import { LensApiRequest } from "../router" +import { LensApi } from "../lens-api" +import { ResourceApplier } from "../resource-applier" + +class ResourceApplierApiRoute extends LensApi { + public async applyResource(request: LensApiRequest) { + const { response, cluster, payload } = request + try { + const resource = await new ResourceApplier(cluster).apply(payload); + this.respondJson(response, [resource], 200) + } catch (error) { + this.respondText(response, error, 422) + } + } +} + +export const resourceApplierRoute = new ResourceApplierApiRoute() diff --git a/src/main/routes/watch.ts b/src/main/routes/watch-route.ts similarity index 96% rename from src/main/routes/watch.ts rename to src/main/routes/watch-route.ts index 512e38530f..d88276eaac 100644 --- a/src/main/routes/watch.ts +++ b/src/main/routes/watch-route.ts @@ -87,10 +87,10 @@ class WatchRoute extends LensApi { response.setHeader("Content-Type", "text/event-stream") response.setHeader("Cache-Control", "no-cache") response.setHeader("Connection", "keep-alive") - logger.debug("watch using kubeconfig:" + JSON.stringify(cluster.proxyKubeconfig(), null, 2)) + logger.debug("watch using kubeconfig:" + JSON.stringify(cluster.getProxyKubeconfig(), null, 2)) apis.forEach(apiUrl => { - const watcher = new ApiWatcher(apiUrl, cluster.proxyKubeconfig(), response) + const watcher = new ApiWatcher(apiUrl, cluster.getProxyKubeconfig(), response) watcher.start() watchers.push(watcher) }) diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index dbcddcb125..4b82b2d77d 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -5,10 +5,11 @@ import path from "path" import shellEnv from "shell-env" import { app } from "electron" import { Kubectl } from "./kubectl" -import { tracker } from "./tracker" -import { Cluster, ClusterPreferences } from "./cluster" -import { helmCli } from "./helm-cli" +import { Cluster } from "./cluster" +import { ClusterPreferences } from "../common/cluster-store"; +import { helmCli } from "./helm/helm-cli" import { isWindows } from "../common/vars"; +import { tracker } from "../common/tracker"; export class ShellSession extends EventEmitter { static shellEnvs: Map = new Map() @@ -24,10 +25,10 @@ export class ShellSession extends EventEmitter { protected running = false; protected clusterId: string; - constructor(socket: WebSocket, pathToKubeconfig: string, cluster: Cluster) { + constructor(socket: WebSocket, cluster: Cluster) { super() this.websocket = socket - this.kubeconfigPath = pathToKubeconfig + this.kubeconfigPath = cluster.kubeConfigPath this.kubectl = new Kubectl(cluster.version) this.preferences = cluster.preferences || {} this.clusterId = cluster.id diff --git a/src/main/shell-sync.ts b/src/main/shell-sync.ts index 101fd46bc9..373d0a36ba 100644 --- a/src/main/shell-sync.ts +++ b/src/main/shell-sync.ts @@ -1,5 +1,7 @@ import shellEnv from "shell-env" import os from "os"; +import { app } from "electron"; +import logger from "./logger"; interface Env { [key: string]: string; @@ -9,14 +11,21 @@ interface Env { * shellSync loads what would have been the environment if this application was * run from the command line, into the process.env object. This is especially * useful on macos where this always needs to be done. - * @param locale Should be electron's `app.getLocale()` */ -export function shellSync(locale: string) { +export async function shellSync() { const { shell } = os.userInfo(); - const env: Env = JSON.parse(JSON.stringify(shellEnv.sync(shell))) + + let envVars = {}; + try { + envVars = await shellEnv(shell); + } catch (error) { + logger.error(`shellEnv: ${error}`) + } + + const env: Env = JSON.parse(JSON.stringify(envVars)); if (!env.LANG) { // the LANG env var expects an underscore instead of electron's dash - env.LANG = `${locale.replace('-', '_')}.UTF-8`; + env.LANG = `${app.getLocale().replace('-', '_')}.UTF-8`; } else if (!env.LANG.endsWith(".UTF-8")) { env.LANG += ".UTF-8" } diff --git a/src/main/tracker.ts b/src/main/tracker.ts deleted file mode 100644 index 4a1e7c4267..0000000000 --- a/src/main/tracker.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Tracker } from "../common/tracker" -import { app, remote } from "electron" - -export const tracker = new Tracker(app || remote.app); diff --git a/src/main/webcontents.ts b/src/main/webcontents.ts deleted file mode 100644 index ffdb99b1d1..0000000000 --- a/src/main/webcontents.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { webContents } from "electron" -/** - * Helper to find the correct web contents handle for main window - */ -export function findMainWebContents() { - return webContents.getAllWebContents().find(w => w.getType() === "window"); -} diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index fce16a5190..166f512b70 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -1,89 +1,96 @@ -import { BrowserWindow, shell } from "electron" -import { PromiseIpc } from "electron-promise-ipc" +import type { ClusterId } from "../common/cluster-store"; +import { BrowserWindow, dialog, ipcMain, shell, WebContents, webContents } from "electron" import windowStateKeeper from "electron-window-state" -import { tracker } from "./tracker"; -import { getStaticUrl } from "../common/register-static"; +import { observable } from "mobx"; +import { initMenu } from "./menu"; export class WindowManager { - public mainWindow: BrowserWindow = null; - public splashWindow: BrowserWindow = null; - protected promiseIpc: any + protected mainView: BrowserWindow; + protected splashWindow: BrowserWindow; protected windowState: windowStateKeeper.State; - constructor({ showSplash = true } = {}) { - this.promiseIpc = new PromiseIpc({ timeout: 2000 }) - // Manage main window size&position with persistence + @observable activeClusterId: ClusterId; + + constructor(protected proxyPort: number) { + // Manage main window size and position with state persistence this.windowState = windowStateKeeper({ defaultHeight: 900, defaultWidth: 1440, }); - this.splashWindow = new BrowserWindow({ - width: 500, - height: 300, - backgroundColor: "#1e2124", - center: true, - frame: false, - resizable: false, + const { width, height, x, y } = this.windowState; + this.mainView = new BrowserWindow({ + x, y, width, height, show: false, - webPreferences: { - nodeIntegration: true - } - }) - if (showSplash) { - this.splashWindow.loadURL(getStaticUrl("splash.html")) - this.splashWindow.show() - } - - this.mainWindow = new BrowserWindow({ - show: false, - x: this.windowState.x, - y: this.windowState.y, - width: this.windowState.width, - height: this.windowState.height, - backgroundColor: "#1e2124", + minWidth: 900, + minHeight: 760, titleBarStyle: "hidden", + backgroundColor: "#1e2124", webPreferences: { nodeIntegration: true, - webviewTag: true + nodeIntegrationInSubFrames: true, + enableRemoteModule: true, }, }); - - // Hook window state manager into window lifecycle - this.windowState.manage(this.mainWindow); - - // handle close event - this.mainWindow.on("close", () => { - this.mainWindow = null; - }); + this.windowState.manage(this.mainView); // open external links in default browser (target=_blank, window.open) - this.mainWindow.webContents.on("new-window", (event, url) => { + this.mainView.webContents.on("new-window", (event, url) => { event.preventDefault(); shell.openExternal(url); }); - // handle external links - this.mainWindow.webContents.on("will-navigate", (event, link) => { - if (link.startsWith("http://localhost")) { - return; - } - event.preventDefault(); - shell.openExternal(link); - }) + // track visible cluster from ui + ipcMain.on("cluster-view:change", (event, clusterId: ClusterId) => { + this.activeClusterId = clusterId; + }); - this.mainWindow.on("focus", () => { - tracker.event("app", "focus") - }) + // load & show app + this.showMain(); + initMenu(this); } - public showMain(url: string) { - this.mainWindow.loadURL(url).then(() => { - this.splashWindow.hide() - this.splashWindow.loadURL("data:text/html;charset=utf-8,").then(() => { - this.splashWindow.close() - this.mainWindow.show() - }) - }) + navigate({ url, channel, frameId }: { url: string, channel: string, frameId?: number }) { + if (frameId) { + this.mainView.webContents.sendToFrame(frameId, channel, url); + } else { + this.mainView.webContents.send(channel, url); + } + } + + async showMain() { + try { + await this.showSplash(); + await this.mainView.loadURL(`http://localhost:${this.proxyPort}`) + this.mainView.show(); + this.splashWindow.close(); + } catch (err) { + dialog.showErrorBox("ERROR!", err.toString()) + } + } + + async showSplash() { + if (!this.splashWindow) { + this.splashWindow = new BrowserWindow({ + width: 500, + height: 300, + backgroundColor: "#1e2124", + center: true, + frame: false, + resizable: false, + show: false, + webPreferences: { + nodeIntegration: true + } + }); + await this.splashWindow.loadURL("static://splash.html"); + } + this.splashWindow.show(); + } + + destroy() { + this.windowState.unmanage(); + this.splashWindow.destroy(); + this.mainView.destroy(); } } diff --git a/src/migrations/cluster-store/2.0.0-beta.2.ts b/src/migrations/cluster-store/2.0.0-beta.2.ts index 3ec4f948f5..8a01af5407 100644 --- a/src/migrations/cluster-store/2.0.0-beta.2.ts +++ b/src/migrations/cluster-store/2.0.0-beta.2.ts @@ -1,17 +1,16 @@ /* Early store format had the kubeconfig directly under context name, this moves it under the kubeConfig key */ -import { isTestEnv } from "../../common/vars"; +import { migration } from "../migration-wrapper"; -export function migration(store: any) { - if(!isTestEnv) { - console.log("CLUSTER STORE, MIGRATION: 2.0.0-beta.2"); +export default migration({ + version: "2.0.0-beta.2", + run(store, log) { + for (const value of store) { + const contextName = value[0]; + // Looping all the keys gives out the store internal stuff too... + if (contextName === "__internal__" || value[1].hasOwnProperty('kubeConfig')) continue; + store.set(contextName, { kubeConfig: value[1] }); + } } - for (const value of store) { - const contextName = value[0]; - // Looping all the keys gives out the store internal stuff too... - if(contextName === "__internal__" || value[1].hasOwnProperty('kubeConfig')) continue; - - store.set(contextName, { kubeConfig: value[1] }); - } -} +}) \ No newline at end of file diff --git a/src/migrations/cluster-store/2.4.1.ts b/src/migrations/cluster-store/2.4.1.ts index 7652ebedf6..5789f6cc36 100644 --- a/src/migrations/cluster-store/2.4.1.ts +++ b/src/migrations/cluster-store/2.4.1.ts @@ -1,15 +1,14 @@ // Cleans up a store that had the state related data stored -import { isTestEnv } from "../../common/vars"; +import { migration } from "../migration-wrapper"; -export function migration(store: any) { - if (!isTestEnv) { - console.log("CLUSTER STORE, MIGRATION: 2.4.1"); +export default migration({ + version: "2.4.1", + run(store, log) { + for (const value of store) { + const contextName = value[0]; + if (contextName === "__internal__") continue; + const cluster = value[1]; + store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {} }); + } } - for (const value of store) { - const contextName = value[0]; - if (contextName === "__internal__") continue; - const cluster = value[1]; - - store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {} }); - } -} +}) diff --git a/src/migrations/cluster-store/2.6.0-beta.2.ts b/src/migrations/cluster-store/2.6.0-beta.2.ts index 48d5b48734..0e13afe7a9 100644 --- a/src/migrations/cluster-store/2.6.0-beta.2.ts +++ b/src/migrations/cluster-store/2.6.0-beta.2.ts @@ -1,19 +1,19 @@ // Move cluster icon from root to preferences -import { isTestEnv } from "../../common/vars"; +import { migration } from "../migration-wrapper"; -export function migration(store: any) { - if(!isTestEnv) { - console.log("CLUSTER STORE, MIGRATION: 2.6.0-beta.2"); - } - for (const value of store) { - const clusterKey = value[0]; - if(clusterKey === "__internal__") continue - const cluster = value[1]; - if(!cluster.preferences) cluster.preferences = {}; - if(cluster.icon) { - cluster.preferences.icon = cluster.icon; - delete(cluster["icon"]); +export default migration({ + version: "2.6.0-beta.2", + run(store, log) { + for (const value of store) { + const clusterKey = value[0]; + if (clusterKey === "__internal__") continue + const cluster = value[1]; + if (!cluster.preferences) cluster.preferences = {}; + if (cluster.icon) { + cluster.preferences.icon = cluster.icon; + delete (cluster["icon"]); + } + store.set(clusterKey, { contextName: clusterKey, kubeConfig: value[1].kubeConfig, preferences: value[1].preferences }); } - store.set(clusterKey, { contextName: clusterKey, kubeConfig: value[1].kubeConfig, preferences: value[1].preferences }); } -} +}) diff --git a/src/migrations/cluster-store/2.6.0-beta.3.ts b/src/migrations/cluster-store/2.6.0-beta.3.ts index d8063764c0..11f1a3bce9 100644 --- a/src/migrations/cluster-store/2.6.0-beta.3.ts +++ b/src/migrations/cluster-store/2.6.0-beta.3.ts @@ -1,38 +1,38 @@ -import * as yaml from "js-yaml" -import { isTestEnv } from "../../common/vars"; +import { migration } from "../migration-wrapper"; +import yaml from "js-yaml" -// Convert access token and expiry from arrays into strings -export function migration(store: any) { - if(!isTestEnv) { - console.log("CLUSTER STORE, MIGRATION: 2.6.0-beta.3"); - } - for (const value of store) { - const clusterKey = value[0]; - if(clusterKey === "__internal__") continue - const cluster = value[1]; - if(!cluster.kubeConfig) continue - const kubeConfig = yaml.safeLoad(cluster.kubeConfig) - if(!kubeConfig.hasOwnProperty('users')) continue - const userObj = kubeConfig.users[0] - if (userObj) { - const user = userObj.user - if (user["auth-provider"] && user["auth-provider"].config) { - const authConfig = user["auth-provider"].config - if (authConfig["access-token"]) { - authConfig["access-token"] = `${authConfig["access-token"]}` +export default migration({ + version: "2.6.0-beta.3", + run(store, log) { + for (const value of store) { + const clusterKey = value[0]; + if (clusterKey === "__internal__") continue + const cluster = value[1]; + if (!cluster.kubeConfig) continue + const kubeConfig = yaml.safeLoad(cluster.kubeConfig) + if (!kubeConfig.hasOwnProperty('users')) continue + const userObj = kubeConfig.users[0] + if (userObj) { + const user = userObj.user + if (user["auth-provider"] && user["auth-provider"].config) { + const authConfig = user["auth-provider"].config + if (authConfig["access-token"]) { + authConfig["access-token"] = `${authConfig["access-token"]}` + } + if (authConfig.expiry) { + authConfig.expiry = `${authConfig.expiry}` + } + log(authConfig) + user["auth-provider"].config = authConfig + kubeConfig.users = [{ + name: userObj.name, + user: user + }] + cluster.kubeConfig = yaml.safeDump(kubeConfig) + store.set(clusterKey, cluster) } - if (authConfig.expiry) { - authConfig.expiry = `${authConfig.expiry}` - } - console.log(authConfig) - user["auth-provider"].config = authConfig - kubeConfig.users = [{ - name: userObj.name, - user: user - }] - cluster.kubeConfig = yaml.safeDump(kubeConfig) - store.set(clusterKey, cluster) } } } -} +}) + diff --git a/src/migrations/cluster-store/2.7.0-beta.0.ts b/src/migrations/cluster-store/2.7.0-beta.0.ts index 1b567fe933..22c4e6bba9 100644 --- a/src/migrations/cluster-store/2.7.0-beta.0.ts +++ b/src/migrations/cluster-store/2.7.0-beta.0.ts @@ -1,15 +1,15 @@ // Add existing clusters to "default" workspace -import { isTestEnv } from "../../common/vars"; +import { migration } from "../migration-wrapper"; -export function migration(store: any) { - if(!isTestEnv) { - console.log("CLUSTER STORE, MIGRATION: 2.7.0-beta.0"); +export default migration({ + version: "2.7.0-beta.0", + run(store, log) { + for (const value of store) { + const clusterKey = value[0]; + if(clusterKey === "__internal__") continue + const cluster = value[1]; + cluster.workspace = "default" + store.set(clusterKey, cluster) + } } - for (const value of store) { - const clusterKey = value[0]; - if(clusterKey === "__internal__") continue - const cluster = value[1]; - cluster.workspace = "default" - store.set(clusterKey, cluster) - } -} +}) diff --git a/src/migrations/cluster-store/2.7.0-beta.1.ts b/src/migrations/cluster-store/2.7.0-beta.1.ts index 4918041245..de9e4506d1 100644 --- a/src/migrations/cluster-store/2.7.0-beta.1.ts +++ b/src/migrations/cluster-store/2.7.0-beta.1.ts @@ -1,25 +1,25 @@ -// add id for clusters and store them to array +// Add id for clusters and store them to array +import { migration } from "../migration-wrapper"; import { v4 as uuid } from "uuid" -import { isTestEnv } from "../../common/vars"; -export function migration(store: any) { - if(!isTestEnv) { - console.log("CLUSTER STORE, MIGRATION: 2.7.0-beta.1"); - } - const clusters: any[] = [] - for (const value of store) { - const clusterKey = value[0]; - if(clusterKey === "__internal__") continue - if(clusterKey === "clusters") continue - const cluster = value[1]; - cluster.id = uuid() - if (!cluster.preferences.clusterName) { - cluster.preferences.clusterName = clusterKey +export default migration({ + version: "2.7.0-beta.1", + run(store, log) { + const clusters: any[] = [] + for (const value of store) { + const clusterKey = value[0]; + if (clusterKey === "__internal__") continue + if (clusterKey === "clusters") continue + const cluster = value[1]; + cluster.id = uuid() + if (!cluster.preferences.clusterName) { + cluster.preferences.clusterName = clusterKey + } + clusters.push(cluster) + store.delete(clusterKey) + } + if (clusters.length > 0) { + store.set("clusters", clusters) } - clusters.push(cluster) - store.delete(clusterKey) } - if (clusters.length > 0) { - store.set("clusters", clusters) - } -} +}) diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index d4c867ffbd..adc354d9ef 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -1,39 +1,38 @@ -// move embedded kubeconfig into separate file and add reference to it to cluster settings -import { app } from "electron" -import { ensureDirSync } from "fs-extra" -import * as path from "path" -import { KubeConfig } from "@kubernetes/client-node"; -import { writeEmbeddedKubeConfig } from "../../common/utils/kubeconfig" +// Move embedded kubeconfig into separate file and add reference to it to cluster settings -export function migration(store: any) { - console.log("CLUSTER STORE, MIGRATION: 3.6.0-beta.1"); - const clusters: any[] = [] +import path from "path" +import { app, remote } from "electron" +import { migration } from "../migration-wrapper"; +import { ensureDirSync } from "fs-extra" +import { ClusterModel } from "../../common/cluster-store"; +import { loadConfig, saveConfigToAppFiles } from "../../common/kube-helpers"; - const kubeConfigBase = path.join(app.getPath("userData"), "kubeconfigs") - ensureDirSync(kubeConfigBase) - const storedClusters = store.get("clusters") as any[] - if (!storedClusters) return +export default migration({ + version: "3.6.0-beta.1", + run(store, printLog) { + const migratedClusters: ClusterModel[] = [] + const storedClusters: ClusterModel[] = store.get("clusters"); + const kubeConfigBase = path.join((app || remote.app).getPath("userData"), "kubeconfigs") - console.log("num clusters to migrate: ", storedClusters.length) - for (const cluster of storedClusters ) { - try { - // take the embedded kubeconfig and dump it into a file - const kubeConfigFile = writeEmbeddedKubeConfig(cluster.id, cluster.kubeConfig) - cluster.kubeConfigPath = kubeConfigFile + if (!storedClusters) return; + ensureDirSync(kubeConfigBase); - const kc = new KubeConfig() - kc.loadFromFile(cluster.kubeConfigPath) - cluster.contextName = kc.getCurrentContext() + printLog("Number of clusters to migrate: ", storedClusters.length) + for (const cluster of storedClusters) { + try { + // take the embedded kubeconfig and dump it into a file + cluster.kubeConfigPath = saveConfigToAppFiles(cluster.id, cluster.kubeConfig) + cluster.contextName = loadConfig(cluster.kubeConfigPath).getCurrentContext(); + delete cluster.kubeConfig; + migratedClusters.push(cluster) + } catch (error) { + printLog(`Failed to migrate Kubeconfig for cluster "${cluster.id}"`, error) + } + } - delete cluster.kubeConfig - clusters.push(cluster) - } catch(error) { - console.error("failed to migrate kubeconfig for cluster:", cluster.id) + // "overwrite" the cluster configs + if (migratedClusters.length > 0) { + store.set("clusters", migratedClusters) } } - - // "overwrite" the cluster configs - if (clusters.length > 0) { - store.set("clusters", clusters) - } -} +}) diff --git a/src/migrations/cluster-store/index.ts b/src/migrations/cluster-store/index.ts new file mode 100644 index 0000000000..d178d7106b --- /dev/null +++ b/src/migrations/cluster-store/index.ts @@ -0,0 +1,19 @@ +// Cluster store migrations + +import version200Beta2 from "./2.0.0-beta.2" +import version241 from "./2.4.1" +import version260Beta2 from "./2.6.0-beta.2" +import version260Beta3 from "./2.6.0-beta.3" +import version270Beta0 from "./2.7.0-beta.0" +import version270Beta1 from "./2.7.0-beta.1" +import version360Beta1 from "./3.6.0-beta.1" + +export default { + ...version200Beta2, + ...version241, + ...version260Beta2, + ...version260Beta3, + ...version270Beta0, + ...version270Beta1, + ...version360Beta1, +} \ No newline at end of file diff --git a/src/migrations/migration-wrapper.ts b/src/migrations/migration-wrapper.ts new file mode 100644 index 0000000000..39015b81fb --- /dev/null +++ b/src/migrations/migration-wrapper.ts @@ -0,0 +1,21 @@ +import Config from "conf"; +import { isTestEnv } from "../common/vars"; + +export interface MigrationOpts { + version: string; + run(storeConfig: Config, log: (...args: any[]) => void): void; +} + +function infoLog(...args: any[]) { + if (isTestEnv) return; + console.log(...args); +} + +export function migration({ version, run }: MigrationOpts) { + return { + [version]: (storeConfig: Config) => { + infoLog(`STORE MIGRATION (${storeConfig.path}): ${version}`,); + run(storeConfig, infoLog); + } + }; +} diff --git a/src/migrations/user-store/2.1.0-beta.4.ts b/src/migrations/user-store/2.1.0-beta.4.ts index 987e9a07d1..24c4cde5e3 100644 --- a/src/migrations/user-store/2.1.0-beta.4.ts +++ b/src/migrations/user-store/2.1.0-beta.4.ts @@ -1,4 +1,9 @@ // Add / reset "lastSeenAppVersion" -export function migration(store: any) { - store.set("lastSeenAppVersion", "0.0.0"); -} +import { migration } from "../migration-wrapper"; + +export default migration({ + version: "2.1.0-beta.4", + run(store) { + store.set("lastSeenAppVersion", "0.0.0"); + } +}) diff --git a/src/migrations/user-store/index.ts b/src/migrations/user-store/index.ts new file mode 100644 index 0000000000..895bc5ee18 --- /dev/null +++ b/src/migrations/user-store/index.ts @@ -0,0 +1,7 @@ +// User store migrations + +import version210Beta4 from "./2.1.0-beta.4" + +export default { + ...version210Beta4, +} diff --git a/src/renderer/_vue/App.vue b/src/renderer/_vue/App.vue deleted file mode 100644 index c8544fe3fe..0000000000 --- a/src/renderer/_vue/App.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/renderer/_vue/assets/css/app.scss b/src/renderer/_vue/assets/css/app.scss deleted file mode 100644 index ed8e19eb68..0000000000 --- a/src/renderer/_vue/assets/css/app.scss +++ /dev/null @@ -1,211 +0,0 @@ -@import "custom"; -@import "~typeface-roboto/index.css"; -@import "~material-design-icons/iconfont/material-icons.css"; -@import "~bootstrap/scss/bootstrap"; -@import "~bootstrap-vue/src/index"; -@import "~prismjs/themes/prism-tomorrow.css"; -@import "~vue-prism-editor/dist/VuePrismEditor.css"; - -html, body { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - background-color: $lens-main-bg; - color: $lens-text-color; - font-size: 14px; // font size that is used also on lens UI - -webkit-font-smoothing: antialiased; -} - -body{ - font-family: 'Roboto', sans-serif; -} - -pre { - color: $lens-text-color-light; -} - -#app { - width: 100%; - height: 100%; - & > .main-view{ - width: 100%; - height: 100%; - & > .content{ - position: absolute; - left: 0; - right: 0; - top: 0; - bottom: 0; - overflow: hidden; - } - &.menu-visible > .content{ - left: 70px; - bottom: 20px; - } - } - - select { - appearance: none; - border: 1px solid $lens-pane-bg; - background-color: $lens-menu-bg; - color: $lens-text-color-light; - background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAKxWlDQ1BEaXNwbGF5AABIiZWXd1BT+RbHv/emEhJaAgJSQm+CFOkivQSQ3m2EhBJKjCFg7yyuYEVFBCu4KDZcCyBrQSzYFnsvi7LY10VdbKi8P3iEfW/ezJs9M3fuZ75zft9zzm/uH+cCHKZQJssnNYACqUIeFxrAT0lN4zO6QQUTbLhCRSgqlPnHxEQCwND7b0EAH26BAIDr9kKZLB//LDTFmYUigIgBkCEuFBUAxCGAeCOSyRUAZTsAs+kKmQKgnAPAk6ekpgGUBwB42YPcC4CXkZKaBlCpAHjyhLhAgKoLMNlCoTwbYJsD4BeLshUAOwiAo1QskQJsBYBxohyhGGAfAjCqoGCqGGB3AbDO+JtP9n94Zig9hcJsJQ/OAgBgBkkKZfnCmf/wOv5/FOQXDdWwBMDOkYfFAdABiDt5UyOULM2Iih5iiRgY4pyisMQhFhUGpg2xWBgUoTybHxU5xFmSEIHSRyFIGOLMwuD4IZZPjVPWypIH+g+xUD5ctygvUannZAqU/rNyEpKHuFiSFDXEhXnxEcM5gUpdXhSn7D9TGhowXDdEOXtB4d/mlQiUZxU5CWHK2YXD/WdK/Yc9C1OUvYkzg4KHcxKV+TJFgLKWLD9GmZ+ZH6rUC4vjlWcV8oThfEWM8g5zheExQwx/xCMR8eAjBgFwhgfcEQsoMmcoACBwqmymXJKdo+D7y2T5mXyBVOQwiu/s6OQJpKSm8Qc/ifd3QAAgdJjDWr424GkBkDXDWsYroGU5oD4wrFndBNSigLYKUZG8eFCjAgANLKiDBz0YwQzWsIcz3OANPwQjHNFIQComQ4QcFECO6ZiDhShFOVZhHaqxBXXYib04gGYcxUmcxUVcwU3cRxd68Aq9+IB+giAYBIfgEnqEMWFB2BHOhAcxjggmIok4IpVIJ7IJKVFEzCEWE+VEBVFNbCMaiJ+JI8RJ4jxxlbhLPCZeEO+ILySFZJM80pC0JEeTHqQ/GUEmkJPIbHIaOYssIVeQVWQtuYdsIk+SF8mbZBf5iuyjgKJK0aGYUOwpHpRASjQljZJFkVPmUcoolZRayj5KK6WDcp3SRXlN+UylU7lUPtWe6k0NoyZSRdRp1HnUZdRq6k5qE/U09Tr1MbWX+p3GoRnQ7GheNAEthZZNm04rpVXS6mmHaWdoN2k9tA90Ol2HbkV3p4fRU+m59Nn0ZfRN9EZ6G/0qvZvex2Aw9Bh2DB9GNEPIUDBKGRsYexgnGNcYPYxPTFWmMdOZGcJMY0qZi5iVzF3M48xrzGfMfhUNFQsVL5VoFbHKTJWVKttVWlUuq/So9LM0WVYsH1YCK5e1kFXF2sc6w3rAeq+qqmqq6qkaqypRXaBapbpf9ZzqY9XPbC22LTuQPZFdxF7B3sFuY99lv+dwOJYcP04aR8FZwWngnOI84nxS46o5qAnUxGrz1WrUmtSuqb1RV1G3UPdXn6w+S71S/aD6ZfXXGioalhqBGkKNeRo1Gkc0bmv0aXI1nTSjNQs0l2nu0jyv+VyLoWWpFawl1irRqtM6pdXNpXDNuIFcEXcxdzv3DLeHR+dZ8QS8XF45by+vk9erraU9RjtJe4Z2jfYx7S4dio6ljkAnX2elzgGdWzpfRhiO8B+ROWLpiH0jro34qDtS1083U7dMt1H3pu4XPb5esF6e3mq9Zr2H+lR9W/1Y/en6m/XP6L8eyRvpPVI0smzkgZH3DEgDW4M4g9kGdQaXDPoMjQxDDWWGGwxPGb420jHyM8o1Wmt03OiFMdd4nLHEeK3xCeOXfG2+Pz+fX8U/ze81MTAJMyky2WbSadJvamWaaLrItNH0oRnLzMMsy2ytWbtZr7mx+XjzOea7ze9ZqFh4WORYrLfosPhoaWWZbLnEstnyuZWulcBqltVuqwfWHGtf62nWtdY3bOg2HjZ5NptsrtiStq62ObY1tpftSDs3O4ndJruro2ijPEdJR9WOum3Ptve3L7bfbf/YQcch0mGRQ7PDm9Hmo9NGrx7dMfq7o6tjvuN2x/tOWk7hToucWp3eOds6i5xrnG+4cFxCXOa7tLi8HWM3JnPM5jF3XLmu412XuLa7fnNzd5O77XN74W7unu6+0f22B88jxmOZxzlPmmeA53zPo56fvdy8FF4HvP70tvfO897l/Xys1djMsdvHdvuY+gh9tvl0jeOPSx+3dVyXr4mv0LfW94mfmZ/Yr97vmb+Nf67/Hv83AY4B8oDDAR8DvQLnBrYFUYJCg8qCOoO1ghODq4MfhZiGZIfsDukNdQ2dHdoWRguLCFsddltgKBAJGgS94e7hc8NPR7Aj4iOqI55E2kbKI1vHk+PDx68Z/yDKIkoa1RyNaEH0muiHMVYx02J+iaXHxsTWxD6Nc4qbE9cRz42fEr8r/kNCQMLKhPuJ1olFie1J6kkTkxqSPiYHJVckd6WMTpmbcjFVP1WS2pLGSEtKq0/rmxA8Yd2EnomuE0sn3ppkNWnGpPOT9SfnTz42RX2KcMrBdFp6cvqu9K/CaGGtsC9DkLExo1cUKFoveiX2E68Vv8j0yazIfJblk1WR9TzbJ3tN9osc35zKnNeSQEm15G1uWO6W3I950Xk78gbyk/MbC5gF6QVHpFrSPOnpqUZTZ0y9KrOTlcq6pnlNWzetVx4hry8kCicVtih4CpniUpF10Q9Fj4vHFdcUf5qeNP3gDM0Z0hmXZtrOXDrz2ayQWT/Nps4WzW6fYzJn4ZzHc/3nbptHzMuY1z7fbH7J/J4FoQt2LmQtzFv46yLHRRWL/lqcvLi1xLBkQUn3D6E/7C5VK5WX3l7ivWTLj9QfJT92LnVZumHp9zJx2YVyx/LK8q/LRMsuLHdaXrV8YEXWis6Vbis3r6Kvkq66tdp39c4KzYpZFd1rxq9pWstfW7b2r3VT1p2vHFO5ZT1rfdH6rqrIqpYN5htWbfhanVN9syagpnGjwcalGz9uEm+6ttlv874thlvKt3zZKtl6Z1votqZay9rKOnpdcd3T7UnbO37y+KmhXr++vP7bDumOrp1xO083uDc07DLYtXI3ubto94s9E/dc2Ru0t2Wf/b5tjTqN5fuxv2j/y5/Tf751IOJA+0GPg/sOWRzaeJh7uKyJaJrZ1Nuc09zVktpy9Uj4kfZW79bDvzj8suOoydGaY9rHVh5nHS85PnBi1om+Nlnb65PZJ7vbp7TfP5Vy6sbp2NOdZyLOnDsbcvZUh3/HiXM+546e9zp/5ILHheaLbhebLrleOvyr66+HO906my67X2654nml9erYq8ev+V47eT3o+tkbghsXb0bdvHor8dad2xNvd90R33l+N//u23vF9/rvL3hAe1D2UONh5SODR7W/2fzW2OXWdexx0ONLT+Kf3O8Wdb/6vfD3rz0lTzlPK58ZP2t47vz86IuQF1deTnjZ80r2qv916R+af2x8Y/3m0J9+f17qTenteSt/O/Bu2Xu99zv+GvNXe19M36MPBR/6P5Z90vu087PH544vyV+e9U//yvha9c3mW+v3iO8PBgoGBmRCuRAAQAFAZmUB73YAnFSAewVgTRjcqQEAxOB/ADC4g/xvHty7AQBuQB2AZD8gug3YCsBqAcBpA2L8gAQ/kC4uyuffUZjl4jzopdoM0CoHBt4nAwwb4NvtgYH+5oGBb/UA5R7Q9mFwlwcAqgSwrgOxcPN113tn8F/xL2AUEjlEqYDhAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF9mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE5LTExLTA1VDE1OjUyOjMzKzAyOjAwIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUxMTI1YTgyLWY2YmUtNDY1ZC05NDAyLTk0NWNhZGFjYTkzZSIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjJiNjlhZDExLTI0M2MtMzk0Ny05NDM3LWNiZjkzNTM3ODFiYyIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjRhM2FjMTY3LWVmMTYtNGM0ZC1hNzU2LTNkYmEzY2NiMzU2NiIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJEaXNwbGF5Ij4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo0YTNhYzE2Ny1lZjE2LTRjNGQtYTc1Ni0zZGJhM2NjYjM1NjYiIHN0RXZ0OndoZW49IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE4IChNYWNpbnRvc2gpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo1MTEyNWE4Mi1mNmJlLTQ2NWQtOTQwMi05NDVjYWRhY2E5M2UiIHN0RXZ0OndoZW49IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE4IChNYWNpbnRvc2gpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PviBDxwAAACESURBVDiN7dIxCgIxEEbhb2WvYCPeLQjaiL2FB7DwAoJVKsEDiBfaWyix0GJZs7FIJ043j/yPmTBNSklNTarSvyFo+02MscPsS6YLIczHJljhUQjfseyDoeCKTUGwxq0kgCMOGb7HaQjHPnGLS68/Y5d72OYgEgKmXnsv3uyjmv8p1wueYnsYek7mGucAAAAASUVORK5CYII='); - background-repeat: no-repeat, repeat; - background-position: right .7em top 50%, 0 0; - background-size: .65em auto, 100%; - } -} - -::-webkit-scrollbar { - width: 12px; - -} - -::-webkit-scrollbar-track { - box-shadow: inset 0 0 12px $lens-pane-bg; -} - -::-webkit-scrollbar-thumb { - box-shadow: inset 0 0 12px $lens-pane-bg; -} - -h1, h2, h3, h4, h5, h6{ - color: #fff; - font-size: 20.8px; - font-weight: 300; -} - -.table{ - color: $lens-text-color-light; - th{ - border-top: 1px solid #353a3e; - font-weight: 300; - color: $lens-text-color; - } - td{ - border-top: 1px solid #353a3e; - } - tr:first-child{ - th, td{ - border-top: none; - } - } -} - -.card{ - background: $lens-pane-bg; -} - -.help{ - border-left: 1px solid #353a3e; - padding-top: 20px; - &:first-child{ - padding-top: 0; - } - h3{ - padding: 0.75rem 0 0.75rem 0; - } -} - -.tooltip-inner { - font-size: 12px; -} - -.editor { - height: 400px; - font-size: 12px; - pre { - background-color: #1E1E1E; - } -} - -.aligner-center-center{ - height: 100%; - width: 100%; - display: flex; - align-items: center; - justify-content: center; -} - -.wrapper{ - width:100%; - height:100%; - display:flex; - justify-content:center; - align-items:center; - .error{ - - text-align: center; - i { - color: #dc3545; - font-size: 100px; - } - } -} - -.btn { - line-height: 1.3; -} - -// Force BS modals to use main app font etc. -.modal-open{ - @extend #app -} - -.popover { - background-color: $lens-menu-hl; - - select { - appearance: none; - border: 1px solid $lens-pane-bg; - background-color: $lens-menu-bg; - color: $lens-text-color-light; - background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAKxWlDQ1BEaXNwbGF5AABIiZWXd1BT+RbHv/emEhJaAgJSQm+CFOkivQSQ3m2EhBJKjCFg7yyuYEVFBCu4KDZcCyBrQSzYFnsvi7LY10VdbKi8P3iEfW/ezJs9M3fuZ75zft9zzm/uH+cCHKZQJssnNYACqUIeFxrAT0lN4zO6QQUTbLhCRSgqlPnHxEQCwND7b0EAH26BAIDr9kKZLB//LDTFmYUigIgBkCEuFBUAxCGAeCOSyRUAZTsAs+kKmQKgnAPAk6ekpgGUBwB42YPcC4CXkZKaBlCpAHjyhLhAgKoLMNlCoTwbYJsD4BeLshUAOwiAo1QskQJsBYBxohyhGGAfAjCqoGCqGGB3AbDO+JtP9n94Zig9hcJsJQ/OAgBgBkkKZfnCmf/wOv5/FOQXDdWwBMDOkYfFAdABiDt5UyOULM2Iih5iiRgY4pyisMQhFhUGpg2xWBgUoTybHxU5xFmSEIHSRyFIGOLMwuD4IZZPjVPWypIH+g+xUD5ctygvUannZAqU/rNyEpKHuFiSFDXEhXnxEcM5gUpdXhSn7D9TGhowXDdEOXtB4d/mlQiUZxU5CWHK2YXD/WdK/Yc9C1OUvYkzg4KHcxKV+TJFgLKWLD9GmZ+ZH6rUC4vjlWcV8oThfEWM8g5zheExQwx/xCMR8eAjBgFwhgfcEQsoMmcoACBwqmymXJKdo+D7y2T5mXyBVOQwiu/s6OQJpKSm8Qc/ifd3QAAgdJjDWr424GkBkDXDWsYroGU5oD4wrFndBNSigLYKUZG8eFCjAgANLKiDBz0YwQzWsIcz3OANPwQjHNFIQComQ4QcFECO6ZiDhShFOVZhHaqxBXXYib04gGYcxUmcxUVcwU3cRxd68Aq9+IB+giAYBIfgEnqEMWFB2BHOhAcxjggmIok4IpVIJ7IJKVFEzCEWE+VEBVFNbCMaiJ+JI8RJ4jxxlbhLPCZeEO+ILySFZJM80pC0JEeTHqQ/GUEmkJPIbHIaOYssIVeQVWQtuYdsIk+SF8mbZBf5iuyjgKJK0aGYUOwpHpRASjQljZJFkVPmUcoolZRayj5KK6WDcp3SRXlN+UylU7lUPtWe6k0NoyZSRdRp1HnUZdRq6k5qE/U09Tr1MbWX+p3GoRnQ7GheNAEthZZNm04rpVXS6mmHaWdoN2k9tA90Ol2HbkV3p4fRU+m59Nn0ZfRN9EZ6G/0qvZvex2Aw9Bh2DB9GNEPIUDBKGRsYexgnGNcYPYxPTFWmMdOZGcJMY0qZi5iVzF3M48xrzGfMfhUNFQsVL5VoFbHKTJWVKttVWlUuq/So9LM0WVYsH1YCK5e1kFXF2sc6w3rAeq+qqmqq6qkaqypRXaBapbpf9ZzqY9XPbC22LTuQPZFdxF7B3sFuY99lv+dwOJYcP04aR8FZwWngnOI84nxS46o5qAnUxGrz1WrUmtSuqb1RV1G3UPdXn6w+S71S/aD6ZfXXGioalhqBGkKNeRo1Gkc0bmv0aXI1nTSjNQs0l2nu0jyv+VyLoWWpFawl1irRqtM6pdXNpXDNuIFcEXcxdzv3DLeHR+dZ8QS8XF45by+vk9erraU9RjtJe4Z2jfYx7S4dio6ljkAnX2elzgGdWzpfRhiO8B+ROWLpiH0jro34qDtS1083U7dMt1H3pu4XPb5esF6e3mq9Zr2H+lR9W/1Y/en6m/XP6L8eyRvpPVI0smzkgZH3DEgDW4M4g9kGdQaXDPoMjQxDDWWGGwxPGb420jHyM8o1Wmt03OiFMdd4nLHEeK3xCeOXfG2+Pz+fX8U/ze81MTAJMyky2WbSadJvamWaaLrItNH0oRnLzMMsy2ytWbtZr7mx+XjzOea7ze9ZqFh4WORYrLfosPhoaWWZbLnEstnyuZWulcBqltVuqwfWHGtf62nWtdY3bOg2HjZ5NptsrtiStq62ObY1tpftSDs3O4ndJruro2ijPEdJR9WOum3Ptve3L7bfbf/YQcch0mGRQ7PDm9Hmo9NGrx7dMfq7o6tjvuN2x/tOWk7hToucWp3eOds6i5xrnG+4cFxCXOa7tLi8HWM3JnPM5jF3XLmu412XuLa7fnNzd5O77XN74W7unu6+0f22B88jxmOZxzlPmmeA53zPo56fvdy8FF4HvP70tvfO897l/Xys1djMsdvHdvuY+gh9tvl0jeOPSx+3dVyXr4mv0LfW94mfmZ/Yr97vmb+Nf67/Hv83AY4B8oDDAR8DvQLnBrYFUYJCg8qCOoO1ghODq4MfhZiGZIfsDukNdQ2dHdoWRguLCFsddltgKBAJGgS94e7hc8NPR7Aj4iOqI55E2kbKI1vHk+PDx68Z/yDKIkoa1RyNaEH0muiHMVYx02J+iaXHxsTWxD6Nc4qbE9cRz42fEr8r/kNCQMLKhPuJ1olFie1J6kkTkxqSPiYHJVckd6WMTpmbcjFVP1WS2pLGSEtKq0/rmxA8Yd2EnomuE0sn3ppkNWnGpPOT9SfnTz42RX2KcMrBdFp6cvqu9K/CaGGtsC9DkLExo1cUKFoveiX2E68Vv8j0yazIfJblk1WR9TzbJ3tN9osc35zKnNeSQEm15G1uWO6W3I950Xk78gbyk/MbC5gF6QVHpFrSPOnpqUZTZ0y9KrOTlcq6pnlNWzetVx4hry8kCicVtih4CpniUpF10Q9Fj4vHFdcUf5qeNP3gDM0Z0hmXZtrOXDrz2ayQWT/Nps4WzW6fYzJn4ZzHc/3nbptHzMuY1z7fbH7J/J4FoQt2LmQtzFv46yLHRRWL/lqcvLi1xLBkQUn3D6E/7C5VK5WX3l7ivWTLj9QfJT92LnVZumHp9zJx2YVyx/LK8q/LRMsuLHdaXrV8YEXWis6Vbis3r6Kvkq66tdp39c4KzYpZFd1rxq9pWstfW7b2r3VT1p2vHFO5ZT1rfdH6rqrIqpYN5htWbfhanVN9syagpnGjwcalGz9uEm+6ttlv874thlvKt3zZKtl6Z1votqZay9rKOnpdcd3T7UnbO37y+KmhXr++vP7bDumOrp1xO083uDc07DLYtXI3ubto94s9E/dc2Ru0t2Wf/b5tjTqN5fuxv2j/y5/Tf751IOJA+0GPg/sOWRzaeJh7uKyJaJrZ1Nuc09zVktpy9Uj4kfZW79bDvzj8suOoydGaY9rHVh5nHS85PnBi1om+Nlnb65PZJ7vbp7TfP5Vy6sbp2NOdZyLOnDsbcvZUh3/HiXM+546e9zp/5ILHheaLbhebLrleOvyr66+HO906my67X2654nml9erYq8ev+V47eT3o+tkbghsXb0bdvHor8dad2xNvd90R33l+N//u23vF9/rvL3hAe1D2UONh5SODR7W/2fzW2OXWdexx0ONLT+Kf3O8Wdb/6vfD3rz0lTzlPK58ZP2t47vz86IuQF1deTnjZ80r2qv916R+af2x8Y/3m0J9+f17qTenteSt/O/Bu2Xu99zv+GvNXe19M36MPBR/6P5Z90vu087PH544vyV+e9U//yvha9c3mW+v3iO8PBgoGBmRCuRAAQAFAZmUB73YAnFSAewVgTRjcqQEAxOB/ADC4g/xvHty7AQBuQB2AZD8gug3YCsBqAcBpA2L8gAQ/kC4uyuffUZjl4jzopdoM0CoHBt4nAwwb4NvtgYH+5oGBb/UA5R7Q9mFwlwcAqgSwrgOxcPN113tn8F/xL2AUEjlEqYDhAAAACXBIWXMAAAsTAAALEwEAmpwYAAAF9mlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNZXRhZGF0YURhdGU9IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE5LTExLTA1VDE1OjUyOjMzKzAyOjAwIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjUxMTI1YTgyLWY2YmUtNDY1ZC05NDAyLTk0NWNhZGFjYTkzZSIgeG1wTU06RG9jdW1lbnRJRD0iYWRvYmU6ZG9jaWQ6cGhvdG9zaG9wOjJiNjlhZDExLTI0M2MtMzk0Ny05NDM3LWNiZjkzNTM3ODFiYyIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjRhM2FjMTY3LWVmMTYtNGM0ZC1hNzU2LTNkYmEzY2NiMzU2NiIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHBob3Rvc2hvcDpDb2xvck1vZGU9IjMiIHBob3Rvc2hvcDpJQ0NQcm9maWxlPSJEaXNwbGF5Ij4gPHhtcE1NOkhpc3Rvcnk+IDxyZGY6U2VxPiA8cmRmOmxpIHN0RXZ0OmFjdGlvbj0iY3JlYXRlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo0YTNhYzE2Ny1lZjE2LTRjNGQtYTc1Ni0zZGJhM2NjYjM1NjYiIHN0RXZ0OndoZW49IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE4IChNYWNpbnRvc2gpIi8+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDo1MTEyNWE4Mi1mNmJlLTQ2NWQtOTQwMi05NDVjYWRhY2E5M2UiIHN0RXZ0OndoZW49IjIwMTktMTEtMDVUMTU6NTI6MzMrMDI6MDAiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE4IChNYWNpbnRvc2gpIiBzdEV2dDpjaGFuZ2VkPSIvIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PviBDxwAAACESURBVDiN7dIxCgIxEEbhb2WvYCPeLQjaiL2FB7DwAoJVKsEDiBfaWyix0GJZs7FIJ043j/yPmTBNSklNTarSvyFo+02MscPsS6YLIczHJljhUQjfseyDoeCKTUGwxq0kgCMOGb7HaQjHPnGLS68/Y5d72OYgEgKmXnsv3uyjmv8p1wueYnsYek7mGucAAAAASUVORK5CYII='); - background-repeat: no-repeat, repeat; - background-position: right .7em top 50%, 0 0; - background-size: .65em auto, 100%; - } - - .arrow:after { - border-top-color: $lens-menu-hl; - } -} -.popover-body { - color: $lens-text-color; - - li.list-group-item { - background-color: inherit; - padding: 0.5rem 0.75rem; - - a { - color: var(--lens-text-color-light); - } - } -} -.popover-header{ - color: $lens-text-color; - background-color: $lens-main-bg; - border-bottom: 0px; - - i.material-icons { - position: relative; - top: 3px; - font-size: 16px; - } -} - -#lens-container { - position: absolute; - top: 0; - left: 70px; - right: 0; - height: 100%; - z-index: 100; - display: none; - - > iframe { - height: calc(100% - var(--lens-bottom-bar-height)); - border: none; - } -} diff --git a/src/renderer/_vue/assets/css/custom.scss b/src/renderer/_vue/assets/css/custom.scss deleted file mode 100644 index 45c9eb7a8d..0000000000 --- a/src/renderer/_vue/assets/css/custom.scss +++ /dev/null @@ -1,73 +0,0 @@ -// from Lens Dashboard -$lens-main-bg: #1e2124 !default; // dark bg -$lens-pane-bg: #262b2f !default; // all panels main bg -$lens-dock-bg: #2E3136 !default; // terminal and top menu bar -$lens-menu-bg: #36393E !default; // sidemenu on left -$lens-menu-hl: #414448 !default; // sidemenu on left, top left corner -$lens-text-color: #87909c !default; -$lens-text-color-light: #a0a0a0 !default; -$lens-primary: #3d90ce !default; - -// export as css variables -:root { - --lens-main-bg: #{$lens-main-bg}; // dark bg - --lens-pane-bg: #{$lens-pane-bg}; // all panels main bg - --lens-dock-bg: #{$lens-dock-bg}; // terminal and top menu bar - --lens-menu-bg: #{$lens-menu-bg}; // sidemenu on left - --lens-menu-hl: #{$lens-menu-hl}; // sidemenu on left, top left corner - --lens-text-color: #{$lens-text-color}; - --lens-text-color-light: #{$lens-text-color-light}; - --lens-primary: #{$lens-primary}; - --lens-bottom-bar-height: 20px; -} - -// Base grayscale colors definitions -$white: #fff !default; -$gray-100: #f8f9fa !default; -$gray-200: #e9ecef !default; -$gray-300: #dee2e6 !default; -$gray-400: #ced4da !default; -$gray-500: #adb5bd !default; -$gray-600: #6c757d !default; -$gray-700: #495057 !default; -$gray-800: #343a40 !default; -$gray-900: #1e2124 !default; -$black: #000 !default; - -// Base colors definitions -$blue: #3d90ce !default; -$indigo: #6610f2 !default; -$purple: #6f42c1 !default; -$pink: #e83e8c !default; -$red: #CE3933 !default; -$orange: #fd7e14 !default; -$yellow: #ffc107 !default; -$green: #4caf50 !default; -$teal: #20c997 !default; -$cyan: #6ca5b7 !default; - -// Theme color default definitions -$primary: $lens-primary !default; -$secondary: $gray-600 !default; -$success: $green !default; -$info: $cyan !default; -$warning: $yellow !default; -$danger: $red !default; -$light: $gray-100 !default; -$dark: $gray-800 !default; - -// This table defines the theme colors (variant names) -$theme-colors: () !default; -$theme-colors: map-merge( - ( - 'primary': $primary, - 'secondary': $secondary, - 'success': $success, - 'info': $info, - 'warning': $warning, - 'danger': $danger, - 'light': $light, - 'dark': $dark - ), - $theme-colors -); diff --git a/src/renderer/_vue/assets/img/planet.png b/src/renderer/_vue/assets/img/planet.png deleted file mode 100644 index a812cc3eb0..0000000000 Binary files a/src/renderer/_vue/assets/img/planet.png and /dev/null differ diff --git a/src/renderer/_vue/components/AddClusterPage.vue b/src/renderer/_vue/components/AddClusterPage.vue deleted file mode 100644 index 510521b711..0000000000 --- a/src/renderer/_vue/components/AddClusterPage.vue +++ /dev/null @@ -1,308 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/AddWorkspacePage.vue b/src/renderer/_vue/components/AddWorkspacePage.vue deleted file mode 100644 index c3794b934a..0000000000 --- a/src/renderer/_vue/components/AddWorkspacePage.vue +++ /dev/null @@ -1,115 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/BottomBar/BottomBar.vue b/src/renderer/_vue/components/BottomBar/BottomBar.vue deleted file mode 100644 index 6cfa1042aa..0000000000 --- a/src/renderer/_vue/components/BottomBar/BottomBar.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/ClusterPage.vue b/src/renderer/_vue/components/ClusterPage.vue deleted file mode 100644 index 00823ee2f5..0000000000 --- a/src/renderer/_vue/components/ClusterPage.vue +++ /dev/null @@ -1,157 +0,0 @@ - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/Features/Components/Metrics.vue b/src/renderer/_vue/components/ClusterSettings/Features/Components/Metrics.vue deleted file mode 100644 index ff1b8fd64a..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/Features/Components/Metrics.vue +++ /dev/null @@ -1,141 +0,0 @@ - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/Features/Components/UserMode.vue b/src/renderer/_vue/components/ClusterSettings/Features/Components/UserMode.vue deleted file mode 100644 index 0c520d0cac..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/Features/Components/UserMode.vue +++ /dev/null @@ -1,128 +0,0 @@ - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/Features/Components/index.js b/src/renderer/_vue/components/ClusterSettings/Features/Components/index.js deleted file mode 100644 index 0736d46c66..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/Features/Components/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import Metrics from './Metrics' -import UserMode from './UserMode' - -export default { - Metrics, - UserMode -} diff --git a/src/renderer/_vue/components/ClusterSettings/Features/index.vue b/src/renderer/_vue/components/ClusterSettings/Features/index.vue deleted file mode 100644 index 0934e3c2a6..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/Features/index.vue +++ /dev/null @@ -1,51 +0,0 @@ - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/General/ClusterIcon.vue b/src/renderer/_vue/components/ClusterSettings/General/ClusterIcon.vue deleted file mode 100644 index 0450b77f7d..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/General/ClusterIcon.vue +++ /dev/null @@ -1,148 +0,0 @@ - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/General/ClusterName.vue b/src/renderer/_vue/components/ClusterSettings/General/ClusterName.vue deleted file mode 100644 index 54ee519182..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/General/ClusterName.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/General/ClusterWorkspace.vue b/src/renderer/_vue/components/ClusterSettings/General/ClusterWorkspace.vue deleted file mode 100644 index c54a36581d..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/General/ClusterWorkspace.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/General/index.vue b/src/renderer/_vue/components/ClusterSettings/General/index.vue deleted file mode 100644 index c0e0f987d3..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/General/index.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/Overview/index.vue b/src/renderer/_vue/components/ClusterSettings/Overview/index.vue deleted file mode 100644 index 8bae313451..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/Overview/index.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/Preferences/index.vue b/src/renderer/_vue/components/ClusterSettings/Preferences/index.vue deleted file mode 100644 index 857e64ded5..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/Preferences/index.vue +++ /dev/null @@ -1,196 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/ClusterSettings/index.vue b/src/renderer/_vue/components/ClusterSettings/index.vue deleted file mode 100644 index 7136a41a5c..0000000000 --- a/src/renderer/_vue/components/ClusterSettings/index.vue +++ /dev/null @@ -1,185 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/CubeSpinner.vue b/src/renderer/_vue/components/CubeSpinner.vue deleted file mode 100644 index 1ad743a46e..0000000000 --- a/src/renderer/_vue/components/CubeSpinner.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - diff --git a/src/renderer/_vue/components/EditWorkspacePage.vue b/src/renderer/_vue/components/EditWorkspacePage.vue deleted file mode 100644 index 2fea7115a7..0000000000 --- a/src/renderer/_vue/components/EditWorkspacePage.vue +++ /dev/null @@ -1,124 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/LandingPage.vue b/src/renderer/_vue/components/LandingPage.vue deleted file mode 100644 index 3bf08d845d..0000000000 --- a/src/renderer/_vue/components/LandingPage.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/MainMenu/AddClusterMenuItem.vue b/src/renderer/_vue/components/MainMenu/AddClusterMenuItem.vue deleted file mode 100644 index 819817b329..0000000000 --- a/src/renderer/_vue/components/MainMenu/AddClusterMenuItem.vue +++ /dev/null @@ -1,72 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/MainMenu/ClusterMenuItem.vue b/src/renderer/_vue/components/MainMenu/ClusterMenuItem.vue deleted file mode 100644 index b27d8ac245..0000000000 --- a/src/renderer/_vue/components/MainMenu/ClusterMenuItem.vue +++ /dev/null @@ -1,166 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/MainMenu/MainMenu.vue b/src/renderer/_vue/components/MainMenu/MainMenu.vue deleted file mode 100644 index 5ab9391e05..0000000000 --- a/src/renderer/_vue/components/MainMenu/MainMenu.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/PreferencesPage.vue b/src/renderer/_vue/components/PreferencesPage.vue deleted file mode 100644 index b780d15f8a..0000000000 --- a/src/renderer/_vue/components/PreferencesPage.vue +++ /dev/null @@ -1,256 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/WhatsNewPage.vue b/src/renderer/_vue/components/WhatsNewPage.vue deleted file mode 100644 index 2823c9a25a..0000000000 --- a/src/renderer/_vue/components/WhatsNewPage.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/WorkspacesPage.vue b/src/renderer/_vue/components/WorkspacesPage.vue deleted file mode 100644 index d8311c7020..0000000000 --- a/src/renderer/_vue/components/WorkspacesPage.vue +++ /dev/null @@ -1,156 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/common/ClosePageButton.vue b/src/renderer/_vue/components/common/ClosePageButton.vue deleted file mode 100644 index 5776d9e63d..0000000000 --- a/src/renderer/_vue/components/common/ClosePageButton.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - - - diff --git a/src/renderer/_vue/components/hashicon/hashicon.vue b/src/renderer/_vue/components/hashicon/hashicon.vue deleted file mode 100644 index 98891612bf..0000000000 --- a/src/renderer/_vue/components/hashicon/hashicon.vue +++ /dev/null @@ -1,28 +0,0 @@ - - diff --git a/src/renderer/_vue/index.js b/src/renderer/_vue/index.js deleted file mode 100644 index 9f1b80cb17..0000000000 --- a/src/renderer/_vue/index.js +++ /dev/null @@ -1,47 +0,0 @@ -import "../../common/system-ca" -import "./assets/css/app.scss" -import "prismjs"; -import "prismjs/components/prism-yaml" -import { remote } from "electron" -import Vue from 'vue' -import VueElectron from 'vue-electron' -import BootstrapVue from 'bootstrap-vue' -import { PromiseIpc } from 'electron-promise-ipc' -import { Tracker } from "../../common/tracker" -import App from './App' -import router from './router' -import store from './store' - -const tracker = new Tracker(remote.app); -const promiseIpc = new PromiseIpc({maxTimeoutMs: 6000}); - -promiseIpc.on('navigate', async (view) => { - router.push(view).catch(err => {}) -}); - -Vue.config.productionTip = false -Vue.use(VueElectron) -Vue.use(BootstrapVue) - -Vue.mixin({ - created: function () { - this.$promiseIpc = promiseIpc; - this.$tracker = tracker; - } -}) - -// any initialization we want to do for app state -setTimeout(() => { - store.dispatch('init', ).catch((error) => { - console.error(error) - }).finally(() => { - /* eslint-disable no-new */ - console.log("start vue") - new Vue({ - components: { App }, - store, - router, - template: '' - }).$mount('#app') - }) -}, 0) diff --git a/src/renderer/_vue/mixins/ClustersMixin.js b/src/renderer/_vue/mixins/ClustersMixin.js deleted file mode 100644 index f43302a2f0..0000000000 --- a/src/renderer/_vue/mixins/ClustersMixin.js +++ /dev/null @@ -1,16 +0,0 @@ -export default { - computed: { - clusters: function() { - return this.$store.getters.clusters - }, - newContexts: function() { - const seenContexts = this.seenContexts || this.$store.getters.seenContexts - const contextNamesFromKubeconfig = this.availableContexts.map(item => item.currentContext) - return contextNamesFromKubeconfig.filter((item) => seenContexts.indexOf(item) < 0) - }, - availableContexts: function() { - // read available kubeconfigs from store on filter out configs already found in added clusters - return this.$store.getters.availableKubeContexts.filter(item => !this.clusters.find((cluster) => cluster.contextName == item.currentContext)); - }, - } -} diff --git a/src/renderer/_vue/router/index.js b/src/renderer/_vue/router/index.js deleted file mode 100644 index a879708aa6..0000000000 --- a/src/renderer/_vue/router/index.js +++ /dev/null @@ -1,97 +0,0 @@ -import Vue from 'vue' -import Router from 'vue-router' -import store from "../store"; -import { whatsNew } from './routeguard' - -Vue.use(Router); - -const router = new Router({ - routes: [ - { - path: '/', - name: 'landing-page', - component: require('@/_vue/components/LandingPage').default, - meta: { - routeguard: [ - // guards in priority order; the first one to catch will trigger something - whatsNew, - ], - } - }, - { - path: '/preferences', - name: 'preferences-page', - component: require('@/_vue/components/PreferencesPage').default, - }, - { - path: '/workspaces', - name: 'workspaces-page', - component: require('@/_vue/components/WorkspacesPage').default, - props: true, - }, - { - path: '/add-workspace', - name: 'add-workspace-page', - component: require('@/_vue/components/AddWorkspacePage').default, - props: true, - }, - { - path: '/edit-workspace', - name: 'edit-workspace-page', - component: require('@/_vue/components/EditWorkspacePage').default, - props: true, - }, - { - path: '/clusters/:id', - name: 'cluster-page', - component: require('@/_vue/components/ClusterPage').default, - props: true, - }, - { - path: '/clusters/:id/settings', - name: 'cluster-settings-page', - component: require('@/_vue/components/ClusterSettings').default, - props: true, - }, - { - path: "/add-cluster", - name: "add-cluster-page", - component: require('@/_vue/components/AddClusterPage').default, - props: true, - }, - { - path: "/whats-new", - name: "whats-new-page", - component: require('@/_vue/components/WhatsNewPage').default, - props: true, - }, - { - path: '*', - redirect: '/' - } - ] -}) - -router.beforeEach((to, from, next) => { - - // guard routes - if(to.meta && to.meta.routeguard && to.meta.routeguard.length > 0){ - - let guardNext; - to.meta.routeguard.forEach(guard => { - if(!guardNext) guardNext = guard(to, from, store); - }); - - if(guardNext) { - next(guardNext); - } else { - next(); - } - - } - - next(); - -}); - -export default router; diff --git a/src/renderer/_vue/router/routeguard/index.js b/src/renderer/_vue/router/routeguard/index.js deleted file mode 100644 index 2c8ba87e55..0000000000 --- a/src/renderer/_vue/router/routeguard/index.js +++ /dev/null @@ -1,26 +0,0 @@ -export function auth ( to, from, store ) { - if(!store.getters.isLoggedIn){ - console.log("router: guard: auth: activated"); - return { - path: '/login' - } - } -} - -export function eula ( to, from, store ) { - if(!store.getters.isEulaAccepted){ - console.log("router: guard: eula: activated"); - return { - path: '/accept-eula' - } - } -} - -export function whatsNew( to, from, store ) { - if(store.getters.showWhatsNew){ - console.log("router: guard: whatsNew: activated"); - return { - path: '/whats-new' - } - } -} diff --git a/src/renderer/_vue/store/index.js b/src/renderer/_vue/store/index.js deleted file mode 100644 index 85b6e0eac8..0000000000 --- a/src/renderer/_vue/store/index.js +++ /dev/null @@ -1,94 +0,0 @@ -import Vue from 'vue' -import Vuex from 'vuex' -import semver from "semver" -import { userStore } from "../../../common/user-store" -import { getAppVersion } from "../../../common/utils/app-version" -import KubeContexts from './modules/kube-contexts' -import Clusters from './modules/clusters' -import HelmRepos from './modules/helm-repos' -import Workspaces from './modules/workspaces' - -// promise ipc -import { PromiseIpc } from 'electron-promise-ipc' -const promiseIpc = new PromiseIpc( { maxTimeoutMs: 120000 } ); - -// tracker -import { Tracker } from "../../../common/tracker" -import { remote } from "electron" -const tracker = new Tracker(remote.app); - -Vue.use(Vuex); - -export default new Vuex.Store({ - modules: { - Clusters, - HelmRepos, - KubeContexts, - Workspaces - }, - state: { - preferences: {}, - hud: { - isMenuVisible: true, - }, - seenContexts: userStore.getSeenContexts(), - lastSeenAppVersion: userStore.lastSeenAppVersion(), - }, - mutations: { - storeSeenContexts(state, context) { - const seenContexts = userStore.storeSeenContext(context); - state.seenContexts = seenContexts - }, - updateLastSeenAppVersion(state, appVersion) { - state.lastSeenAppVersion = appVersion; - userStore.setLastSeenAppVersion(appVersion) - }, - loadPreferences(state) { - this.commit("savePreferences", userStore.getPreferences()); - }, - savePreferences(state, prefs) { - if (prefs.allowTelemetry) { - tracker.event("telemetry", "enabled") - } else { - tracker.event("telemetry", "disabled") - } - state.preferences = prefs; - userStore.setPreferences(prefs); - this.dispatch("destroyWebviews") - promiseIpc.send("preferencesSaved") - }, - hideMenu(state) { - state.hud.isMenuVisible = false; - }, - showMenu(state) { - state.hud.isMenuVisible = true; - } - }, - actions: { - async init({commit, getters}) { - commit("loadPreferences"); - - await this.dispatch('refreshClusters', getters.currentWorkspace); - - return true; - }, - async addSeenContexts({commit}, data){ - commit('storeSeenContexts', data); - }, - async updateLastSeenAppVersion({commit, state}) { - tracker.event("app", "whats-new-seen") - commit("updateLastSeenAppVersion", getAppVersion()) - } - }, - getters : { - seenContexts: state => state.seenContexts, - hud: state => state.hud, - isMenuVisible: function(state, getters){ - return state.hud.isMenuVisible && !getters.showWhatsNew; - }, - showWhatsNew: function(state) { - return semver.gt(getAppVersion(), state.lastSeenAppVersion); - }, - preferences: state => state.preferences, - } -}); diff --git a/src/renderer/_vue/store/modules/clusters.ts b/src/renderer/_vue/store/modules/clusters.ts deleted file mode 100644 index 219a4d9393..0000000000 --- a/src/renderer/_vue/store/modules/clusters.ts +++ /dev/null @@ -1,279 +0,0 @@ -import Vue from "vue" -import { ClusterInfo } from "../../../../main/cluster" -import { MutationTree, ActionTree, GetterTree } from "vuex" -import { PromiseIpc } from 'electron-promise-ipc' -import { Tracker } from "../../../../common/tracker" -import { remote } from "electron" -import { clusterStore } from "../../../../common/cluster-store" -import { Workspace } from "../../../../common/workspace-store" - -const promiseIpc = new PromiseIpc( { maxTimeoutMs: 120000 } ); -const tracker = new Tracker(remote.app); - -export interface LensWebview { - id: string; - loaded: boolean; - webview?: HTMLIFrameElement; -} - -export interface ClusterState { - lenses: LensWebview[]; - clusters: ClusterInfo[]; -} - -const state: ClusterState = { - lenses: [], - clusters: [] -} - -const actions: ActionTree = { - async refreshClusters({commit}, currentWorkspace: Workspace) { - const clusters: ClusterInfo[] = await promiseIpc.send('getClusters', currentWorkspace.id).catch((error: Error) => { - return false; - }) - if(!clusters) return false; - commit('updateClusters', clusters); - clusters.forEach((cluster: ClusterInfo) => { - const lens: LensWebview = { - id: cluster.id, - webview: null, - loaded: false - }; - commit("updateLens", lens) - }) - return true; - }, - async getCluster({commit, getters}, id: string) { - const cluster: ClusterInfo = getters.clusters.find((c: ClusterInfo) => c.id === id) - if(!cluster) return null; - - const remoteCluster = await promiseIpc.send("getCluster", cluster.id) - if(!remoteCluster) return null; - - Object.assign(cluster, remoteCluster) - commit('updateCluster', cluster); - - return cluster; - }, - async refineCluster({commit}, id: string) { - console.log("VUEX: ACTION: REFINE CLUSTER", id); - - const remoteCluster = await promiseIpc.send("getCluster", id) - if(!remoteCluster) return null; - - commit('updateCluster', remoteCluster); - - return remoteCluster; - }, - async stopCluster({dispatch, getters}, id: string) { - const cluster: ClusterInfo = getters.clusters.find((c: ClusterInfo) => c.id === id) - if (!cluster) return; - - const lens = getters.lensById(cluster.id) - if (lens) { - await dispatch("detachWebview", lens) - await promiseIpc.send("stopCluster", cluster.id) - tracker.event("cluster", "stop") - } - }, - async removeCluster({getters, dispatch}, id: string) { - const cluster: ClusterInfo = getters.clusters.find((c: ClusterInfo) => c.id === id) - if (!cluster) { - return - } - const lens = this.getters.lensById(cluster.id) - if (lens) { - dispatch("detachWebview", lens) - } - await promiseIpc.send("removeCluster", cluster.id).catch((error: Error) => { - return false; - }) - tracker.event("cluster", "remove"); - await dispatch("refreshClusters", getters.currentWorkspace) - return true; - }, - async addCluster({commit, getters, dispatch}, data) { - const res = await promiseIpc.send("addCluster", data) - if(!res) return null; - - tracker.event("cluster", "add"); - commit('updateClusters', res.allClusters); - await dispatch("refreshClusters", getters.currentWorkspace); - return res.addedCluster; - }, - async clearClusters({commit, getters, dispatch}){ - // todo: clean from main process as well? - getters.lenses.forEach((lens: LensWebview) => { - if (lens.webview) { - dispatch("detachWebview", lens) - } - }) - commit('updateLenses', []); - commit('updateClusters', []); - return true; - }, - - async uploadClusterIcon({commit}, data) { - const res = await promiseIpc.send("saveClusterIcon", data) - tracker.event("cluster", "upload-icon") - if (res.cluster) commit("updateCluster", res.cluster) - return res - }, - - async resetClusterIcon({commit}, data) { - const res = await promiseIpc.send("resetClusterIcon", data.clusterId) - tracker.event("cluster", "reset-icon") - if (res.cluster) commit("updateCluster", res.cluster) - return res - }, - - // For data structure see: cluster-manager.ts / FeatureInstallRequest - async installClusterFeature({commit}, data) { - // Custom no timeout IPC as install can take very variable time - const ipc = new PromiseIpc(); - const response = await ipc.send('installFeature', data) - console.log("installer result:", response); - const cluster = await ipc.send('refreshCluster', data.clusterId) - - tracker.event("cluster", "install-feature") - commit("updateCluster", cluster) - return response - }, - // For data structure see: cluster-manager.ts / FeatureInstallRequest - async upgradeClusterFeature({commit}, data) { - // Custom no timeout IPC as install can take very variable time - const ipc = new PromiseIpc(); - const response = await ipc.send('upgradeFeature', data) - console.log("upgrade result:", response); - const cluster = await ipc.send('refreshCluster', data.clusterId) - - - tracker.event("cluster", "upgrade-feature") - commit("updateCluster", cluster) - return response - }, - // For data structure see: cluster-manager.ts / FeatureInstallRequest - async uninstallClusterFeature({commit}, data) { - // Custom no timeout IPC as uninstall can take very variable time - const ipc = new PromiseIpc(); - const response = await ipc.send('uninstallFeature', data) - console.log("uninstaller result:", response); - const cluster = await ipc.send('refreshCluster', data.clusterId) - - tracker.event("cluster", "uninstall-feature") - commit("updateCluster", cluster) - return response - }, - - attachWebview({commit}, lens: LensWebview) { - const container: any = document.getElementById("lens-container"); - if (!container || !lens.webview) { - return - } - container.style = "display: block;" - let webview = null - container.childNodes.forEach((child: any) => { - if (child === lens.webview) { - webview = child - } - }) - if (!webview) { - container.appendChild(lens.webview) - } - container.childNodes.forEach((child: any) => { - if (child !== lens.webview) { - child.style = "display: none;" - } else { - child.style = "top: 0; bottom: 20px; position: absolute; width: 100%;" - } - }) - promiseIpc.send("enableClusterSettingsMenuItem", lens.id) - }, - detachWebview({commit}, lens: LensWebview) { - const container: any = document.getElementById("lens-container"); - if (!container) { return } - container.childNodes.forEach((child: any) => { - if (child === lens.webview) { - container.removeChild(lens.webview) - lens.webview = null - lens.loaded = false - commit("updateLens", lens) - } - }) - promiseIpc.send("disableClusterSettingsMenuItem") - }, - hideWebviews({commit}) { - const container: any = document.getElementById("lens-container"); - if (!container) { return } - container.style = "display: none;" - container.childNodes.forEach((child: any) => { - child.style = "display: none;" - }) - promiseIpc.send("disableClusterSettingsMenuItem") - }, - destroyWebviews({commit}) { - state.lenses.forEach((lens) => { - this.dispatch("detachWebview", lens) - }) - }, - storeCluster({commit}, cluster: ClusterInfo) { - clusterStore.storeCluster(cluster); - commit("updateCluster", cluster) - promiseIpc.send("clusterStored", cluster.id) - } -} - -const getters: GetterTree = { - clusters: state => state.clusters, - clusterById: state => (id: string) => { - const cluster = state.clusters.find(c => c.id === id); - if (cluster) { - return cluster; - } else { - return null; - } - }, - lenses: state => state.lenses, - lensById: state => (id: string) => { - const lens = state.lenses.find(c => c.id === id); - if (lens) { - return lens; - } else { - return null; - } - }, -} - -const mutations: MutationTree = { - updateClusters(state, clusters: ClusterInfo[]) { - Vue.set(state, 'clusters', [...clusters]) - }, - updateCluster(state, cluster) { - state.clusters.forEach((c, index) => { - if(c.id === cluster.id) { - Vue.set(state.clusters, index, cluster) - } - }) - }, - updateLenses(state, data) { - Vue.set(state, 'lenses', [...data]) - }, - updateLens(state, lens: LensWebview) { - const lensIndex = state.lenses.findIndex(l => l.id == lens.id); - if (lensIndex >= 0) { - state.lenses[lensIndex] = lens - Vue.set(state.lenses, lensIndex, lens) - } else { - console.log("update new lens") - state.lenses.push(lens) - } - } -} - -export default { - namespaced: false, - state, - getters, - mutations, - actions -} diff --git a/src/renderer/_vue/store/modules/helm-repos.ts b/src/renderer/_vue/store/modules/helm-repos.ts deleted file mode 100644 index 195557ccaa..0000000000 --- a/src/renderer/_vue/store/modules/helm-repos.ts +++ /dev/null @@ -1,54 +0,0 @@ -import Vue from "vue" -import { MutationTree, ActionTree, GetterTree } from "vuex" -import { HelmRepo, repoManager } from "../../../../main/helm-repo-manager" - -export interface HelmRepoState { - repos: HelmRepo[]; -} - -const state: HelmRepoState = { - repos: [] -} - -const actions: ActionTree = { - async addHelmRepo({ commit }, data){ - const res = await repoManager.addRepo(data).catch((error: Error) => { - return false; - }) - if(!res) return false; - return await this.dispatch("refreshHelmRepos") - }, - async removeHelmRepo({ commit }, data){ - const res = await repoManager.removeRepo(data).catch((error: Error) => { - return false; - }) - if(!res) return false; - return await this.dispatch("refreshHelmRepos") - }, - async refreshHelmRepos({commit}){ - const repos: HelmRepo[] = await repoManager.repositories().catch((error: Error) => { - return null; - }) - if(!repos) return false; - commit('updateRepos', repos); - return true; - } -} - -const getters: GetterTree = { - repos: state => state.repos -} - -const mutations: MutationTree = { - updateRepos(state, repos: HelmRepo[]) { - Vue.set(state, 'repos', [...repos]) - }, -} - -export default { - namespaced: false, - state, - getters, - mutations, - actions -} diff --git a/src/renderer/_vue/store/modules/kube-contexts.js b/src/renderer/_vue/store/modules/kube-contexts.js deleted file mode 100644 index 9a75e7a33c..0000000000 --- a/src/renderer/_vue/store/modules/kube-contexts.js +++ /dev/null @@ -1,46 +0,0 @@ -import * as k8s from "@kubernetes/client-node" -import { splitConfig, dumpConfigYaml } from "../../../../main/k8s" - -const state = { - availableKubeContexts: [] -} - -const actions = { - reloadAvailableKubeContexts({commit}, file) { - let kc = new k8s.KubeConfig(); - try { - kc.loadFromFile(file); - } catch (error) { - console.error("Failed to read default kubeconfig: " + error.message); - } - - // Remove the default setup the client makes if it does not find anything in the default config - // See: https://github.com/kubernetes-client/javascript/blob/2fc8fbc956ca89bf425ca3ea045d46ee7b75296b/src/config.ts#L253 - // It defaults to loadFromClusterAndUser() when no config file can be found - if(kc.currentContext === "loaded-context") { - kc = new k8s.KubeConfig(); - } - - commit("saveAvailableKubeContexts", splitConfig(kc)) - } -} - -const getters = { - availableKubeContexts: function(state){ - return state.availableKubeContexts - } -} - -const mutations = { - saveAvailableKubeContexts(state, contexts) { - state.availableKubeContexts = contexts - } -} - -export default { - namespaced: false, - state, - getters, - mutations, - actions -} diff --git a/src/renderer/_vue/store/modules/workspaces.ts b/src/renderer/_vue/store/modules/workspaces.ts deleted file mode 100644 index ea075cb43f..0000000000 --- a/src/renderer/_vue/store/modules/workspaces.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { MutationTree, ActionTree, GetterTree } from "vuex" -import { workspaceStore, Workspace, WorkspaceData } from "../../../../common/workspace-store" - -export interface WorkspaceState { - workspaces: Array; - currentWorkspace: Workspace; -} - -const state: WorkspaceState = { - workspaces: workspaceStore.getAllWorkspaces(), - currentWorkspace: workspaceStore.getAllWorkspaces().find((w) => w.id === "default") -} - -const actions: ActionTree = { -} - -const getters: GetterTree = { - workspaces: state => state.workspaces, - currentWorkspace: state => state.currentWorkspace, - workspaceById: state => (id: string) => { - return state.workspaces.find((ws) => ws.id == id) - } -} - -const mutations: MutationTree = { - setCurrentWorkspace(state, workspace: Workspace) { - state.currentWorkspace = workspace - }, - addWorkspace(state, workspace: WorkspaceData) { - workspaceStore.storeWorkspace(workspace) - state.workspaces = workspaceStore.getAllWorkspaces() - }, - updateWorkspace(state, workspace: WorkspaceData) { - workspaceStore.storeWorkspace(workspace) - state.workspaces = workspaceStore.getAllWorkspaces() - }, - removeWorkspace(state, workspace: Workspace) { - workspaceStore.removeWorkspace(workspace) - state.workspaces = workspaceStore.getAllWorkspaces() - } -} - -export default { - namespaced: false, - state, - getters, - mutations, - actions -} diff --git a/src/renderer/api/endpoints/config.api.ts b/src/renderer/api/endpoints/config.api.ts deleted file mode 100644 index 9a24d3291c..0000000000 --- a/src/renderer/api/endpoints/config.api.ts +++ /dev/null @@ -1,9 +0,0 @@ -// App configuration api -import type { IConfigRoutePayload } from "../../../main/routes/config"; -import { apiBase } from "../index"; - -export const configApi = { - getConfig() { - return apiBase.get("/config") - }, -}; diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index 1f2d8ce0fd..8943cd49df 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -1,5 +1,5 @@ import { compile } from "path-to-regexp"; -import { apiHelm } from "../index"; +import { apiBase } from "../index"; import { stringify } from "querystring"; import { autobind } from "../../utils"; @@ -21,7 +21,7 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: { export const helmChartsApi = { list() { - return apiHelm + return apiBase .get(endpoint()) .then(data => { return Object @@ -33,7 +33,7 @@ export const helmChartsApi = { get(repo: string, name: string, readmeVersion?: string) { const path = endpoint({ repo, name }); - return apiHelm + return apiBase .get(path + "?" + stringify({ version: readmeVersion })) .then(data => { const versions = data.versions.map(HelmChart.create); @@ -46,7 +46,7 @@ export const helmChartsApi = { }, getValues(repo: string, name: string, version: string) { - return apiHelm + return apiBase .get(`/v2/charts/${repo}/${name}/values?` + stringify({ version })); } }; diff --git a/src/renderer/api/endpoints/helm-releases.api.ts b/src/renderer/api/endpoints/helm-releases.api.ts index 831569ef3f..a3c1a62045 100644 --- a/src/renderer/api/endpoints/helm-releases.api.ts +++ b/src/renderer/api/endpoints/helm-releases.api.ts @@ -2,7 +2,7 @@ import jsYaml from "js-yaml"; import { compile } from "path-to-regexp"; import { autobind, formatDuration } from "../../utils"; import capitalize from "lodash/capitalize"; -import { apiHelm } from "../index"; +import { apiBase } from "../index"; import { helmChartStore } from "../../components/+apps-helm-charts/helm-chart.store"; import { ItemObject } from "../../item.store"; import { KubeObject } from "../kube-object"; @@ -69,14 +69,14 @@ const endpoint = compile(`/v2/releases/:namespace?/:name?`) as ( export const helmReleasesApi = { list(namespace?: string) { - return apiHelm + return apiBase .get(endpoint({ namespace })) .then(releases => releases.map(HelmRelease.create)); }, get(name: string, namespace: string) { const path = endpoint({ name, namespace }); - return apiHelm.get(path).then(details => { + return apiBase.get(path).then(details => { const items: KubeObject[] = JSON.parse(details.resources).items; const resources = items.map(item => KubeObject.create(item)); return { @@ -90,34 +90,34 @@ export const helmReleasesApi = { const { repo, ...data } = payload; data.chart = `${repo}/${data.chart}`; data.values = jsYaml.safeLoad(data.values); - return apiHelm.post(endpoint(), { data }); + return apiBase.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 apiHelm.put(endpoint({ name, namespace }), { data }); + return apiBase.put(endpoint({ name, namespace }), { data }); }, async delete(name: string, namespace: string) { const path = endpoint({ name, namespace }); - return apiHelm.del(path); + return apiBase.del(path); }, getValues(name: string, namespace: string) { const path = endpoint({ name, namespace }) + "/values"; - return apiHelm.get(path); + return apiBase.get(path); }, getHistory(name: string, namespace: string): Promise { const path = endpoint({ name, namespace }) + "/history"; - return apiHelm.get(path); + return apiBase.get(path); }, rollback(name: string, namespace: string, revision: number) { const path = endpoint({ name, namespace }) + "/rollback"; - return apiHelm.put(path, { + return apiBase.put(path, { data: { revision: revision } diff --git a/src/renderer/api/endpoints/index.ts b/src/renderer/api/endpoints/index.ts index f175d6feb9..d723766f07 100644 --- a/src/renderer/api/endpoints/index.ts +++ b/src/renderer/api/endpoints/index.ts @@ -1,10 +1,7 @@ -// Local express.js endpoints -export * from "./config.api" -export * from "./cluster.api" -export * from "./kubeconfig.api" - -// Kubernetes endpoints +// Kubernetes apis // Docs: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/ + +export * from "./cluster.api" export * from "./namespaces.api" export * from "./cluster-role.api" export * from "./cluster-role-binding.api" diff --git a/src/renderer/api/endpoints/kubeconfig.api.ts b/src/renderer/api/endpoints/kubeconfig.api.ts deleted file mode 100644 index c6476badf4..0000000000 --- a/src/renderer/api/endpoints/kubeconfig.api.ts +++ /dev/null @@ -1,12 +0,0 @@ -// 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/src/renderer/api/endpoints/metrics.api.ts b/src/renderer/api/endpoints/metrics.api.ts index edb840ba77..81d2f1d500 100644 --- a/src/renderer/api/endpoints/metrics.api.ts +++ b/src/renderer/api/endpoints/metrics.api.ts @@ -2,7 +2,7 @@ import moment from "moment"; import { apiBase } from "../index"; -import type { IMetricsQuery } from "../../../main/routes/metrics"; +import type { IMetricsQuery } from "../../../main/routes/metrics-route"; export interface IMetrics { status: string; diff --git a/src/renderer/api/endpoints/resource-applier.api.ts b/src/renderer/api/endpoints/resource-applier.api.ts index 6697b4a56d..3cecb3de65 100644 --- a/src/renderer/api/endpoints/resource-applier.api.ts +++ b/src/renderer/api/endpoints/resource-applier.api.ts @@ -1,7 +1,7 @@ import jsYaml from "js-yaml" import { KubeObject } from "../kube-object"; import { KubeJsonApiData } from "../kube-json-api"; -import { apiResourceApplier } from "../index"; +import { apiBase } from "../index"; import { apiManager } from "../api-manager"; export const resourceApplierApi = { @@ -13,7 +13,7 @@ export const resourceApplierApi = { if (typeof resource === "string") { resource = jsYaml.safeLoad(resource); } - return apiResourceApplier + return apiBase .post("/stack", { data: resource }) .then(data => { const items = data.map(obj => { diff --git a/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts b/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts index 755dcfa54b..e03ba0b6ae 100644 --- a/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts +++ b/src/renderer/api/endpoints/selfsubjectrulesreviews.api.ts @@ -24,7 +24,7 @@ export class SelfSubjectRulesReview extends KubeObject { static kind = "SelfSubjectRulesReview" spec: { - // fixme: add more types from api docs + // todo: add more types from api docs namespace?: string; } diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 33667f1eab..7dd2306e74 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -1,29 +1,19 @@ import { JsonApi, JsonApiErrorParsed } from "./json-api"; import { KubeJsonApi } from "./kube-json-api"; import { Notifications } from "../components/notifications"; -import { apiPrefix, isDevelopment } from "../../common/vars"; - -//-- JSON HTTP APIS +import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars"; export const apiBase = new JsonApi({ + apiBase: apiPrefix, debug: isDevelopment, - apiPrefix: apiPrefix.BASE, }); export const apiKube = new KubeJsonApi({ + apiBase: apiKubePrefix, debug: isDevelopment, - apiPrefix: apiPrefix.KUBE_BASE, -}); -export const apiHelm = new KubeJsonApi({ - debug: isDevelopment, - apiPrefix: apiPrefix.KUBE_HELM, -}); -export const apiResourceApplier = new KubeJsonApi({ - debug: isDevelopment, - apiPrefix: apiPrefix.KUBE_RESOURCE_APPLIER, }); // Common handler for HTTP api errors -function onApiError(error: JsonApiErrorParsed, res: Response) { +export function onApiError(error: JsonApiErrorParsed, res: Response) { switch (res.status) { case 403: error.isUsedForNotification = true; @@ -34,5 +24,3 @@ function onApiError(error: JsonApiErrorParsed, res: Response) { apiBase.onError.addListener(onApiError); apiKube.onError.addListener(onApiError); -apiHelm.onError.addListener(onApiError); -apiResourceApplier.onError.addListener(onApiError); diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index 5f50296b0d..2d274d5f6c 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -27,7 +27,7 @@ export interface JsonApiLog { } export interface JsonApiConfig { - apiPrefix: string; + apiBase: string; debug?: boolean; } @@ -72,7 +72,7 @@ export class JsonApi { } protected request(path: string, params?: P, init: RequestInit = {}) { - let reqUrl = this.config.apiPrefix + path; + let reqUrl = this.config.apiBase + path; const reqInit: RequestInit = { ...this.reqInit, ...init }; const { data, query } = params || {} as P; if (data && !reqInit.body) { diff --git a/src/renderer/api/kube-api-parse.ts b/src/renderer/api/kube-api-parse.ts index 1354f86b60..d5e61c2305 100644 --- a/src/renderer/api/kube-api-parse.ts +++ b/src/renderer/api/kube-api-parse.ts @@ -1,5 +1,15 @@ // Parse kube-api path and get api-version, group, etc. + +import type { KubeObject } from "./kube-object"; import { splitArray } from "../../common/utils"; +import { apiManager } from "./api-manager"; + +export interface IKubeObjectRef { + kind: string; + apiVersion: string; + name: string; + namespace?: string; +} export interface IKubeApiLinkRef { apiPrefix?: string; @@ -9,13 +19,13 @@ export interface IKubeApiLinkRef { namespace?: string; } -export interface IKubeApiLinkBase extends IKubeApiLinkRef { +export interface IKubeApiParsed extends IKubeApiLinkRef { apiBase: string; apiGroup: string; apiVersionWithGroup: string; } -export function parseApi(path: string): IKubeApiLinkBase { +export function parseKubeApi(path: string): IKubeApiParsed { path = new URL(path, location.origin).pathname; const [, prefix, ...parts] = path.split("/"); const apiPrefix = `/${prefix}`; @@ -94,7 +104,7 @@ export function parseApi(path: string): IKubeApiLinkBase { }; } -export function createApiLink(ref: IKubeApiLinkRef): string { +export function createKubeApiURL(ref: IKubeApiLinkRef): string { const { apiPrefix = "/apis", resource, apiVersion, name } = ref; let { namespace } = ref; if (namespace) { @@ -104,3 +114,36 @@ export function createApiLink(ref: IKubeApiLinkRef): string { .filter(v => v) .join("/") } + +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 = createKubeApiURL({ 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 createKubeApiURL({ apiVersion, name, namespace, resource }) +} diff --git a/src/renderer/api/kube-api-parse_test.ts b/src/renderer/api/kube-api-parse_test.ts index 1fda8c3e53..dee3bf031d 100644 --- a/src/renderer/api/kube-api-parse_test.ts +++ b/src/renderer/api/kube-api-parse_test.ts @@ -1,8 +1,8 @@ -import { IKubeApiLinkBase, parseApi } from "./kube-api-parse"; +import { IKubeApiParsed, parseKubeApi } from "./kube-api-parse"; interface KubeApi_Parse_Test { url: string; - expected: Required; + expected: Required; } const tests: KubeApi_Parse_Test[] = [ @@ -129,7 +129,7 @@ describe.only("parseApi unit tests", () => { for (const i in tests) { const { url: tUrl, expected:tExpect} = tests[i]; test(`test #${parseInt(i)+1}`, () => { - expect(parseApi(tUrl)).toStrictEqual(tExpect); + expect(parseKubeApi(tUrl)).toStrictEqual(tExpect); }); } }); diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 869e3a90ea..480abb4a4f 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -3,11 +3,11 @@ 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 { KubeJsonApi, KubeJsonApiData, KubeJsonApiDataList } from "./kube-json-api"; import { apiKube } from "./index"; import { kubeWatchApi } from "./kube-watch-api"; import { apiManager } from "./api-manager"; -import { createApiLink, parseApi } from "./kube-api-parse"; +import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; export interface IKubeApiOptions { kind: string; // resource type within api-group, e.g. "Namespace" @@ -26,8 +26,7 @@ export interface IKubeApiQueryParams { } export class KubeApi { - static parseApi = parseApi; - static createLink = createApiLink; + static parseApi = parseKubeApi; static watchAll(...apis: KubeApi[]) { const disposers = apis.map(api => api.watch()); @@ -85,7 +84,7 @@ export class KubeApi { getUrl({ name = "", namespace = "" } = {}, query?: Partial) { const { apiPrefix, apiVersionWithGroup, apiResource } = this; - const resourcePath = KubeApi.createLink({ + const resourcePath = createKubeApiURL({ apiPrefix: apiPrefix, apiVersion: apiVersionWithGroup, resource: apiResource, @@ -175,35 +174,4 @@ export class KubeApi { } } -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 }) -} +export * from "./kube-api-parse" \ No newline at end of file diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index ad7272cd90..ce7da50826 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -31,13 +31,6 @@ export interface KubeJsonApiData extends JsonApiData { }; } -export interface IKubeObjectRef { - kind: string; - apiVersion: string; - name: string; - namespace?: string; -} - export interface KubeJsonApiError extends JsonApiError { code: number; status: string; @@ -49,14 +42,6 @@ export interface KubeJsonApiError extends JsonApiError { }; } -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; diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 9fadfcfd27..464b785dfb 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -6,9 +6,9 @@ import { autobind, EventEmitter } from "../utils"; import { KubeJsonApiData } from "./kube-json-api"; import type { KubeObjectStore } from "../kube-object.store"; import { KubeApi } from "./kube-api"; -import { configStore } from "../config.store"; import { apiManager } from "./api-manager"; import { apiPrefix, isDevelopment } from "../../common/vars"; +import { getHostedCluster } from "../../common/cluster-store"; export interface IKubeWatchEvent { type: "ADDED" | "MODIFIED" | "DELETED"; @@ -29,7 +29,6 @@ export interface IKubeWatchRouteQuery { export class KubeWatchApi { protected evtSource: EventSource; protected onData = new EventEmitter<[IKubeWatchEvent]>(); - protected apiUrl = apiPrefix.BASE + "/watch"; protected subscribers = observable.map(); protected reconnectTimeoutMs = 5000; protected maxReconnectsOnError = 10; @@ -62,10 +61,10 @@ export class KubeWatchApi { } protected getQuery(): Partial { - const { isClusterAdmin, allowedNamespaces } = configStore; + const { isAdmin, allowedNamespaces } = getHostedCluster() return { api: this.activeApis.map(api => { - if (isClusterAdmin) return api.getWatchUrl(); + if (isAdmin) return api.getWatchUrl(); return allowedNamespaces.map(namespace => api.getWatchUrl(namespace)) }).flat() } @@ -79,7 +78,7 @@ export class KubeWatchApi { return; } const query = this.getQuery(); - const apiUrl = this.apiUrl + "?" + stringify(query); + const apiUrl = `${apiPrefix}/watch?` + stringify(query); this.evtSource = new EventSource(apiUrl); this.evtSource.onmessage = this.onMessage; this.evtSource.onerror = this.onError; diff --git a/src/renderer/api/rbac.ts b/src/renderer/api/rbac.ts deleted file mode 100644 index 46d9d066c0..0000000000 --- a/src/renderer/api/rbac.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { configStore } from "../config.store"; - -export function isAllowedResource(resources: string | string[]) { - if (!Array.isArray(resources)) { - resources = [resources]; - } - const { allowedResources } = configStore; - for (const resource of resources) { - if (!allowedResources.includes(resource)) { - return false; - } - } - return true; -} diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index d12cd56c7c..0e8bba2df6 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -1,9 +1,8 @@ import { stringify } from "querystring"; -import { autobind, base64, EventEmitter, interval } from "../utils"; +import { autobind, base64, EventEmitter } from "../utils"; import { WebSocketApi } from "./websocket-api"; -import { configStore } from "../config.store"; import isEqual from "lodash/isEqual" -import { apiPrefix, isDevelopment } from "../../common/vars"; +import { isDevelopment } from "../../common/vars"; export enum TerminalChannels { STDIN = 0, @@ -25,21 +24,19 @@ enum TerminalColor { NO_COLOR = "\u001b[0m", } -export interface ITerminalApiOptions { +export type TerminalApiQuery = Record & { id: string; node?: string; - colorTheme?: "light" | "dark"; + type?: string | "node"; } 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) { + constructor(protected options: TerminalApiQuery) { super({ logging: isDevelopment, flushOnOpen: false, @@ -47,50 +44,33 @@ export class TerminalApi extends WebSocketApi { }); } - async getUrl(token: string) { - const { hostname, protocol } = location; + async getUrl() { let { port } = location; + const { hostname, protocol } = location; const { id, node } = this.options; const wss = `ws${protocol === "https:" ? "s" : ""}://`; - const queryParams = { token, id }; + const query: TerminalApiQuery = { id }; if (port) { port = `:${port}` } if (node) { - Object.assign(queryParams, { - node: node, - type: "node" - }); + query.node = node; + query.type = "node"; } - return `${wss}${hostname}${port}/api?${stringify(queryParams)}`; + return `${wss}${hostname}${port}/api?${stringify(query)}`; } 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 - }); + const apiUrl = await this.getUrl(); + this.emitStatus("Connecting ..."); 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); } diff --git a/src/renderer/api/workload-kube-object.ts b/src/renderer/api/workload-kube-object.ts index a32ccd99e9..c18b8df6c4 100644 --- a/src/renderer/api/workload-kube-object.ts +++ b/src/renderer/api/workload-kube-object.ts @@ -1,5 +1,5 @@ import get from "lodash/get"; -import { IKubeObjectMetadata, KubeObject } from "./kube-object"; +import { KubeObject } from "./kube-object"; interface IToleration { key?: string; @@ -47,9 +47,7 @@ export interface IAffinity { } export class WorkloadKubeObject extends KubeObject { - - // fixme: add type - spec: any; + spec: any; // todo: add proper types getSelectors(): string[] { const selector = this.spec.selector; diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx new file mode 100644 index 0000000000..0fcb216cd9 --- /dev/null +++ b/src/renderer/bootstrap.tsx @@ -0,0 +1,38 @@ +import "./components/app.scss" +import React from "react"; +import { render } from "react-dom"; +import { isMac } from "../common/vars"; +import { userStore } from "../common/user-store"; +import { workspaceStore } from "../common/workspace-store"; +import { clusterStore, getHostedClusterId } from "../common/cluster-store"; +import { i18nStore } from "./i18n"; +import { themeStore } from "./theme.store"; +import { App } from "./components/app"; +import { LensApp } from "./lens-app"; + +type AppComponent = React.ComponentType & { + init?(): void; +} + +export async function bootstrap(App: AppComponent) { + const rootElem = document.getElementById("app") + rootElem.classList.toggle("is-mac", isMac); + + // preload common stores + await Promise.all([ + userStore.load(), + workspaceStore.load(), + clusterStore.load(), + i18nStore.init(), + themeStore.init(), + ]); + + // init app's dependencies if any + if (App.init) { + await App.init(); + } + render(, rootElem); +} + +// run +bootstrap(getHostedClusterId() ? App : LensApp); diff --git a/src/renderer/browser-check.tsx b/src/renderer/browser-check.tsx deleted file mode 100644 index ce6c9ecad6..0000000000 --- a/src/renderer/browser-check.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import 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 Lens features. {" "} - Please consider using another browser. - -

- ) - } -} \ No newline at end of file diff --git a/src/renderer/components/+add-cluster/add-cluster.route.ts b/src/renderer/components/+add-cluster/add-cluster.route.ts new file mode 100644 index 0000000000..21f7522f0f --- /dev/null +++ b/src/renderer/components/+add-cluster/add-cluster.route.ts @@ -0,0 +1,8 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const addClusterRoute: RouteProps = { + path: "/add-cluster" +} + +export const addClusterURL = buildURL(addClusterRoute.path) diff --git a/src/renderer/components/+add-cluster/add-cluster.scss b/src/renderer/components/+add-cluster/add-cluster.scss new file mode 100644 index 0000000000..17b6a09d82 --- /dev/null +++ b/src/renderer/components/+add-cluster/add-cluster.scss @@ -0,0 +1,11 @@ +.AddCluster { + .Select { + &__control { + box-shadow: 0 0 0 1px $borderFaintColor; + } + } + + code { + color: $pink-400; + } +} \ No newline at end of file diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx new file mode 100644 index 0000000000..0672a1a91a --- /dev/null +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -0,0 +1,224 @@ +import "./add-cluster.scss" +import React, { Fragment } from "react"; +import { observer } from "mobx-react"; +import { computed, observable } from "mobx"; +import { KubeConfig } from "@kubernetes/client-node"; +import { t, Trans } from "@lingui/macro"; +import { _i18n } from "../../i18n"; +import { Select, SelectOption } from "../select"; +import { Input } from "../input"; +import { AceEditor } from "../ace-editor"; +import { Button } from "../button"; +import { Icon } from "../icon"; +import { WizardLayout } from "../layout/wizard-layout"; +import { getKubeConfigLocal, loadConfig, saveConfigToAppFiles, splitConfig, validateConfig } from "../../../common/kube-helpers"; +import { clusterStore } from "../../../common/cluster-store"; +import { workspaceStore } from "../../../common/workspace-store"; +import { v4 as uuid } from "uuid" +import { navigate } from "../../navigation"; +import { userStore } from "../../../common/user-store"; +import { clusterViewURL } from "../cluster-manager/cluster-view.route"; + +@observer +export class AddCluster extends React.Component { + readonly custom: any = "custom" + @observable.ref clusterConfig: KubeConfig; + @observable.ref kubeConfig: KubeConfig; // local ~/.kube/config (if available) + @observable.ref error: React.ReactNode; + + @observable isWaiting = false + @observable showSettings = false + @observable proxyServer = "" + @observable customConfig = "" + + async componentDidMount() { + const kubeConfig: string = await getKubeConfigLocal() + if (kubeConfig) { + this.kubeConfig = loadConfig(kubeConfig) + } + } + + componentWillUnmount() { + userStore.markNewContextsAsSeen(); + } + + @computed get isCustom() { + return this.clusterConfig === this.custom; + } + + @computed get clusterOptions() { + const options: SelectOption[] = []; + if (this.kubeConfig) { + splitConfig(this.kubeConfig).forEach(kubeConfig => { + const context = kubeConfig.currentContext; + const hasContext = clusterStore.hasContext(context); + if (!hasContext) { + options.push({ + value: kubeConfig, + label: context, + }); + } + }) + } + options.push({ + label: Custom.., + value: this.custom, + }); + return options; + } + + protected formatClusterContextLabel = ({ value, label }: SelectOption) => { + if (value instanceof KubeConfig) { + const context = value.currentContext; + const isNew = userStore.newContexts.has(context); + const className = `${context} kube-context flex gaps align-center` + return ( +
+ {context} + {isNew && } +
+ ) + } + return label; + }; + + addCluster = async () => { + const { clusterConfig, customConfig, proxyServer } = this; + const clusterId = uuid(); + this.isWaiting = true + this.error = "" + try { + const config = this.isCustom ? loadConfig(customConfig) : clusterConfig; + if (!config) { + this.error = Please select kubeconfig + return; + } + validateConfig(config); + await clusterStore.addCluster({ + id: clusterId, + kubeConfigPath: saveConfigToAppFiles(clusterId, config), + workspace: workspaceStore.currentWorkspaceId, + contextName: config.currentContext, + preferences: { + clusterName: config.currentContext, + httpsProxy: proxyServer || undefined, + }, + }); + navigate(clusterViewURL({ params: { clusterId } })) + } catch (err) { + this.error = String(err); + } finally { + this.isWaiting = false; + } + } + + renderInfo() { + return ( + +

Clusters associated with Lens

+

+ Add clusters by clicking the Add Cluster button. + You'll need to obtain a working kubeconfig for the cluster you want to add. +

+
+

+ For more information on kubeconfig see Kubernetes docs. +

+

+ NOTE: Any manually added cluster is not merged into your kubeconfig file. +

+

+ To see your currently enabled config with kubectl, use kubectl config view --minify --raw command in your terminal. +

+

+ When connecting to a cluster, make sure you have a valid and working kubeconfig for the cluster. Following lists known "gotchas" in some authentication types used in kubeconfig with Lens + app. +

+ +

OIDC (OpenID Connect)

+
+

+ When connecting Lens to OIDC enabled cluster, there's few things you as a user need to take into account. +

+

Dedicated refresh token

+

+ As Lens app utilized kubeconfig is "disconnected" from your main kubeconfig Lens needs to have it's own refresh token it utilizes. + If you share the refresh token with e.g. kubectl who ever uses the token first will invalidate it for the next user. + One way to achieve this is with kubelogin tool by removing the tokens + (both id_token and refresh_token) from + the config and issuing kubelogin command. That'll take you through the login process and will result you having "dedicated" refresh token. +

+

Exec auth plugins

+

+ When using exec auth plugins make sure the paths that are used to call + any binaries + are full paths as Lens app might not be able to call binaries with relative paths. Make also sure that you pass all needed information either as arguments or env variables in the config, + Lens app might not have all login shell env variables set automatically. +

+ + ) + } + + render() { + return ( + +

Add Cluster

+

Choose config:

+ this.proxyServer = value} + theme="round-black" + /> + + {'A HTTP proxy server URL (format: http://
:).'} + + + )} + {this.isCustom && ( +
+

Kubeconfig:

+ this.customConfig = value} + /> +
+ )} + {this.error && ( +
{this.error}
+ )} +
+
+ + ) + } +} diff --git a/src/renderer/components/+add-cluster/index.ts b/src/renderer/components/+add-cluster/index.ts new file mode 100644 index 0000000000..42ab8bf944 --- /dev/null +++ b/src/renderer/components/+add-cluster/index.ts @@ -0,0 +1,2 @@ +export * from "./add-cluster" +export * from "./add-cluster.route" diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index 6ef02fca6b..5120a63053 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -1,11 +1,11 @@ import isEqual from "lodash/isEqual"; -import { action, observable, when, IReactionDisposer, reaction } from "mobx"; +import { action, IReactionDisposer, observable, reaction, when } 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"; +import { secretsStore } from "../+config-secrets/secrets.store"; +import { getHostedCluster } from "../../../common/cluster-store"; @autobind() export class ReleaseStore extends ItemStore { @@ -58,8 +58,8 @@ export class ReleaseStore extends ItemStore { this.isLoading = true; let items; try { - const { isClusterAdmin, allowedNamespaces } = configStore; - items = await this.loadItems(!isClusterAdmin ? allowedNamespaces : null); + const { isAdmin, allowedNamespaces } = getHostedCluster() + items = await this.loadItems(!isAdmin ? allowedNamespaces : null); } finally { if (items) { items = this.sortItems(items); @@ -73,8 +73,7 @@ export class ReleaseStore extends ItemStore { async loadItems(namespaces?: string[]) { if (!namespaces) { return helmReleasesApi.list(); - } - else { + } else { return Promise .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) .then(items => items.flat()); diff --git a/src/renderer/components/+cluster-settings/cluster-settings.route.ts b/src/renderer/components/+cluster-settings/cluster-settings.route.ts new file mode 100644 index 0000000000..a2c7a45fd8 --- /dev/null +++ b/src/renderer/components/+cluster-settings/cluster-settings.route.ts @@ -0,0 +1,12 @@ +import type { IClusterViewRouteParams } from "../cluster-manager/cluster-view.route"; +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export interface IClusterSettingsRouteParams extends IClusterViewRouteParams { +} + +export const clusterSettingsRoute: RouteProps = { + path: `/cluster/:clusterId/settings`, +} + +export const clusterSettingsURL = buildURL(clusterSettingsRoute.path) diff --git a/src/renderer/components/+cluster-settings/cluster-settings.scss b/src/renderer/components/+cluster-settings/cluster-settings.scss new file mode 100644 index 0000000000..f20699de35 --- /dev/null +++ b/src/renderer/components/+cluster-settings/cluster-settings.scss @@ -0,0 +1,83 @@ +.ClusterSettings { + .WizardLayout { + grid-template-columns: unset; + grid-template-rows: 76px 1fr; + padding: 0; + + .head-col { + justify-content: space-between; + + :nth-child(2) { + flex: 1 0 0; + } + } + + .content-col { + margin: 0; + padding-top: $padding * 3; + background-color: transparent; + + .SubTitle { + text-transform: none; + } + + > div { + margin-top: $margin * 5; + } + + .admin-note { + font-size: small; + opacity: 0.5; + margin-left: $margin; + } + + .button-area { + margin-top: $margin * 2; + } + + .file-loader { + margin-top: $margin * 2; + } + + .hint { + font-size: smaller; + opacity: 0.8; + } + + p + p, .hint + p { + padding-top: $padding; + } + } + + .status-table { + margin: $margin * 3 0; + + .Table { + border: 1px solid var(--drawerSubtitleBackground); + border-radius: $radius; + + .TableRow { + &:not(:last-of-type) { + border-bottom: 1px solid var(--drawerSubtitleBackground); + } + + .value { + flex-grow: 2; + word-break: break-word; + color: var(--textColorSecondary); + } + } + } + } + + .Input, .Select { + margin-top: 10px; + } + + .Select { + &__control { + box-shadow: 0 0 0 1px $borderFaintColor; + } + } + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/cluster-settings.tsx b/src/renderer/components/+cluster-settings/cluster-settings.tsx new file mode 100644 index 0000000000..7f1f0382fc --- /dev/null +++ b/src/renderer/components/+cluster-settings/cluster-settings.tsx @@ -0,0 +1,43 @@ +import "./cluster-settings.scss"; + +import React from "react"; +import { Link } from "react-router-dom"; +import { observer } from "mobx-react"; +import { Features } from "./features"; +import { Removal } from "./removal"; +import { Status } from "./status"; +import { General } from "./general"; +import { WizardLayout } from "../layout/wizard-layout"; +import { ClusterIcon } from "../cluster-icon"; +import { Icon } from "../icon"; +import { getMatchedCluster } from "../cluster-manager/cluster-view.route"; +import { navigate } from "../../navigation"; + +@observer +export class ClusterSettings extends React.Component { + render() { + const cluster = getMatchedCluster(); + if (!cluster) return null; + const header = ( + <> + +

{cluster.preferences.clusterName}

+ navigate("/")} big/> + + ); + return ( +
+ + + + + + +
+ ); + } +} diff --git a/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx new file mode 100644 index 0000000000..f998035b44 --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { Cluster } from "../../../../main/cluster"; +import { Input } from "../../input"; +import { SubTitle } from "../../layout/sub-title"; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterHomeDirSetting extends React.Component { + @observable directory = this.props.cluster.preferences.terminalCWD || ""; + + save = () => { + this.props.cluster.preferences.terminalCWD = this.directory; + }; + + onChange = (value: string) => { + this.directory = value; + } + + render() { + return ( + <> + +

Terminal working directory.

+ + + An explicit start path where the terminal will be launched,{" "} + this is used as the current working directory (cwd) for the shell process. + + + ); + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx new file mode 100644 index 0000000000..20b4e6d6d5 --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { Cluster } from "../../../../main/cluster"; +import { FilePicker, OverSizeLimitStyle } from "../../file-picker"; +import { autobind } from "../../../utils"; +import { Button } from "../../button"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { SubTitle } from "../../layout/sub-title"; +import { ClusterIcon } from "../../cluster-icon"; + +enum GeneralInputStatus { + CLEAN = "clean", + ERROR = "error", +} + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterIconSetting extends React.Component { + @observable status = GeneralInputStatus.CLEAN; + @observable errorText?: string; + + @autobind() + async onIconPick([file]: File[]) { + const { cluster } = this.props; + try { + if (file) { + const buf = Buffer.from(await file.arrayBuffer()); + cluster.preferences.icon = `data:image/jpeg;base64, ${buf.toString('base64')}`; + } else { + // this has to be done as a seperate branch (and not always) because `cluster` + // is observable and triggers an update loop. + cluster.preferences.icon = undefined; + } + } catch (e) { + this.errorText = e.toString() + this.status = GeneralInputStatus.ERROR + } + } + + getClearButton() { + if (this.props.cluster.preferences.icon) { + return + } + } + + render() { + const label = ( + <> + + {"Browse for new icon..."} + + ); + return ( + <> + +

Define cluster icon. By default automatically generated.

+
+ + {this.getClearButton()} +
+ + ); + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx new file mode 100644 index 0000000000..8e2f8a2afa --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Cluster } from "../../../../main/cluster"; +import { Input } from "../../input"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { SubTitle } from "../../layout/sub-title"; +import { isRequired } from "../../input/input.validators"; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterNameSetting extends React.Component { + @observable name = this.props.cluster.preferences.clusterName || ""; + + save = () => { + this.props.cluster.preferences.clusterName = this.name; + }; + + onChange = (value: string) => { + this.name = value; + } + + render() { + return ( + <> + +

Define cluster name.

+ + + ); + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx new file mode 100644 index 0000000000..729090629d --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { prometheusProviders } from "../../../../common/prometheus-providers"; +import { Cluster } from "../../../../main/cluster"; +import { SubTitle } from "../../layout/sub-title"; +import { Select, SelectOption } from "../../select"; +import { Input } from "../../input"; +import { observable, computed } from "mobx"; + +const options: SelectOption[] = [ + { value: "", label: "Auto detect" }, + ...prometheusProviders.map(pp => ({value: pp.id, label: pp.name})) +]; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterPrometheusSetting extends React.Component { + @observable path = ""; + @observable provider = ""; + + @computed get canEditPrometheusPath() { + if (this.provider === "" || this.provider === "lens") return false; + return true; + } + + componentDidMount() { + const { prometheus, prometheusProvider } = this.props.cluster.preferences; + if (prometheus) { + const prefix = prometheus.prefix || ""; + this.path = `${prometheus.namespace}/${prometheus.service}:${prometheus.port}${prefix}`; + } + if (prometheusProvider) { + this.provider = prometheusProvider.type; + } + } + + parsePrometheusPath = () => { + if (!this.provider || !this.path) { + return null; + } + const parsed = this.path.split(/\/|:/, 3); + const apiPrefix = this.path.substring(parsed.join("/").length); + if (!parsed[0] || !parsed[1] || !parsed[2]) { + return null; + } + return { + namespace: parsed[0], + service: parsed[1], + port: parseInt(parsed[2]), + prefix: apiPrefix + } + } + + onSaveProvider = () => { + this.props.cluster.preferences.prometheusProvider = this.provider ? + { type: this.provider } : + null; + } + + onSavePath = () => { + this.props.cluster.preferences.prometheus = this.parsePrometheusPath(); + }; + + render() { + return ( + <> + +

+ Use pre-installed Prometheus service for metrics. Please refer to the{" "} + guide{" "} + for possible configuration changes. +

+

Prometheus installation method.

+ this.path = value} + onBlur={this.onSavePath} + placeholder="/:" + /> + + An address to an existing Prometheus installation{" "} + ({'/:'}). Lens tries to auto-detect address if left empty. + + + )} + + ); + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx new file mode 100644 index 0000000000..1b94992e5b --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import { Cluster } from "../../../../main/cluster"; +import { Input } from "../../input"; +import { isUrl } from "../../input/input.validators"; +import { SubTitle } from "../../layout/sub-title"; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterProxySetting extends React.Component { + @observable proxy = this.props.cluster.preferences.httpsProxy || ""; + + save = () => { + this.props.cluster.preferences.httpsProxy = this.proxy; + }; + + onChange = (value: string) => { + this.proxy = value; + } + + render() { + return ( + <> + +

HTTP Proxy server. Used for communicating with Kubernetes API.

+ + + ); + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx new file mode 100644 index 0000000000..6cd933ca11 --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { Link } from "react-router-dom"; +import { workspacesURL } from "../../+workspaces"; +import { workspaceStore } from "../../../../common/workspace-store"; +import { Cluster } from "../../../../main/cluster"; +import { Select } from "../../../components/select"; +import { SubTitle } from "../../layout/sub-title"; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterWorkspaceSetting extends React.Component { + render() { + return ( + <> + +

+ Define cluster{" "} + + workspace + . +

+ { +interface Props extends KubeObjectDetailsProps { } function CrdColumnValue({ value }: { value: any[] | {} | string }) { diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index 834fbacf40..a8f6d8f61b 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -1,13 +1,13 @@ import "./events.scss"; -import React from "react"; +import React, { Fragment } from "react"; import { observer } from "mobx-react"; import { MainLayout } from "../layout/main-layout"; import { eventStore } from "./event.store"; import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object"; import { Trans } from "@lingui/macro"; import { KubeEvent } from "../../api/endpoints/events.api"; -import { Tooltip, TooltipContent } from "../tooltip"; +import { Tooltip } from "../tooltip"; import { Link } from "react-router-dom"; import { cssNames, IClassName, stopPropagation } from "../../utils"; import { Icon } from "../icon"; @@ -90,18 +90,14 @@ export class Events extends React.Component { const detailsUrl = getDetailsUrl(lookupApiLink(involvedObject, event)); return [ { - className: { - warning: isWarning - }, + className: { warning: isWarning }, title: ( - <> + {message} - - - {message} - + + {message} - + ) }, event.getNs(), diff --git a/src/renderer/components/+events/kube-event-icon.tsx b/src/renderer/components/+events/kube-event-icon.tsx index bad4c055ca..204dfd3a36 100644 --- a/src/renderer/components/+events/kube-event-icon.tsx +++ b/src/renderer/components/+events/kube-event-icon.tsx @@ -2,7 +2,6 @@ import "./kube-event-icon.scss"; import React from "react"; import { Icon } from "../icon"; -import { TooltipContent } from "../tooltip"; import { KubeObject } from "../../api/kube-object"; import { eventStore } from "./event.store"; import { cssNames } from "../../utils"; @@ -34,15 +33,17 @@ export class KubeEventIcon extends React.Component { - {event.message} -
- - {event.getAge(undefined, undefined, true)} + tooltip={{ + children: ( +
+
{event.message}
+
+ + {event.getAge(undefined, undefined, true)} +
- - )} + ) + }} /> ) } diff --git a/src/renderer/components/+landing-page/index.tsx b/src/renderer/components/+landing-page/index.tsx new file mode 100644 index 0000000000..36f2dce350 --- /dev/null +++ b/src/renderer/components/+landing-page/index.tsx @@ -0,0 +1,2 @@ +export * from "./landing-page.route" +export * from "./landing-page" \ No newline at end of file diff --git a/src/renderer/components/+landing-page/landing-page.route.ts b/src/renderer/components/+landing-page/landing-page.route.ts new file mode 100644 index 0000000000..3b3e17a6c2 --- /dev/null +++ b/src/renderer/components/+landing-page/landing-page.route.ts @@ -0,0 +1,8 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const landingRoute: RouteProps = { + path: "/landing" +} + +export const landingURL = buildURL(landingRoute.path) diff --git a/src/renderer/components/+landing-page/landing-page.scss b/src/renderer/components/+landing-page/landing-page.scss new file mode 100644 index 0000000000..6cc726f5d9 --- /dev/null +++ b/src/renderer/components/+landing-page/landing-page.scss @@ -0,0 +1,8 @@ +.LandingPage { + height: 100%; + background: #282b2f url(../../components/icon/crane.svg) no-repeat; + background-position: 0 35%; + background-size: 85%; + background-clip: content-box; + text-align: center; +} \ No newline at end of file diff --git a/src/renderer/components/+landing-page/landing-page.tsx b/src/renderer/components/+landing-page/landing-page.tsx new file mode 100644 index 0000000000..134c950f92 --- /dev/null +++ b/src/renderer/components/+landing-page/landing-page.tsx @@ -0,0 +1,28 @@ +import "./landing-page.scss" +import React from "react"; +import { observer } from "mobx-react"; +import { Trans } from "@lingui/macro"; +import { clusterStore } from "../../../common/cluster-store"; +import { workspaceStore } from "../../../common/workspace-store"; + +@observer +export class LandingPage extends React.Component { + render() { + const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId); + const noClustersInScope = !clusters.length; + return ( +
+ {noClustersInScope && ( +
+

+ Welcome! +

+

+ Get started by associating one or more clusters to Lens. +

+
+ )} +
+ ) + } +} diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 925835ff19..4d4dcc22da 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -11,7 +11,6 @@ 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"; -import { isAllowedResource } from "../../api/rbac" interface Props extends SelectProps { showIcons?: boolean; diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 7f6e22748e..624c823a52 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -4,7 +4,7 @@ import { KubeObjectStore } from "../../kube-object.store"; import { Namespace, namespacesApi } from "../../api/endpoints"; import { IQueryParams, navigation, setQueryParams } from "../../navigation"; import { apiManager } from "../../api/api-manager"; -import { isAllowedResource } from "../..//api/rbac"; +import { isAllowedResource } from "../../../common/rbac"; @autobind() export class NamespaceStore extends KubeObjectStore { diff --git a/src/renderer/components/+network/network.tsx b/src/renderer/components/+network/network.tsx index 7e0cfa99f6..96ea5bc27f 100644 --- a/src/renderer/components/+network/network.tsx +++ b/src/renderer/components/+network/network.tsx @@ -12,7 +12,7 @@ import { Ingresses, ingressRoute, ingressURL } from "../+network-ingresses"; import { NetworkPolicies, networkPoliciesRoute, networkPoliciesURL } from "../+network-policies"; import { namespaceStore } from "../+namespaces/namespace.store"; import { networkURL } from "./network.route"; -import { isAllowedResource } from "../../api/rbac"; +import { isAllowedResource } from "../../../common/rbac"; interface Props extends RouteComponentProps<{}> { } diff --git a/src/renderer/components/+nodes/node-details.tsx b/src/renderer/components/+nodes/node-details.tsx index 259c5c2c1a..73a497063e 100644 --- a/src/renderer/components/+nodes/node-details.tsx +++ b/src/renderer/components/+nodes/node-details.tsx @@ -7,7 +7,6 @@ import { disposeOnUnmount, observer } from "mobx-react"; import { Trans } from "@lingui/macro"; import { DrawerItem, DrawerItemLabels } from "../drawer"; import { Badge } from "../badge"; -import { TooltipContent } from "../tooltip"; import { nodesStore } from "./nodes.store"; import { ResourceMetrics } from "../resource-metrics"; import { podsStore } from "../+workloads-pods/pods.store"; @@ -127,16 +126,17 @@ export class NodeDetails extends React.Component { key={type} label={type} className={kebabCase(type)} - tooltip={ - - {Object.entries(condition).map(([key, value]) => -
-
{upperFirst(key)}
-
{value}
-
- )} -
- } + tooltip={{ + formatters: { + tableView: true, + }, + children: Object.entries(condition).map(([key, value]) => +
+
{upperFirst(key)}
+
{value}
+
+ ) + }} /> ) }) diff --git a/src/renderer/components/+nodes/nodes.tsx b/src/renderer/components/+nodes/nodes.tsx index 2b9658fbf3..e2d67a5870 100644 --- a/src/renderer/components/+nodes/nodes.tsx +++ b/src/renderer/components/+nodes/nodes.tsx @@ -1,5 +1,4 @@ import "./nodes.scss"; - import React from "react"; import { observer } from "mobx-react"; import { RouteComponentProps } from "react-router"; @@ -15,7 +14,7 @@ import { NodeMenu } from "./node-menu"; import { LineProgress } from "../line-progress"; import { _i18n } from "../../i18n"; import { bytesToUnits } from "../../utils/convertMemory"; -import { Tooltip, TooltipContent } from "../tooltip"; +import { Tooltip, TooltipPosition } from "../tooltip"; import kebabCase from "lodash/kebabCase"; import upperFirst from "lodash/upperFirst"; import { apiManager } from "../../api/api-manager"; @@ -57,7 +56,10 @@ export class Nodes extends React.Component { ) } @@ -71,7 +73,10 @@ export class Nodes extends React.Component { ) } @@ -85,7 +90,10 @@ export class Nodes extends React.Component { ) } @@ -101,15 +109,13 @@ export class Nodes extends React.Component { return (
{type} - - - {Object.entries(condition).map(([key, value]) => -
-
{upperFirst(key)}
-
{value}
-
- )} -
+ + {Object.entries(condition).map(([key, value]) => +
+
{upperFirst(key)}
+
{value}
+
+ )}
) }) @@ -162,7 +168,7 @@ export class Nodes extends React.Component { this.renderDiskUsage(node), <> {node.getTaints().length} - + {node.getTaints().map(({ key, effect }) => `${key}: ${effect}`).join("\n")} , diff --git a/src/renderer/components/+preferences/index.tsx b/src/renderer/components/+preferences/index.tsx new file mode 100644 index 0000000000..b4d669a029 --- /dev/null +++ b/src/renderer/components/+preferences/index.tsx @@ -0,0 +1,2 @@ +export * from "./preferences.route" +export * from "./preferences" diff --git a/src/renderer/components/+preferences/preferences.route.ts b/src/renderer/components/+preferences/preferences.route.ts new file mode 100644 index 0000000000..6e71a88963 --- /dev/null +++ b/src/renderer/components/+preferences/preferences.route.ts @@ -0,0 +1,8 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const preferencesRoute: RouteProps = { + path: "/preferences" +} + +export const preferencesURL = buildURL(preferencesRoute.path) diff --git a/src/renderer/components/+preferences/preferences.scss b/src/renderer/components/+preferences/preferences.scss new file mode 100644 index 0000000000..94c08e8f53 --- /dev/null +++ b/src/renderer/components/+preferences/preferences.scss @@ -0,0 +1,51 @@ +.Preferences { + position: fixed!important; // Allows to cover ClustersMenu + z-index: 1; + + .WizardLayout { + grid-template-columns: unset; + grid-template-rows: 76px 1fr; + padding: 0; + + .content-col { + background-color: transparent; + padding: $padding * 8 0; + + h2 { + margin-bottom: $margin * 2; + + &:not(:first-child) { + margin-top: $margin * 3; + } + } + + .repos { + position: relative; + + .Badge { + display: flex; + margin: 0; + margin-bottom: 1px; + padding: $padding $padding * 2; + } + } + + .hint { + margin-top: -$margin; + } + } + } + + .is-mac & { + .WizardLayout .head-col { + padding-top: 32px; + overflow: hidden; + } + } + + .Select { + &__control { + box-shadow: 0 0 0 1px $borderFaintColor; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx new file mode 100644 index 0000000000..fc91952213 --- /dev/null +++ b/src/renderer/components/+preferences/preferences.tsx @@ -0,0 +1,198 @@ +import "./preferences.scss" +import React from "react"; +import { observer } from "mobx-react"; +import { action, computed, observable } from "mobx"; +import { t, Trans } from "@lingui/macro"; +import { _i18n } from "../../i18n"; +import { WizardLayout } from "../layout/wizard-layout"; +import { Icon } from "../icon"; +import { Select, SelectOption } from "../select"; +import { userStore } from "../../../common/user-store"; +import { HelmRepo, repoManager } from "../../../main/helm/helm-repo-manager"; +import { Input } from "../input"; +import { Checkbox } from "../checkbox"; +import { Notifications } from "../notifications"; +import { Badge } from "../badge"; +import { Spinner } from "../spinner"; +import { themeStore } from "../../theme.store"; +import { history } from "../../navigation"; +import { Tooltip } from "../tooltip"; + +@observer +export class Preferences extends React.Component { + @observable helmLoading = false; + @observable helmRepos: HelmRepo[] = []; + @observable helmAddedRepos = observable.map(); + + @observable downloadMirrorOptions: SelectOption[] = [ + { value: "default", label: "Default (Google)" }, + { value: "china", label: "China (Azure)" }, + ] + + @computed get themeOptions(): SelectOption[] { + return themeStore.themes.map(theme => ({ + label: theme.name, + value: theme.id, + })) + } + + @computed get helmOptions(): SelectOption[] { + return this.helmRepos.map(repo => ({ + label: repo.name, + value: repo, + })) + } + + async componentDidMount() { + await this.loadHelmRepos(); + } + + @action + async loadHelmRepos() { + this.helmLoading = true; + try { + if (!this.helmRepos.length) { + this.helmRepos = await repoManager.loadAvailableRepos(); // via https://helm.sh + } + const repos = await repoManager.repositories(); // via helm-cli + this.helmAddedRepos.clear(); + repos.forEach(repo => this.helmAddedRepos.set(repo.name, repo)); + } catch (err) { + Notifications.error(err); + } + this.helmLoading = false; + } + + async addRepo(repo: HelmRepo) { + try { + await repoManager.addRepo(repo); + this.helmAddedRepos.set(repo.name, repo); + } catch (err) { + Notifications.error(Adding helm branch {repo.name} has failed: {String(err)}) + } + } + + async removeRepo(repo: HelmRepo) { + try { + await repoManager.removeRepo(repo); + this.helmAddedRepos.delete(repo.name); + } catch (err) { + Notifications.error( + Removing helm branch {repo.name} has failed: {String(err)} + ) + } + } + + onRepoSelect = async ({ value: repo }: SelectOption) => { + const isAdded = this.helmAddedRepos.has(repo.name); + if (isAdded) { + Notifications.ok(Helm branch {repo.name} already in use) + return; + } + this.helmLoading = true; + await this.addRepo(repo); + this.helmLoading = false; + } + + formatHelmOptionLabel = ({ value: repo }: SelectOption) => { + const isAdded = this.helmAddedRepos.has(repo.name); + return ( +
+ {repo.name} + {isAdded && } +
+ ) + } + + render() { + const { preferences } = userStore; + const header = ( + <> +

Preferences

+ + + ); + return ( +
+ +

Color Theme

+ Download mirror for kubectl} + options={this.downloadMirrorOptions} + value={preferences.downloadMirror} + onChange={({ value }: SelectOption) => preferences.downloadMirror = value} + /> + +

Helm

+ preferences.httpsProxy = v} + /> + + Proxy is used only for non-cluster communication. + + +

Certificate Trust

+ Allow untrusted Certificate Authorities} + value={preferences.allowUntrustedCAs} + onChange={v => preferences.allowUntrustedCAs = v} + /> + + This will make Lens to trust ANY certificate authority without any validations.{" "} + Needed with some corporate proxies that do certificate re-writing.{" "} + Does not affect cluster communications! + + +

Telemetry & Usage Tracking

+ Allow telemetry & usage tracking} + value={preferences.allowTelemetry} + onChange={v => preferences.allowTelemetry = v} + /> + + Telemetry & usage data is collected to continuously improve the Lens experience. + +
+
+ ); + } +} diff --git a/src/renderer/components/+storage/storage.tsx b/src/renderer/components/+storage/storage.tsx index 0951811dcb..9da302b908 100644 --- a/src/renderer/components/+storage/storage.tsx +++ b/src/renderer/components/+storage/storage.tsx @@ -9,9 +9,9 @@ import { MainLayout, TabRoute } from "../layout/main-layout"; import { PersistentVolumes, volumesRoute, volumesURL } from "../+storage-volumes"; import { StorageClasses, storageClassesRoute, storageClassesURL } from "../+storage-classes"; import { PersistentVolumeClaims, volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims"; -import { configStore } from "../../config.store"; import { namespaceStore } from "../+namespaces/namespace.store"; import { storageURL } from "./storage.route"; +import { isAllowedResource } from "../../../common/rbac"; interface Props extends RouteComponentProps<{}> { } @@ -20,7 +20,6 @@ interface Props extends RouteComponentProps<{}> { export class Storage extends React.Component { static get tabRoutes() { const tabRoutes: TabRoute[] = []; - const { allowedResources } = configStore; const query = namespaceStore.getContextParams() tabRoutes.push({ @@ -30,7 +29,7 @@ export class Storage extends React.Component { path: volumeClaimsRoute.path, }) - if (allowedResources.includes('persistentvolumes')) { + if (isAllowedResource('persistentvolumes')) { tabRoutes.push({ title: Persistent Volumes, component: PersistentVolumes, @@ -39,7 +38,7 @@ export class Storage extends React.Component { }); } - if (allowedResources.includes('storageclasses')) { + if (isAllowedResource('storageclasses')) { tabRoutes.push({ title: Storage Classes, component: StorageClasses, diff --git a/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx b/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx index 8d9f0da888..55dc94068a 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx +++ b/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx @@ -5,7 +5,7 @@ import { observer } from "mobx-react"; import { Trans } from "@lingui/macro"; import { RouteComponentProps } from "react-router"; import { Icon } from "../icon"; -import { IRoleBindingsRouteParams } from "../+user-management/user-management.routes"; +import { IRoleBindingsRouteParams } from "../+user-management/user-management.route"; import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; import { clusterRoleBindingApi, RoleBinding, roleBindingApi } from "../../api/endpoints"; import { roleBindingsStore } from "./role-bindings.store"; diff --git a/src/renderer/components/+user-management-roles/roles.tsx b/src/renderer/components/+user-management-roles/roles.tsx index f542fafc42..f9b9e8c239 100644 --- a/src/renderer/components/+user-management-roles/roles.tsx +++ b/src/renderer/components/+user-management-roles/roles.tsx @@ -4,7 +4,7 @@ import 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 { IRolesRouteParams } from "../+user-management/user-management.route"; import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu"; import { rolesStore } from "./roles.store"; import { clusterRoleApi, Role, roleApi } from "../../api/endpoints"; diff --git a/src/renderer/components/+user-management/index.ts b/src/renderer/components/+user-management/index.ts index f9b243aa50..8250079e60 100644 --- a/src/renderer/components/+user-management/index.ts +++ b/src/renderer/components/+user-management/index.ts @@ -1,2 +1,2 @@ export * from "./user-management" -export * from "./user-management.routes" \ No newline at end of file +export * from "./user-management.route" \ No newline at end of file diff --git a/src/renderer/components/+user-management/user-management.routes.ts b/src/renderer/components/+user-management/user-management.route.ts similarity index 100% rename from src/renderer/components/+user-management/user-management.routes.ts rename to src/renderer/components/+user-management/user-management.route.ts diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index 3806cc37d7..28a7964e76 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -1,5 +1,4 @@ import "./user-management.scss" - import React from "react"; import { observer } from "mobx-react"; import { Redirect, Route, Switch } from "react-router"; @@ -9,10 +8,10 @@ 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 { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL, usersManagementURL } from "./user-management.route"; import { namespaceStore } from "../+namespaces/namespace.store"; -import { configStore } from "../../config.store"; import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies"; +import { isAllowedResource } from "../../../common/rbac"; interface Props extends RouteComponentProps<{}> { } @@ -21,7 +20,6 @@ interface Props extends RouteComponentProps<{}> { export class UserManagement extends React.Component { static get tabRoutes() { const tabRoutes: TabRoute[] = []; - const { allowedResources } = configStore; const query = namespaceStore.getContextParams() tabRoutes.push( { @@ -43,7 +41,7 @@ export class UserManagement extends React.Component { path: rolesRoute.path, }, ) - if (allowedResources.includes("podsecuritypolicies")) { + if (isAllowedResource("podsecuritypolicies")) { tabRoutes.push({ title: Pod Security Policies, component: PodSecurityPolicies, diff --git a/src/renderer/components/+whats-new/index.tsx b/src/renderer/components/+whats-new/index.tsx new file mode 100644 index 0000000000..9f46112328 --- /dev/null +++ b/src/renderer/components/+whats-new/index.tsx @@ -0,0 +1,2 @@ +export * from "./whats-new.route" +export * from "./whats-new" diff --git a/src/renderer/components/+whats-new/whats-new.route.ts b/src/renderer/components/+whats-new/whats-new.route.ts new file mode 100644 index 0000000000..ee251ff81e --- /dev/null +++ b/src/renderer/components/+whats-new/whats-new.route.ts @@ -0,0 +1,8 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const whatsNewRoute: RouteProps = { + path: "/what-s-new" +} + +export const whatsNewURL = buildURL(whatsNewRoute.path) diff --git a/src/renderer/components/+whats-new/whats-new.scss b/src/renderer/components/+whats-new/whats-new.scss new file mode 100644 index 0000000000..9eef62cc13 --- /dev/null +++ b/src/renderer/components/+whats-new/whats-new.scss @@ -0,0 +1,36 @@ +.WhatsNew { + $spacing: $padding * 2; + + background: $mainBackground url(../../components/icon/crane.svg) no-repeat; + background-position: 0 35%; + background-size: 85%; + background-clip: content-box; + + .logo { + width: 200px; + margin-bottom: $spacing; + } + + > .content { + @include custom-scrollbar; + margin-top: $spacing; + padding: $spacing * 2; + + a { + color: $colorInfo; + text-decoration: underline; + } + + ul { + list-style: disc inside; + line-height: 120%; + padding-left: $spacing * 2; + } + } + + > .bottom { + text-align: center; + padding: $spacing; + background: $contentColor; + } +} \ No newline at end of file diff --git a/src/renderer/components/+whats-new/whats-new.tsx b/src/renderer/components/+whats-new/whats-new.tsx new file mode 100644 index 0000000000..c63e8f25d1 --- /dev/null +++ b/src/renderer/components/+whats-new/whats-new.tsx @@ -0,0 +1,43 @@ +import "./whats-new.scss" +import fs from "fs"; +import path from "path"; +import React from "react"; +import { observer } from "mobx-react"; +import { userStore } from "../../../common/user-store" +import { navigate } from "../../navigation"; +import { Button } from "../button"; +import { Trans } from "@lingui/macro"; +import marked from "marked" + +@observer +export class WhatsNew extends React.Component { + releaseNotes = fs.readFileSync(path.join(__static, "RELEASE_NOTES.md")).toString(); + + ok = () => { + navigate("/"); + userStore.saveLastSeenAppVersion(); + } + + render() { + const logo = require("../../components/icon/lens-logo.svg"); + const releaseNotes = marked(this.releaseNotes); + return ( +
+
+ Lens +
+
+
+
+
+ ); + } +} diff --git a/src/renderer/components/+workloads-deployments/deployments.tsx b/src/renderer/components/+workloads-deployments/deployments.tsx index 32fb1ec10c..5a3031787a 100644 --- a/src/renderer/components/+workloads-deployments/deployments.tsx +++ b/src/renderer/components/+workloads-deployments/deployments.tsx @@ -98,7 +98,7 @@ export function DeploymentMenu(props: KubeObjectMenuProps) { return ( DeploymentScaleDialog.open(object)}> - + Scale diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx index 83575fec86..be770cd39a 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -15,13 +15,11 @@ 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"; -import { configStore } from "../../config.store"; -import { isAllowedResource } from "../../api/rbac"; +import { isAllowedResource } from "../../../common/rbac"; @observer export class OverviewStatuses extends React.Component { render() { - const { allowedResources } = configStore; const { contextNs } = namespaceStore; const pods = isAllowedResource("pods") ? podsStore.getAllByNs(contextNs) : []; const deployments = isAllowedResource("deployments") ? deploymentStore.getAllByNs(contextNs) : []; @@ -37,37 +35,37 @@ export class OverviewStatuses extends React.Component {
- { isAllowedResource("pods") && + {isAllowedResource("pods") &&
Pods ({pods.length})
} - { isAllowedResource("deployments") && + {isAllowedResource("deployments") &&
Deployments ({deployments.length})
} - { isAllowedResource("statefulsets") && + {isAllowedResource("statefulsets") &&
StatefulSets ({statefulSets.length})
} - { isAllowedResource("daemonsets") && + {isAllowedResource("daemonsets") &&
DaemonSets ({daemonSets.length})
} - { isAllowedResource("jobs") && + {isAllowedResource("jobs") &&
Jobs ({jobs.length})
} - { isAllowedResource("cronjobs") && + {isAllowedResource("cronjobs") &&
CronJobs ({cronJobs.length})
diff --git a/src/renderer/components/+workloads-overview/overview.tsx b/src/renderer/components/+workloads-overview/overview.tsx index c3f08d13c2..9dd201cbf2 100644 --- a/src/renderer/components/+workloads-overview/overview.tsx +++ b/src/renderer/components/+workloads-overview/overview.tsx @@ -17,7 +17,7 @@ import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { Spinner } from "../spinner"; import { Events } from "../+events"; import { KubeObjectStore } from "../../kube-object.store"; -import { isAllowedResource } from "../../api/rbac" +import { isAllowedResource } from "../../../common/rbac" interface Props extends RouteComponentProps { } diff --git a/src/renderer/components/+workloads-pods/container-charts.tsx b/src/renderer/components/+workloads-pods/container-charts.tsx index f879fa4662..eeaa7cde76 100644 --- a/src/renderer/components/+workloads-pods/container-charts.tsx +++ b/src/renderer/components/+workloads-pods/container-charts.tsx @@ -1,5 +1,6 @@ import React, { useContext } from "react"; import { t } 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"; @@ -10,7 +11,7 @@ import { themeStore } from "../../theme.store"; type IContext = IResourceMetricsValue; -export const ContainerCharts = () => { +export const ContainerCharts = observer(() => { const { params: { metrics }, tabId } = useContext(ResourceMetricsContext); const { chartCapacityColor } = themeStore.activeTheme.colors; @@ -100,4 +101,4 @@ export const ContainerCharts = () => { data={{ datasets: datasets[tabId] }} /> ); -} \ No newline at end of file +}) \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx b/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx index e6e9708002..d7ff3863cc 100644 --- a/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx +++ b/src/renderer/components/+workloads-pods/pod-logs-dialog.tsx @@ -226,7 +226,7 @@ export class PodLogsDialog extends React.Component { tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)} /> diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx index 890a13fe41..5f5ec19fbd 100644 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ b/src/renderer/components/+workloads-pods/pods.tsx @@ -14,7 +14,6 @@ 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"; @@ -42,26 +41,29 @@ 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 ( - + ( + +
+ {name} ({status}{ready ? ", ready" : ""}) +
+ {toPairs(state[status]).map(([name, value]) => ( +
+
{startCase(name)}
+
{value}
+
+ ))} +
+ )) + }} + />
) }); diff --git a/src/renderer/components/+workloads/workloads.tsx b/src/renderer/components/+workloads/workloads.tsx index ebf5c9d348..94d1755eaa 100644 --- a/src/renderer/components/+workloads/workloads.tsx +++ b/src/renderer/components/+workloads/workloads.tsx @@ -15,7 +15,7 @@ import { DaemonSets } from "../+workloads-daemonsets"; import { StatefulSets } from "../+workloads-statefulsets"; import { Jobs } from "../+workloads-jobs"; import { CronJobs } from "../+workloads-cronjobs"; -import { isAllowedResource } from "../../api/rbac" +import { isAllowedResource } from "../../../common/rbac" interface Props extends RouteComponentProps { } diff --git a/src/renderer/components/+workspaces/index.ts b/src/renderer/components/+workspaces/index.ts new file mode 100644 index 0000000000..6aa0afedd3 --- /dev/null +++ b/src/renderer/components/+workspaces/index.ts @@ -0,0 +1,2 @@ +export * from "./workspaces.route" +export * from "./workspaces" diff --git a/src/renderer/components/+workspaces/workspace-menu.scss b/src/renderer/components/+workspaces/workspace-menu.scss new file mode 100644 index 0000000000..e7adf9ae6a --- /dev/null +++ b/src/renderer/components/+workspaces/workspace-menu.scss @@ -0,0 +1,7 @@ +.WorkspaceMenu { + border-radius: $radius; + + .workspaces-title { + padding: $padding; + } +} \ No newline at end of file diff --git a/src/renderer/components/+workspaces/workspace-menu.tsx b/src/renderer/components/+workspaces/workspace-menu.tsx new file mode 100644 index 0000000000..bcf9c3a74d --- /dev/null +++ b/src/renderer/components/+workspaces/workspace-menu.tsx @@ -0,0 +1,51 @@ +import "./workspace-menu.scss" +import React from "react"; +import { observer } from "mobx-react"; +import { Link } from "react-router-dom"; +import { workspacesURL } from "./workspaces.route"; +import { Trans } from "@lingui/macro"; +import { Menu, MenuItem, MenuProps } from "../menu"; +import { Icon } from "../icon"; +import { observable } from "mobx"; +import { workspaceStore } from "../../../common/workspace-store"; +import { cssNames } from "../../utils"; + +interface Props extends Partial { +} + +@observer +export class WorkspaceMenu extends React.Component { + @observable menuVisible = false; + + render() { + const { className, ...menuProps } = this.props; + const { workspacesList, currentWorkspace } = workspaceStore; + return ( + this.menuVisible = true} + close={() => this.menuVisible = false} + > + + Workspaces + + {workspacesList.map(({ id: workspaceId, name, description }) => { + return ( + workspaceStore.setActive(workspaceId)} + > + + {name} + + ) + })} + + ) + } +} diff --git a/src/renderer/components/+workspaces/workspaces.route.ts b/src/renderer/components/+workspaces/workspaces.route.ts new file mode 100644 index 0000000000..bfdbe012a6 --- /dev/null +++ b/src/renderer/components/+workspaces/workspaces.route.ts @@ -0,0 +1,8 @@ +import { RouteProps } from "react-router"; +import { buildURL } from "../../navigation"; + +export const workspacesRoute: RouteProps = { + path: "/workspaces" +} + +export const workspacesURL = buildURL(workspacesRoute.path) diff --git a/src/renderer/components/+workspaces/workspaces.scss b/src/renderer/components/+workspaces/workspaces.scss new file mode 100644 index 0000000000..95c036c304 --- /dev/null +++ b/src/renderer/components/+workspaces/workspaces.scss @@ -0,0 +1,14 @@ +.Workspaces { + .workspace { + --flex-gap: #{$padding}; + padding: $padding / 2; + + &.default { + font-style: italic; + } + + > .description { + flex: 1; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/+workspaces/workspaces.tsx b/src/renderer/components/+workspaces/workspaces.tsx new file mode 100644 index 0000000000..96007a95fa --- /dev/null +++ b/src/renderer/components/+workspaces/workspaces.tsx @@ -0,0 +1,174 @@ +import "./workspaces.scss" +import React, { Fragment } from "react"; +import { observer } from "mobx-react"; +import { computed, observable, toJS } from "mobx"; +import { t, Trans } from "@lingui/macro"; +import { WizardLayout } from "../layout/wizard-layout"; +import { Workspace, WorkspaceId, workspaceStore } from "../../../common/workspace-store"; +import { v4 as uuid } from "uuid" +import { _i18n } from "../../i18n"; +import { ConfirmDialog } from "../confirm-dialog"; +import { Icon } from "../icon"; +import { Input } from "../input"; +import { cssNames, prevDefault } from "../../utils"; +import { Button } from "../button"; + +@observer +export class Workspaces extends React.Component { + @observable editingWorkspaces = observable.map(); + + @computed get workspaces(): Workspace[] { + const allWorkspaces = new Map([ + ...workspaceStore.workspaces, + ...this.editingWorkspaces, + ]); + return Array.from(allWorkspaces.values()); + } + + renderInfo() { + return ( + +

What is a Workspace?

+

+ Workspaces are used to organize number of clusters into logical groups. +

+

+ A single workspaces contains a list of clusters and their full configuration. +

+
+ ) + } + + saveWorkspace = (id: WorkspaceId) => { + const draft = toJS(this.editingWorkspaces.get(id)); + if (draft) { + this.clearEditing(id); + workspaceStore.saveWorkspace(draft); + } + } + + addWorkspace = () => { + const workspaceId = uuid(); + this.editingWorkspaces.set(workspaceId, { + id: workspaceId, + name: "", + description: "", + }) + } + + editWorkspace = (id: WorkspaceId) => { + const workspace = workspaceStore.getById(id); + this.editingWorkspaces.set(id, toJS(workspace)); + } + + clearEditing = (id: WorkspaceId) => { + this.editingWorkspaces.delete(id); + } + + removeWorkspace = (id: WorkspaceId) => { + const workspace = workspaceStore.getById(id); + ConfirmDialog.open({ + okButtonProps: { + label: _i18n._(t`Remove Workspace`), + primary: false, + accent: true, + }, + ok: () => { + this.clearEditing(id); + workspaceStore.removeWorkspace(id); + }, + message: ( +
+

+ Are you sure you want remove workspace {workspace.name}? +

+

+ All clusters within workspace will be cleared as well +

+
+ ), + }) + } + + render() { + return ( + +

+ Workspaces +

+
+ {this.workspaces.map(({ id: workspaceId, name, description }) => { + const isActive = workspaceStore.currentWorkspaceId === workspaceId; + const isDefault = workspaceStore.isDefault(workspaceId); + const isEditing = this.editingWorkspaces.has(workspaceId); + const editingWorkspace = this.editingWorkspaces.get(workspaceId); + const className = cssNames("workspace flex gaps align-center", { + active: isActive, + editing: isEditing, + default: isDefault, + }); + return ( +
+ {!isEditing && ( + + + workspaceStore.setActive(workspaceId))}>{name} + {isActive && (current)} + + {description} + {!isDefault && ( + + Edit} + onClick={() => this.editWorkspace(workspaceId)} + /> + Delete} + onClick={() => this.removeWorkspace(workspaceId)} + /> + + )} + + )} + {isEditing && ( + + editingWorkspace.name = v} + /> + editingWorkspace.description = v} + /> + Cancel} + onClick={() => this.clearEditing(workspaceId)} + /> + Save} + onClick={() => this.saveWorkspace(workspaceId)} + /> + + )} +
+ ) + })} +
+
diff --git a/src/renderer/components/dialog/dialog.tsx b/src/renderer/components/dialog/dialog.tsx index d816a5aa9c..071c6aa811 100644 --- a/src/renderer/components/dialog/dialog.tsx +++ b/src/renderer/components/dialog/dialog.tsx @@ -8,6 +8,8 @@ import { Animate } from "../animate"; import { cssNames, noop, stopPropagation } from "../../utils"; import { navigation } from "../../navigation"; +// todo: refactor + handle animation-end in props.onClose()? + export interface DialogProps { className?: string; isOpen?: boolean; @@ -24,7 +26,6 @@ interface DialogState { isOpen: boolean; } -// fixme: handle animation end props.onClose() (await props.close()?) @observer export class Dialog extends React.PureComponent { private contentElem: HTMLElement; diff --git a/src/renderer/components/dialog/logs-dialog.tsx b/src/renderer/components/dialog/logs-dialog.tsx index 1aeb9b1811..4ce1b3b27f 100644 --- a/src/renderer/components/dialog/logs-dialog.tsx +++ b/src/renderer/components/dialog/logs-dialog.tsx @@ -10,6 +10,8 @@ import { Button } from "../button"; import { Icon } from "../icon"; import { _i18n } from "../../i18n"; +// todo: make as external BrowserWindow (?) + interface Props extends DialogProps { title: string; logs: string; @@ -41,7 +43,7 @@ export class LogsDialog extends React.Component { - this.logsElem = e}> + this.logsElem = e}> {logs || There are no logs available.} diff --git a/src/renderer/components/dock/terminal.store.ts b/src/renderer/components/dock/terminal.store.ts index 278a538852..ee5ab694dc 100644 --- a/src/renderer/components/dock/terminal.store.ts +++ b/src/renderer/components/dock/terminal.store.ts @@ -6,7 +6,6 @@ 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 @@ -16,7 +15,6 @@ export function isTerminalTab(tab: IDockTab) { return tab && tab.kind === TabKind.TERMINAL; } - export function createTerminalTab(tabParams: Partial = {}) { return dockStore.createTab({ kind: TabKind.TERMINAL, @@ -56,7 +54,6 @@ export class TerminalStore { const api = new TerminalApi({ id: tabId, node: tab.node, - colorTheme: themeStore.activeTheme.type }); const terminal = new Terminal(tabId, api); this.connections.set(tabId, api); diff --git a/src/renderer/components/dock/terminal.ts b/src/renderer/components/dock/terminal.ts index 565b1f8781..f0685c0255 100644 --- a/src/renderer/components/dock/terminal.ts +++ b/src/renderer/components/dock/terminal.ts @@ -1,5 +1,5 @@ import debounce from "lodash/debounce"; -import { autorun } from "mobx"; +import { reaction, toJS } from "mobx"; import { Terminal as XTerm } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import { dockStore, TabId } from "./dock.store"; @@ -20,7 +20,7 @@ export class Terminal { Terminal.spawningPool = pool; } - static async preloadFonts(){ + static async preloadFonts() { const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires const fontFace = new FontFace("RobotoMono", `url(${fontPath})`); await fontFace.load(); @@ -33,7 +33,7 @@ export class Terminal { public disposers: Function[] = []; @autobind() - protected setTheme(colors = themeStore.activeTheme.colors) { + protected setTheme(colors: Record) { // Replacing keys stored in styles to format accepted by terminal // E.g. terminalBrightBlack -> brightBlack const colorPrefix = "terminal" @@ -94,19 +94,18 @@ export class Terminal { 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)); + const onDataHandler = this.xterm.onData(this.onData); 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(), + reaction(() => toJS(themeStore.activeTheme.colors), this.setTheme, { + fireImmediately: true + }), + dockStore.onResize(this.onResize), + () => onDataHandler.dispose(), () => this.fitAddon.dispose(), () => this.api.removeAllListeners(), () => window.removeEventListener("resize", this.onResize), diff --git a/src/renderer/components/drawer/drawer.tsx b/src/renderer/components/drawer/drawer.tsx index 21a380296f..ef14d5c75e 100644 --- a/src/renderer/components/drawer/drawer.tsx +++ b/src/renderer/components/drawer/drawer.tsx @@ -5,7 +5,7 @@ import { createPortal } from "react-dom"; import { cssNames, noop } from "../../utils"; import { Icon } from "../icon"; import { Animate, AnimateName } from "../animate"; -import { browserHistory } from "../../navigation"; +import { history } from "../../navigation"; import { themeStore } from "../../theme.store"; export interface DrawerProps { @@ -36,7 +36,7 @@ export class Drawer extends React.Component { private scrollElem: HTMLElement private scrollPos = new Map(); - private stopListenLocation = browserHistory.listen(() => { + private stopListenLocation = history.listen(() => { this.restoreScrollPos(); }); @@ -55,13 +55,13 @@ export class Drawer extends React.Component { saveScrollPos = () => { if (!this.scrollElem) return; - const key = browserHistory.location.key; + const key = history.location.key; this.scrollPos.set(key, this.scrollElem.scrollTop); } restoreScrollPos = () => { if (!this.scrollElem) return; - const key = browserHistory.location.key; + const key = history.location.key; this.scrollElem.scrollTop = this.scrollPos.get(key) || 0; } diff --git a/src/renderer/components/error-boundary/error-boundary.tsx b/src/renderer/components/error-boundary/error-boundary.tsx index 5e43f53f26..7ce72c99c2 100644 --- a/src/renderer/components/error-boundary/error-boundary.tsx +++ b/src/renderer/components/error-boundary/error-boundary.tsx @@ -7,7 +7,7 @@ import { t, Trans } from "@lingui/macro"; import { Button } from "../button"; import { navigation } from "../../navigation"; import { _i18n } from "../../i18n"; -import { issuesTrackerUrl, slackUrl, buildVersion } from "../../../common/vars"; +import { issuesTrackerUrl, slackUrl } from "../../../common/vars"; interface Props { } @@ -45,7 +45,6 @@ export class ErrorBoundary extends React.Component {
App crash at {pageUrl} - {buildVersion &&

Build version: {buildVersion}

}

@@ -53,7 +52,7 @@ export class ErrorBoundary extends React.Component {

- +

Component stack:

{errorInfo.componentStack}
diff --git a/src/renderer/components/file-picker/file-picker.scss b/src/renderer/components/file-picker/file-picker.scss new file mode 100644 index 0000000000..63a9c2da74 --- /dev/null +++ b/src/renderer/components/file-picker/file-picker.scss @@ -0,0 +1,11 @@ +.FilePicker { + input[type="file"] { + display: none; + } + + label { + display: inline-flex; + cursor: pointer; + color: var(--blue); + } +} \ No newline at end of file diff --git a/src/renderer/components/file-picker/file-picker.tsx b/src/renderer/components/file-picker/file-picker.tsx new file mode 100644 index 0000000000..5af7a176d1 --- /dev/null +++ b/src/renderer/components/file-picker/file-picker.tsx @@ -0,0 +1,203 @@ +import "./file-picker.scss" + +import React from "react"; +import fse from "fs-extra"; +import path from "path"; +import { Icon } from "../icon"; +import { Spinner } from "../spinner"; +import { observable } from "mobx"; +import { observer } from "mobx-react"; +import _ from "lodash"; + +export interface FileUploadProps { + uploadDir: string; + rename?: boolean; + handler?(path: string[]): void; +} + +export interface MemoryUseProps { + handler?(file: File[]): void; +} + +enum FileInputStatus { + CLEAR = "clear", + PROCESSING = "processing", + ERROR = "error", +} + +export enum OverLimitStyle { + REJECT = "reject", + CAP = "cap", +} + +export enum OverSizeLimitStyle { + REJECT = "reject", + FILTER = "filter", +} + +export enum OverTotalSizeLimitStyle { + REJECT = "reject", + FILTER_LAST = "filter-last", + FILTER_LARGEST = "filter-largest", +} + +export interface BaseProps { + accept?: string; + label: React.ReactNode; + multiple?: boolean; + + // limit is the optional maximum number of files to upload + // the larger number is upper limit, the lower is lower limit + // the lower limit is capped at 0 and the upper limit is capped at Infinity + limit?: [number, number]; + + // default is "Reject" + onOverLimit?: OverLimitStyle; + + // individual files are checked before the total size. + maxSize?: number; + // default is "Reject" + onOverSizeLimit?: OverSizeLimitStyle; + + maxTotalSize?: number; + // default is "Reject" + onOverTotalSizeLimit?: OverTotalSizeLimitStyle; +} + +export type Props = BaseProps & (MemoryUseProps | FileUploadProps); + +const defaultProps: Partial = { + maxSize: Infinity, + onOverSizeLimit: OverSizeLimitStyle.REJECT, + maxTotalSize: Infinity, + onOverLimit: OverLimitStyle.REJECT, + onOverTotalSizeLimit: OverTotalSizeLimitStyle.REJECT, +}; + +@observer +export class FilePicker extends React.Component { + static defaultProps = defaultProps as Object; + + @observable status = FileInputStatus.CLEAR; + @observable errorText?: string; + + handleFileCount(files: File[]): File[] { + const { limit: [minLimit, maxLimit] = [0, Infinity], onOverLimit } = this.props; + if (files.length > maxLimit) { + switch (onOverLimit) { + case OverLimitStyle.CAP: + files.length = maxLimit; + break; + case OverLimitStyle.REJECT: + throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`; + } + } + if (files.length < minLimit) { + throw `Too many files. Expected at most ${maxLimit}. Got ${files.length}.`; + } + + return files; + } + + handleIndiviualFileSizes(files: File[]): File[] { + const { onOverSizeLimit, maxSize } = this.props; + + switch (onOverSizeLimit) { + case OverSizeLimitStyle.FILTER: + return files.filter(file => file.size <= maxSize ); + case OverSizeLimitStyle.REJECT: + const firstFileToLarge = files.find(file => file.size > maxSize); + if (firstFileToLarge) { + throw `${firstFileToLarge.name} is too large. Maximum size is ${maxSize}. Has size of ${firstFileToLarge.size}`; + } + + return files; + } + } + + handleTotalFileSizes(files: File[]): File[] { + const { maxTotalSize, onOverTotalSizeLimit } = this.props; + + const totalSize = _.sum(files.map(f => f.size)); + if (totalSize <= maxTotalSize) { + return files; + } + + switch (onOverTotalSizeLimit) { + case OverTotalSizeLimitStyle.FILTER_LARGEST: + files = _.orderBy(files, ["size"]) + case OverTotalSizeLimitStyle.FILTER_LAST: + let newTotalSize = totalSize; + + for (;files.length > 0;) { + newTotalSize -= files.pop().size; + if (newTotalSize <= maxTotalSize) { + break; + } + } + return files; + case OverTotalSizeLimitStyle.REJECT: + throw `Total file size to upload is too large. Expected at most ${maxTotalSize}. Found ${totalSize}.`; + } + } + + async handlePickFiles(selectedFiles: FileList) { + const files: File[] = Array.from(selectedFiles); + + try { + const numberLimitedFiles = this.handleFileCount(files); + const sizeLimitedFiles = this.handleIndiviualFileSizes(numberLimitedFiles); + const totalSizeLimitedFiles = this.handleTotalFileSizes(sizeLimitedFiles); + + if ("uploadDir" in this.props) { + const { uploadDir } = this.props; + this.status = FileInputStatus.PROCESSING; + + const paths: string[] = []; + const promises = totalSizeLimitedFiles.map(async file => { + const destinationPath = path.join(uploadDir, file.name); + paths.push(destinationPath); + + return fse.copyFile(file.path, destinationPath); + }); + + await Promise.all(promises); + this.props.handler(paths); + this.status = FileInputStatus.CLEAR; + } else { + this.props.handler(totalSizeLimitedFiles); + } + } catch (errorText) { + this.status = FileInputStatus.ERROR; + this.errorText = errorText; + return; + } + } + + render() { + const { accept, label, multiple } = this.props; + + return
+ + this.handlePickFiles(event.target.files)} + /> +
; + } + + getIconRight(): React.ReactNode { + switch (this.status) { + case FileInputStatus.CLEAR: + return + case FileInputStatus.PROCESSING: + return ; + case FileInputStatus.ERROR: + return + } + } +} \ No newline at end of file diff --git a/src/renderer/components/file-picker/index.ts b/src/renderer/components/file-picker/index.ts new file mode 100644 index 0000000000..9e4bd291c2 --- /dev/null +++ b/src/renderer/components/file-picker/index.ts @@ -0,0 +1 @@ +export * from "./file-picker" \ No newline at end of file diff --git a/src/renderer/_vue/assets/img/crane.svg b/src/renderer/components/icon/crane.svg similarity index 100% rename from src/renderer/_vue/assets/img/crane.svg rename to src/renderer/components/icon/crane.svg diff --git a/src/renderer/components/icon/icon.scss b/src/renderer/components/icon/icon.scss index 60ce4a959e..529a8d8551 100644 --- a/src/renderer/components/icon/icon.scss +++ b/src/renderer/components/icon/icon.scss @@ -33,18 +33,13 @@ height: var(--big-size); } - > span { - width: 100%; - height: 100%; - } - // material-icon &.material { - > span { + > .icon { font-family: "Material Icons"; font-size: inherit; - font-weight: normal; - font-style: normal; + font-weight: inherit; + font-style: inherit; display: inline-block; line-height: 1; text-transform: none; @@ -68,6 +63,11 @@ &.svg { box-sizing: content-box; + > .icon { + width: 100%; + height: 100%; + } + svg { pointer-events: none; width: 100%; diff --git a/src/renderer/components/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index cb3cd92ce8..81635faae1 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -87,12 +87,12 @@ export class Icon extends React.PureComponent { // render as inline svg-icon if (svg) { const svgIconText = require("!!raw-loader!./" + svg + ".svg").default; - iconContent = ; + iconContent = ; } // render as material-icon if (material) { - iconContent = {material}; + iconContent = {material}; } // wrap icon's content passed from decorator diff --git a/src/renderer/_vue/assets/img/lens-logo.svg b/src/renderer/components/icon/lens-logo.svg similarity index 100% rename from src/renderer/_vue/assets/img/lens-logo.svg rename to src/renderer/components/icon/lens-logo.svg diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index 4aaa174ec5..31f3c9c46c 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -74,7 +74,7 @@ .input-info { .errors { - color: var(color-error); + color: var(--colorError); font-size: $font-size-small; } diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index 86e4bc09a7..a8fd532dde 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -13,7 +13,7 @@ type Omit = Pick> type InputElement = HTMLInputElement | HTMLTextAreaElement; type InputElementProps = InputHTMLAttributes & TextareaHTMLAttributes & DOMAttributes; -export type InputProps = Omit & { +export type InputProps = Omit & { theme?: "round-black"; className?: string; value?: T; @@ -25,6 +25,7 @@ export type InputProps = Omit & { iconRight?: string | React.ReactNode; validators?: Validator | Validator[]; onChange?(value: T, evt: React.ChangeEvent): void; + onSubmit?(value: T): void; } interface State { @@ -100,7 +101,7 @@ export class Input extends React.Component { async validate(value = this.getValue()) { let validationId = (this.validationId = ""); // reset every time for async validators const asyncValidators: Promise[] = []; - let errors: React.ReactNode[] = []; + const errors: React.ReactNode[] = []; // run validators for (const validator of this.validators) { @@ -130,12 +131,10 @@ export class Input extends React.Component { // handle async validators result if (asyncValidators.length > 0) { - this.setState({ validating: true, valid: false, }); + this.setState({ validating: true, valid: false }); const asyncErrors = await Promise.all(asyncValidators); - const isLastValidationCheck = this.validationId === validationId; - if (isLastValidationCheck) { - errors = this.state.errors.concat(asyncErrors.filter(err => err)); - this.setValidation(errors); + if (this.validationId === validationId) { + this.setValidation(errors.concat(...asyncErrors.filter(err => err))); } } @@ -157,7 +156,7 @@ export class Input extends React.Component { private setupValidators() { this.validators = conditionalValidators - // add conditional validators if matches input props + // add conditional validators if matches input props .filter(validator => validator.condition(this.props)) // add custom validators .concat(this.props.validators) @@ -209,6 +208,19 @@ export class Input extends React.Component { } } + @autobind() + onKeyDown(evt: React.KeyboardEvent) { + const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey; + + switch (evt.key) { + case "Enter": + if (this.props.onSubmit && !modified && !evt.repeat) { + this.props.onSubmit(this.getValue()); + } + break; + } + } + get showMaxLenIndicator() { const { maxLength, multiLine } = this.props; return maxLength && multiLine; @@ -269,8 +281,11 @@ export class Input extends React.Component { onFocus: this.onFocus, onBlur: this.onBlur, onChange: this.onChange, + onKeyDown: this.onKeyDown, rows: multiLine ? (rows || 1) : null, ref: this.bindRef, + type: "text", + spellCheck: "false", }); return ( diff --git a/src/renderer/components/input/input.validators.ts b/src/renderer/components/input/input.validators.ts index 7db0934d4f..2236265e5d 100644 --- a/src/renderer/components/input/input.validators.ts +++ b/src/renderer/components/input/input.validators.ts @@ -38,7 +38,7 @@ export const isNumber: Validator = { export const isUrl: Validator = { condition: ({ type }) => type === "url", message: () => _i18n._(t`Wrong url format`), - validate: value => !!value.match(/^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/), + validate: value => !!value.match(/^http(s)?:\/\/\w+(\.\w+)*(:[0-9]+)?\/?(\/[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]*)*$/), }; export const minLength: Validator = { diff --git a/src/renderer/components/input/search-input.tsx b/src/renderer/components/input/search-input.tsx index 6d7eb11746..9bf2b7387d 100644 --- a/src/renderer/components/input/search-input.tsx +++ b/src/renderer/components/input/search-input.tsx @@ -26,7 +26,7 @@ const defaultProps: Partial = { export class SearchInput extends React.Component { static defaultProps = defaultProps as object; - @observable inputVal: string; + @observable inputVal = ""; // fix: use empty string to avoid react warnings @disposeOnUnmount updateInput = autorun(() => this.inputVal = getSearch()) diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 80c1f76d70..902d294771 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -119,7 +119,7 @@ export class ItemListLayout extends React.Component { const subscriptions = stores.map(store => store.subscribe()); await when(() => this.isUnmounting); subscriptions.forEach(dispose => dispose()); // unsubscribe all - } catch(error) { + } catch (error) { console.log("catched", error) } } @@ -356,8 +356,7 @@ export class ItemListLayout extends React.Component { const modifiedHeader = customizeHeader(placeholders, header); if (isReactNode(modifiedHeader)) { header = modifiedHeader; - } - else { + } else { header = this.renderHeaderContent({ ...placeholders, ...modifiedHeader as IHeaderPlaceholders, diff --git a/src/renderer/components/items-list/index.ts b/src/renderer/components/items-list/index.ts deleted file mode 100644 index 0db21a797b..0000000000 --- a/src/renderer/components/items-list/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./items-list" \ No newline at end of file diff --git a/src/renderer/components/items-list/items-list.scss b/src/renderer/components/items-list/items-list.scss deleted file mode 100644 index fbb3495443..0000000000 --- a/src/renderer/components/items-list/items-list.scss +++ /dev/null @@ -1,45 +0,0 @@ -.ItemsList { - &.selectable { - .Item:not(.disabled) { - cursor: pointer; - - &:hover { - background-color: #f7f7f7; - border-radius: $radius; - } - } - } - - &.inline { - .Item { - margin-right: $margin; - } - } -} - -.Item { - padding: $padding / 2; - margin: 0 $margin / -2; - user-select: none; - - .Icon { - &.tick-icon { - color: $colorOk; - } - - &.action-icon { - border-radius: $radius; - background: white; - color: #36393e; - } - } - - &.selected { - } - - &.disabled { - opacity: .5; - pointer-events: none; - } -} - diff --git a/src/renderer/components/items-list/items-list.tsx b/src/renderer/components/items-list/items-list.tsx deleted file mode 100644 index 0b15ff1684..0000000000 --- a/src/renderer/components/items-list/items-list.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import './items-list.scss' -import React from 'react' -import { observer } from "mobx-react"; -import { cssNames } from "../../utils"; -import { Icon } from "../icon"; - -interface ItemsListProps { - className?: string; - disabled?: boolean; - inline?: boolean; - selectable?: boolean; - multiSelect?: boolean; - showSelectedItems?: boolean; - showSelectedIcon?: boolean; - selectedValues?: any[]; - onSelectChange(currentItem: any, selectedItems: any[]): void; -} - -@observer -export class ItemsList extends React.Component { - static defaultProps: Partial = { - selectable: true, - multiSelect: true, - showSelectedIcon: false, - } - - onClickItem(itemValue: any) { - const { selectedValues, multiSelect, onSelectChange } = this.props; - if (multiSelect) { - const itemIndex = selectedValues.findIndex(value => value === itemValue); - if (itemIndex > -1) { - // remove - const newSelectedValues = [...selectedValues]; - newSelectedValues.splice(itemIndex, 1); - onSelectChange(itemValue, newSelectedValues); - } - else { - // add - const newSelectedValues = [].concat(selectedValues, itemValue); - onSelectChange(itemValue, newSelectedValues) - } - } - else { - onSelectChange(itemValue, [itemValue]); - } - } - - render() { - const { disabled, inline, selectable, selectedValues, showSelectedItems, showSelectedIcon } = this.props; - let { className, children } = this.props; - className = cssNames('ItemsList flex', className, { - selectable: selectable, - "inline wrap": inline, - column: !inline, - }); - if (selectable) { - children = React.Children.toArray(children).map((item: React.ReactElement) => { - const itemValue = item.props.value; - const isSelected = selectedValues.includes(itemValue); - const isDisabled = disabled !== undefined ? disabled : item.props.disabled; - - if (showSelectedItems === false && isSelected) { - return null; - } - - const onClick = (evt: React.MouseEvent) => { - if (item.props.onClick) item.props.onClick(evt); - this.onClickItem(itemValue); - }; - - return React.cloneElement(item, { - showSelectedIcon: showSelectedIcon, - selected: isSelected, - disabled: isDisabled, - onClick: onClick, - }) - }); - } - return ( -
    - {children} -
- ); - } -} - -interface ItemProps extends React.HTMLProps { - value: any; - className?: string; - disabled?: boolean; - selected?: boolean; - showSelectedIcon?: boolean; -} - -const defaultProps: Partial = { - showSelectedIcon: true, -} - -export class Item extends React.Component { - static defaultProps = defaultProps as object; - - render() { - const { disabled, selected, value, showSelectedIcon, children, ...itemProps } = this.props; - let { className } = this.props; - className = cssNames('Item flex gaps', className, { disabled, selected }); - const actionIcon = selected ? "remove" : "add"; - return ( -
  • -
    - {children} -
    - {showSelectedIcon && selected && ( - - )} - {!showSelectedIcon && ( - - )} -
  • - ); - } -} diff --git a/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx b/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx index b5ce1ed6d2..68f0a93129 100644 --- a/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx +++ b/src/renderer/components/kubeconfig-dialog/kubeconfig-dialog.tsx @@ -6,14 +6,15 @@ import { observer } from "mobx-react"; import jsYaml from "js-yaml"; import { Trans } from "@lingui/macro"; import { AceEditor } from "../ace-editor"; -import { kubeConfigApi, ServiceAccount } from "../../api/endpoints"; -import { copyToClipboard, downloadFile, cssNames } from "../../utils"; +import { ServiceAccount } from "../../api/endpoints"; +import { copyToClipboard, cssNames, downloadFile } from "../../utils"; import { Button } from "../button"; import { Dialog, DialogProps } from "../dialog"; import { Icon } from "../icon"; import { Notifications } from "../notifications"; import { Wizard, WizardStep } from "../wizard"; import { themeStore } from "../../theme.store"; +import { apiBase } from "../../api"; interface IKubeconfigDialogData { title?: React.ReactNode; @@ -111,19 +112,11 @@ export class KubeConfigDialog extends React.Component { } } -export function openUserKubeConfig() { - KubeConfigDialog.open({ - loader: () => kubeConfigApi.getUserConfig() - }) -} - export function openServiceAccountKubeConfig(account: ServiceAccount) { const accountName = account.getName() const namespace = account.getNs() KubeConfigDialog.open({ title: {accountName} kubeconfig, - loader: () => { - return kubeConfigApi.getServiceAccountConfig(accountName, namespace) - } + loader: () => apiBase.get(`/kubeconfig/service-account/${namespace}/${account}`) }) } \ No newline at end of file diff --git a/src/renderer/components/layout/main-layout.scss b/src/renderer/components/layout/main-layout.scss index a9036ad0d5..9174852643 100755 --- a/src/renderer/components/layout/main-layout.scss +++ b/src/renderer/components/layout/main-layout.scss @@ -1,13 +1,12 @@ .MainLayout { - --main-layout-header: 40px; --sidebar-max-size: 200px; display: grid; grid-template-areas: "aside header" "aside tabs" "aside main" "aside footer"; grid-template-rows: [header] var(--main-layout-header) [tabs] min-content [main] 1fr [footer] auto; grid-template-columns: [sidebar] minmax(var(--main-layout-header), min-content) [main] 1fr; - height: 100vh; + height: 100%; &.light { main { @@ -46,7 +45,6 @@ background: $sidebarBackground; white-space: nowrap; transition: width 150ms cubic-bezier(0.4, 0, 0.2, 1); - z-index: 100; &.pinned { width: var(--sidebar-max-size); diff --git a/src/renderer/components/layout/main-layout.tsx b/src/renderer/components/layout/main-layout.tsx index fc91d16dbb..678f4d8876 100755 --- a/src/renderer/components/layout/main-layout.tsx +++ b/src/renderer/components/layout/main-layout.tsx @@ -7,10 +7,10 @@ import { matchPath, RouteProps } from "react-router-dom"; import { createStorage, cssNames } from "../../utils"; import { Tab, Tabs } from "../tabs"; import { Sidebar } from "./sidebar"; -import { configStore } from "../../config.store"; import { ErrorBoundary } from "../error-boundary"; import { Dock } from "../dock"; import { navigate, navigation } from "../../navigation"; +import { getHostedCluster } from "../../../common/cluster-store"; import { themeStore } from "../../theme.store"; export interface TabRoute extends RouteProps { @@ -47,14 +47,14 @@ export class MainLayout extends React.Component { render() { const { className, contentClass, headerClass, tabs, footer, footerClass, children } = this.props; - const { clusterName } = configStore.config; - const { pathname } = navigation.location; + const routePath = navigation.location.pathname; + const cluster = getHostedCluster(); return (
    -
    - {clusterName && {clusterName}} -
    + + {cluster.preferences?.clusterName || cluster.contextName} +

    + Each cluster context is added as a separate item in the + left-side cluster menu + to allow you to operate easily on multiple clusters and/or contexts. +