From 7f65f1ea0682cebb23a6cbdecb947ee90ffc2376 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Fri, 31 Jul 2020 16:50:57 +0300 Subject: [PATCH 01/14] Fix CRD api parsing (#622) Signed-off-by: Lauri Nevala --- src/renderer/api/kube-api-parse.ts | 3 +++ src/renderer/api/kube-api-parse_test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/renderer/api/kube-api-parse.ts b/src/renderer/api/kube-api-parse.ts index 0745dc71eb..97a7875322 100644 --- a/src/renderer/api/kube-api-parse.ts +++ b/src/renderer/api/kube-api-parse.ts @@ -40,6 +40,9 @@ export function parseApi(path: string): IKubeApiLinkBase { apiGroup = left.join("/"); } else { switch (left.length) { + case 4: + [apiGroup, apiVersion, resource, name] = left + break; case 2: resource = left.pop(); // fallthrough diff --git a/src/renderer/api/kube-api-parse_test.ts b/src/renderer/api/kube-api-parse_test.ts index 03f53ae34d..1fda8c3e53 100644 --- a/src/renderer/api/kube-api-parse_test.ts +++ b/src/renderer/api/kube-api-parse_test.ts @@ -6,6 +6,19 @@ interface KubeApi_Parse_Test { } const tests: KubeApi_Parse_Test[] = [ + { + url: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com", + expected: { + apiBase: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions", + apiPrefix: "/apis", + apiGroup: "apiextensions.k8s.io", + apiVersion: "v1beta1", + apiVersionWithGroup: "apiextensions.k8s.io/v1beta1", + namespace: undefined, + resource: "customresourcedefinitions", + name: "prometheuses.monitoring.coreos.com" + }, + }, { url: "/api/v1/namespaces/kube-system/pods/coredns-6955765f44-v8p27", expected: { From 0c3be9bbaea6d18eb8296b1e52949081180abed3 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 4 Aug 2020 13:14:02 -0400 Subject: [PATCH 02/14] Fix Resource Quota Rendering (#624) * add parsing of quota values which are non-numeric in the general case * add unit tests Signed-off-by: Sebastian Malton Co-authored-by: Sebastian Malton --- .../resource-quota-details.tsx | 48 +++++++++++-------- src/renderer/utils/convertCpu.ts | 13 +++-- src/renderer/utils/convertMemory.ts | 10 ++-- src/renderer/utils/index.ts | 1 + src/renderer/utils/metricUnitsToNumber.ts | 10 ++++ .../utils/metricUnitsToNumber_test.ts | 15 ++++++ 6 files changed, 68 insertions(+), 29 deletions(-) create mode 100644 src/renderer/utils/metricUnitsToNumber.ts create mode 100644 src/renderer/utils/metricUnitsToNumber_test.ts diff --git a/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx b/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx index 6a2665e741..3da8c7112a 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx +++ b/src/renderer/components/+config-resource-quotas/resource-quota-details.tsx @@ -4,7 +4,7 @@ import kebabCase from "lodash/kebabCase"; import { observer } from "mobx-react"; import { Trans } from "@lingui/macro"; import { DrawerItem, DrawerTitle } from "../drawer"; -import { cpuUnitsToNumber, cssNames, unitsToBytes } from "../../utils"; +import { cpuUnitsToNumber, cssNames, unitsToBytes, metricUnitsToNumber } from "../../utils"; import { KubeObjectDetailsProps } from "../kube-object"; import { ResourceQuota, resourceQuotaApi } from "../../api/endpoints/resource-quota.api"; import { LineProgress } from "../line-progress"; @@ -15,24 +15,30 @@ import { KubeObjectMeta } from "../kube-object/kube-object-meta"; interface Props extends KubeObjectDetailsProps { } -@observer -export class ResourceQuotaDetails extends React.Component { - renderQuotas = (quota: ResourceQuota) => { - const { hard, used } = quota.status - if (!hard || !used) return null - const transformUnit = (name: string, value: string) => { - if (name.includes("memory") || name.includes("storage")) { - return unitsToBytes(value) - } - if (name.includes("cpu")) { - return cpuUnitsToNumber(value) - } - return parseInt(value) - } - return Object.entries(hard).map(([name, value]) => { - if (!used[name]) return null +const onlyNumbers = /$[0-9]*^/g; + +function transformUnit(name: string, value: string): number { + if (name.includes("memory") || name.includes("storage")) { + return unitsToBytes(value) + } + + if (name.includes("cpu")) { + return cpuUnitsToNumber(value) + } + + return metricUnitsToNumber(value); +} + +function renderQuotas(quota: ResourceQuota): JSX.Element[] { + const { hard = {}, used = {} } = quota.status + + return Object.entries(hard) + .filter(([name]) => used[name]) + .map(([name, value]) => { const current = transformUnit(name, used[name]) const max = transformUnit(name, value) + const usage = max === 0 ? 100 : Math.ceil(current / max * 100); // special case 0 max as always 100% usage + return (
{name} @@ -41,14 +47,16 @@ export class ResourceQuotaDetails extends React.Component { max={max} value={current} tooltip={ -

Set: {value}. Used: {Math.ceil(current / max * 100) + "%"}

+

Set: {value}. Usage: {usage + "%"}

} />
) }) - } +} +@observer +export class ResourceQuotaDetails extends React.Component { render() { const { object: quota } = this.props; if (!quota) return null; @@ -57,7 +65,7 @@ export class ResourceQuotaDetails extends React.Component { Quotas} className="quota-list"> - {this.renderQuotas(quota)} + {renderQuotas(quota)} {quota.getScopeSelector().length > 0 && ( diff --git a/src/renderer/utils/convertCpu.ts b/src/renderer/utils/convertCpu.ts index 2e7c6b85c3..7b81a30cc3 100644 --- a/src/renderer/utils/convertCpu.ts +++ b/src/renderer/utils/convertCpu.ts @@ -1,10 +1,13 @@ // Helper to convert CPU K8S units to numbers +const thousand = 1000; +const million = thousand * thousand; +const shortBillion = thousand * million; + export function cpuUnitsToNumber(cpu: string) { const cpuNum = parseInt(cpu) - const billion = 1000000 * 1000 - if (cpu.includes("m")) return cpuNum / 1000 - if (cpu.includes("u")) return cpuNum / 1000000 - if (cpu.includes("n")) return cpuNum / billion + if (cpu.includes("m")) return cpuNum / thousand + if (cpu.includes("u")) return cpuNum / million + if (cpu.includes("n")) return cpuNum / shortBillion return parseFloat(cpu) -} \ No newline at end of file +} diff --git a/src/renderer/utils/convertMemory.ts b/src/renderer/utils/convertMemory.ts index faa89dd990..d0d7e1fc52 100644 --- a/src/renderer/utils/convertMemory.ts +++ b/src/renderer/utils/convertMemory.ts @@ -7,9 +7,9 @@ export function unitsToBytes(value: string) { if (!suffixes.some(suffix => value.includes(suffix))) { return parseFloat(value) } - const index = suffixes.findIndex(suffix => - suffix == value.replace(/[0-9]|i|\./g, '') - ) + + const suffix = value.replace(/[0-9]|i|\./g, ''); + const index = suffixes.indexOf(suffix); return parseInt( (parseFloat(value) * Math.pow(base, index + 1)).toFixed(1) ) @@ -21,8 +21,10 @@ export function bytesToUnits(bytes: number, precision = 1) { if (!bytes) { return "N/A" } + if (index === 0) { return `${bytes}${sizes[index]}` } + return `${(bytes / (1024 ** index)).toFixed(precision)}${sizes[index]}i` -} \ No newline at end of file +} diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 8a3a263077..578ec5c355 100755 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -20,3 +20,4 @@ export * from './formatDuration' export * from './isReactNode' export * from './convertMemory' export * from './convertCpu' +export * from './metricUnitsToNumber' diff --git a/src/renderer/utils/metricUnitsToNumber.ts b/src/renderer/utils/metricUnitsToNumber.ts new file mode 100644 index 0000000000..9390c35b24 --- /dev/null +++ b/src/renderer/utils/metricUnitsToNumber.ts @@ -0,0 +1,10 @@ +const base = 1000; +const suffixes = ["k", "m", "g", "t", "q"]; + +export function metricUnitsToNumber(value: string): number { + const suffix = value.toLowerCase().slice(-1); + const index = suffixes.indexOf(suffix); + return parseInt( + (parseFloat(value) * Math.pow(base, index + 1)).toFixed(1) + ) +} diff --git a/src/renderer/utils/metricUnitsToNumber_test.ts b/src/renderer/utils/metricUnitsToNumber_test.ts new file mode 100644 index 0000000000..cbb0669122 --- /dev/null +++ b/src/renderer/utils/metricUnitsToNumber_test.ts @@ -0,0 +1,15 @@ +import { metricUnitsToNumber } from "./metricUnitsToNumber"; + +describe("metricUnitsToNumber tests", () => { + test("plain number", () => { + expect(metricUnitsToNumber("124")).toStrictEqual(124); + }); + + test("with k suffix", () => { + expect(metricUnitsToNumber("124k")).toStrictEqual(124000); + }); + + test("with m suffix", () => { + expect(metricUnitsToNumber("124m")).toStrictEqual(124000000); + }); +}); \ No newline at end of file From 0f4248de689daddaf0b71cd00d88776b0c6fc74a Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Fri, 7 Aug 2020 15:57:16 +0300 Subject: [PATCH 03/14] Fixing Cluster Settings layout (#651) * A bit of cleaning in Add Cluster page Signed-off-by: alexfront * Adding head-col to WizardLayout Signed-off-by: alexfront * Fixing Cluster Settings general layout bugs Signed-off-by: alexfront * Cluster Status view refactoring Signed-off-by: alexfront * Install Metrics component refactoring Using notifications for error, removed picking button icon method, simplified button generation. Signed-off-by: alexfront * Remove icons / checks from RemoveClusterButton Signed-off-by: alexfront * Fixing colorError in Input styles Signed-off-by: alexfront * Preventing Input's spellchecking Signed-off-by: alexfront * ClusterNameSettings refactoring Signed-off-by: alexfront * ClusterWorkspaceSettings refactoring/fixing Signed-off-by: alexfront * ClusterProxySetting refactoring Signed-off-by: alexfront * ClusterPrometheusSetting refactoring Signed-off-by: alexfront * Clean up Removal section Signed-off-by: alexfront * Glued InstallMetrics & InstallUserMode into 1 component Signed-off-by: alexfront * Removing unused styles in Cluster Settings Signed-off-by: alexfront * Cluster Settings styling Signed-off-by: alexfront * Adding close button to settings header Signed-off-by: alexfront * ClusterHomeDirSetting refactoring Signed-off-by: alexfront * FilePicker restyling Signed-off-by: alexfront * Fixing Prometheus selector Signed-off-by: alexfront * Fixing Hashicon Passing cluster name instead of cluster id to prevent icon changing while typing new cluster name Signed-off-by: alexfront * Minor ClusterSettings fixes Signed-off-by: alexfront * Increasing opacity for non-interactive icons Signed-off-by: alexfront * Keep feature install loading state Waiting for props to change before disabling loading state (gray button width spinner) Signed-off-by: alexfront * Remove arrays in disposeOnUnmount() Signed-off-by: alexfront * Fix Cluster select behavior Now clicking cluster icon in sidebar always leads to / dashboard. And 'Settings' submenu switches active cluster at first and only the showing Cluster Settings Signed-off-by: alexfront * Using structuralComparator in feature installer Signed-off-by: alexfront * Saving input fields on blur Signed-off-by: alexfront * Setting Select color same as Input color Signed-off-by: alexfront --- src/common/cluster-store.ts | 4 + .../components/+add-cluster/add-cluster.tsx | 30 ++-- .../+cluster-settings/cluster-settings.scss | 137 +++++++++--------- .../+cluster-settings/cluster-settings.tsx | 44 ++++-- .../components/cluster-home-dir-setting.tsx | 101 ++++--------- .../components/cluster-icon-setting.tsx | 64 ++++---- .../components/cluster-name-setting.tsx | 93 +++--------- .../components/cluster-prometheus-setting.tsx | 55 +++---- .../components/cluster-proxy-setting.tsx | 118 ++++----------- .../components/cluster-workspace-setting.tsx | 50 +++---- .../components/install-feature.tsx | 93 ++++++++++++ .../components/install-metrics.tsx | 109 -------------- .../components/install-user-mode.tsx | 108 -------------- .../components/remove-cluster-button.tsx | 58 ++------ .../+cluster-settings/components/statuses.ts | 24 --- .../components/+cluster-settings/features.tsx | 36 ++++- .../components/+cluster-settings/general.tsx | 2 - .../components/+cluster-settings/removal.tsx | 12 +- .../components/+cluster-settings/status.tsx | 37 +++-- .../components/cluster-icon/cluster-icon.scss | 7 +- .../components/cluster-icon/cluster-icon.tsx | 4 +- .../cluster-manager/clusters-menu.tsx | 12 +- .../components/file-picker/file-picker.scss | 19 +-- .../components/file-picker/file-picker.tsx | 6 +- src/renderer/components/input/input.scss | 2 +- src/renderer/components/input/input.tsx | 1 + .../components/layout/wizard-layout.scss | 9 ++ .../components/layout/wizard-layout.tsx | 9 +- src/renderer/components/select/select.scss | 6 +- 29 files changed, 497 insertions(+), 753 deletions(-) create mode 100644 src/renderer/components/+cluster-settings/components/install-feature.tsx delete mode 100644 src/renderer/components/+cluster-settings/components/install-metrics.tsx delete mode 100644 src/renderer/components/+cluster-settings/components/install-user-mode.tsx delete mode 100644 src/renderer/components/+cluster-settings/components/statuses.ts diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index e4febf532a..efed98d00a 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -86,6 +86,10 @@ export class ClusterStore extends BaseStore { return Array.from(this.clusters.values()); } + setActive(id: ClusterId) { + this.activeClusterId = id; + } + hasClusters() { return this.clusters.size > 0; } diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 221f695425..93d28d400a 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -124,7 +124,7 @@ export class AddCluster extends React.Component { to allow you to operate easily on multiple clusters and/or contexts.

- For more information on kubeconfig see Kubernetes docs + For more information on kubeconfig see Kubernetes docs.

NOTE: Any manually added cluster is not merged into your kubeconfig file. @@ -137,22 +137,20 @@ export class AddCluster extends React.Component { app.

-

OIDC (OpenID Connect)

+

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 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 diff --git a/src/renderer/components/+cluster-settings/cluster-settings.scss b/src/renderer/components/+cluster-settings/cluster-settings.scss index 1ea6926eab..e02a4da5f8 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.scss +++ b/src/renderer/components/+cluster-settings/cluster-settings.scss @@ -1,86 +1,85 @@ .ClusterSettings { - overflow-y: scroll; - grid-template-columns: unset; + grid-template-columns: unset; + padding: 0; - .info-col { - display: none; + .head-col { + justify-content: space-between; + + :nth-child(2) { + flex: 1 0 0; } - .content-col { - margin-right: unset; + a { + text-decoration: none; + color: $grey-600; + } + } + + .info-col { + display: none; + } + + .content-col { + margin: 0; + padding-top: $padding * 3; + background-color: transparent; + + .SubTitle { + text-transform: none; } - * { - margin-top: 40px; + .settings-wrapper { + margin: 0 auto; + width: 60%; + min-width: 570px; + max-width: 1000px; - &:first-child { - margin-top: 0px; - } - } + > div { + margin-top: $margin * 5; + } - h4 { - margin-top: 20px; - } - - .status-table { - margin-top: 20px; - display: grid; - grid-template-columns: 1fr 3fr; - grid-gap: 10px; - } - - .loading { - margin-top: 20px; - text-align: center; - - .Spinner { - display: inline-block; - } - } - - .Input,.Select { - margin-top: 10px; - } - - .Icon:not(.updated):not(.clean) { - color: #ad0000; - } - - .Icon.updated { - color: #00dd1d; - } - - .updated { - animation: updated-name 1s 1; - animation-fill-mode: forwards; - animation-delay: 3s; - } - - @keyframes updated-name { - from {opacity :1;} - to {opacity :0;} - } - - .center { - text-align: center; - } - - input[type="text"]::placeholder { + .admin-note { font-size: small; - color: #707070; + opacity: 0.5; + margin-left: $margin; + } + + .button-area { + margin-top: $margin * 2; + } } - input[type="text"] { - color: white; + .file-loader { + margin-top: $margin * 2; } - button { - margin-top: 5px; + .hint { + font-size: smaller; + opacity: 0.8; + } + } - .Spinner { - width: 10px; - height: 10px; - border-color: transparent black; + .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; + color: var(--textColorSecondary); + } + } } + } + + .Input,.Select { + margin-top: 10px; + } } \ 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 index 777562060b..4ce4dcb379 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/+cluster-settings/cluster-settings.tsx @@ -1,25 +1,43 @@ -import "./cluster-settings.scss" +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 { getHostedCluster } from "../../../common/cluster-store" +import { Features } from "./features"; +import { Removal } from "./removal"; +import { Status } from "./status"; +import { General } from "./general"; +import { getHostedCluster } from "../../../common/cluster-store"; import { WizardLayout } from "../layout/wizard-layout"; +import { ClusterIcon } from "../cluster-icon"; +import { Icon } from "../icon"; @observer export class ClusterSettings extends React.Component { render() { const cluster = getHostedCluster(); - + const header = ( + <> + +

{cluster.preferences.clusterName}

+ + + + + ); 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 index c8779c692a..f998035b44 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-home-dir-setting.tsx @@ -1,86 +1,43 @@ import React from "react"; -import { Cluster } from "../../../../main/cluster"; -import { Input } from "../../input"; -import { Spinner } from "../../spinner"; -import { clusterStore } from "../../../../common/cluster-store" -import { Icon } from "../../icon"; -import { Tooltip, TooltipPosition } from "../../tooltip"; -import { autobind } from "../../../utils"; -import { TextInputStatus } from "./statuses" 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; + cluster: Cluster; } @observer export class ClusterHomeDirSetting extends React.Component { @observable directory = this.props.cluster.preferences.terminalCWD || ""; - @observable status = TextInputStatus.CLEAN; - @observable errorText?: string; + + save = () => { + this.props.cluster.preferences.terminalCWD = this.directory; + }; + + onChange = (value: string) => { + this.directory = value; + } render() { - return <> -

Working Directory

-

Set initial working directory for terminals. When set it will the `pwd` when a new terminal instance is opened for this cluster.

- - ; - } - - @autobind() - onWorkingDirectoryChange(directory: string, _e: React.ChangeEvent) { - if (this.status === TextInputStatus.UPDATING) { - console.log("prevent changing cluster directory while updating"); - return; - } - - this.status = this.dirDiffers(directory); - this.directory = directory; - } - - dirDiffers(directory: string): TextInputStatus { - const { terminalCWD = "" } = this.props.cluster.preferences; - - return directory === terminalCWD ? TextInputStatus.CLEAN : TextInputStatus.DIRTY; - } - - getIconRight(): React.ReactNode { - switch (this.status) { - case TextInputStatus.CLEAN: - return null; - case TextInputStatus.DIRTY: - return ; - case TextInputStatus.UPDATED: - return ; - case TextInputStatus.UPDATING: - return ; - case TextInputStatus.ERROR: - return - - {this.errorText} - - - } - } - - @autobind() - onWorkingDirectorySubmit(directory: string) { - if (this.dirDiffers(directory) !== TextInputStatus.DIRTY) { - return; - } - - this.status = TextInputStatus.UPDATING - this.props.cluster.preferences.terminalCWD = directory; - this.directory = directory; - this.status = TextInputStatus.UPDATED + 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 index 2a5f0d0b97..20b4e6d6d5 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-icon-setting.tsx @@ -1,16 +1,20 @@ import React from "react"; import { Cluster } from "../../../../main/cluster"; -import { clusterStore } from "../../../../common/cluster-store" -import { Icon } from "../../icon"; import { FilePicker, OverSizeLimitStyle } from "../../file-picker"; import { autobind } from "../../../utils"; import { Button } from "../../button"; -import { GeneralInputStatus } from "./statuses" 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; + cluster: Cluster; } @observer @@ -21,7 +25,6 @@ export class ClusterIconSetting extends React.Component { @autobind() async onIconPick([file]: File[]) { const { cluster } = this.props; - try { if (file) { const buf = Buffer.from(await file.arrayBuffer()); @@ -38,35 +41,36 @@ export class ClusterIconSetting extends React.Component { } getClearButton() { - const { cluster } = this.props; - - if (cluster.preferences.icon) { - return + if (this.props.cluster.preferences.icon) { + return } } render() { - return <> -

Cluster Icon

-

Set cluster icon. By default it is automatically generated. {this.getIconRight()}

-
- + - {this.getClearButton()} -
- ; - } - - getIconRight(): React.ReactNode { - switch (this.status) { - case GeneralInputStatus.CLEAN: - return null; - case GeneralInputStatus.ERROR: - return - } + {"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 index e605864030..8e2f8a2afa 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx @@ -1,85 +1,40 @@ import React from "react"; import { Cluster } from "../../../../main/cluster"; import { Input } from "../../input"; -import { Spinner } from "../../spinner"; -import { clusterStore } from "../../../../common/cluster-store" -import { Icon } from "../../icon"; -import { Tooltip, TooltipPosition } from "../../tooltip"; -import { autobind } from "../../../utils"; -import { TextInputStatus } from "./statuses" 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; + cluster: Cluster; } @observer export class ClusterNameSetting extends React.Component { @observable name = this.props.cluster.preferences.clusterName || ""; - @observable status = TextInputStatus.CLEAN; - @observable errorText?: string; + + save = () => { + this.props.cluster.preferences.clusterName = this.name; + }; + + onChange = (value: string) => { + this.name = value; + } render() { - return <> -

Cluster Name

-

Change cluster name:

- - ; - } - - @autobind() - onClusterNameChange(name: string, _e: React.ChangeEvent) { - if (this.status === TextInputStatus.UPDATING) { - console.log("prevent changing cluster name while updating"); - return; - } - - this.status = this.nameDiffers(name) - this.name = name; - } - - nameDiffers(name: string): TextInputStatus { - const { clusterName } = this.props.cluster.preferences; - - return name === clusterName ? TextInputStatus.CLEAN : TextInputStatus.DIRTY; - } - - getIconRight(): React.ReactNode { - switch (this.status) { - case TextInputStatus.CLEAN: - return null; - case TextInputStatus.DIRTY: - return ; - case TextInputStatus.UPDATED: - return ; - case TextInputStatus.UPDATING: - return ; - case TextInputStatus.ERROR: - return - - {this.errorText} - - - } - } - - @autobind() - onClusterNameSubmit(name: string) { - if (this.nameDiffers(name) !== TextInputStatus.DIRTY) { - return; - } - - this.status = TextInputStatus.UPDATING - this.props.cluster.preferences.clusterName = name; - this.name = name; - this.status = TextInputStatus.UPDATED + 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 index 7596cc245b..e4f81bb18a 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx @@ -1,41 +1,44 @@ import React from "react"; -import { Cluster } from "../../../../main/cluster"; -import { clusterStore } from "../../../../common/cluster-store" -import { Select, SelectOption, SelectProps } from "../../select"; -import { prometheusProviders } from "../../../../common/prometheus-providers"; -import { autobind } from "../../../utils"; -import { observable } from "mobx"; +import merge from "lodash/merge"; 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"; -const prometheusGuide = "https://github.com/lensapp/lens/blob/master/troubleshooting/custom-prometheus.md"; const options: SelectOption[] = [ - { value: "", label: "Auto detect" }, + { value: "", label: "Auto detect" }, ...prometheusProviders.map(pp => ({value: pp.id, label: pp.name})) ]; interface Props { - cluster: Cluster; + cluster: Cluster; } @observer export class ClusterPrometheusSetting extends React.Component { - @observable prometheusProvider = this.props.cluster.preferences.prometheusProvider?.type || ""; - render() { - return <> -

Cluster Prometheus

-

Use pre-installed Prometheus service for metrics. Please refer to this guide for possible configuration changes.

- { + const provider = { + prometheusProvider: { + type: value + } + } + merge(this.props.cluster.preferences, provider); + }} + options={options} + /> + + ); } } \ 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 index 3ddcf25c7f..1b94992e5b 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx @@ -1,105 +1,41 @@ import React from "react"; -import { Cluster } from "../../../../main/cluster"; -import { Input } from "../../input"; -import { Spinner } from "../../spinner"; -import { clusterStore } from "../../../../common/cluster-store" -import { Icon } from "../../icon"; -import { Tooltip, TooltipPosition } from "../../tooltip"; -import { autobind } from "../../../utils"; -import { TextInputStatus } from "./statuses" 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; + cluster: Cluster; } @observer export class ClusterProxySetting extends React.Component { @observable proxy = this.props.cluster.preferences.httpsProxy || ""; - @observable status = TextInputStatus.CLEAN; - @observable errorText?: string; + + save = () => { + this.props.cluster.preferences.httpsProxy = this.proxy; + }; + + onChange = (value: string) => { + this.proxy = value; + } render() { - return <> -

HTTPS Proxy

-

HTTPS Proxy server. Used for communicating with Kubernetes API.

- - ; - } - - @autobind() - changeProxyState(proxy: string, _e: React.ChangeEvent) { - if (this.status === TextInputStatus.UPDATING) { - console.log("prevent changing cluster proxy while updating"); - return; - } - - this.status = this.proxyDiffers(proxy); - this.proxy = proxy; - } - - proxyDiffers(proxy: string): TextInputStatus { - const { httpsProxy = "" } = this.props.cluster.preferences; - - return proxy === httpsProxy ? TextInputStatus.CLEAN : TextInputStatus.DIRTY; - } - - getIconRight(): React.ReactNode { - switch (this.status) { - case TextInputStatus.CLEAN: - return null; - case TextInputStatus.DIRTY: - return ; - case TextInputStatus.UPDATED: - return ; - case TextInputStatus.UPDATING: - return ; - case TextInputStatus.ERROR: - return - - {this.errorText} - - - } - } - - @autobind() - updateClusterProxy(proxy: string) { - if (this.proxyDiffers(proxy) !== TextInputStatus.DIRTY) { - return; - } - - try { - const url = new URL(proxy); - - if (url.protocol !== "https") { - this.status = TextInputStatus.ERROR - this.errorText= `Proxy's protocol should be "https"` - return - } - if (url.port === "") { - this.status = TextInputStatus.ERROR - this.errorText= "Proxy should include a port" - return - } - } catch (e) { - this.status = TextInputStatus.ERROR - this.errorText= "Invalid URL" - return - } - - this.status = TextInputStatus.UPDATING - this.props.cluster.preferences.httpsProxy = proxy; - this.proxy = proxy; - this.status = TextInputStatus.UPDATED + 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 index 6337f56aa0..6cd933ca11 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx @@ -1,36 +1,36 @@ import React from "react"; -import { Cluster } from "../../../../main/cluster"; -import { clusterStore } from "../../../../common/cluster-store" -import { workspaceStore } from "../../../../common/workspace-store" -import { Select, SelectOption } from "../../../components/select"; -import { GeneralInputStatus } from "./statuses" -import { observable } from "mobx"; -import { autobind } from "../../../utils"; 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; + cluster: Cluster; } @observer export class ClusterWorkspaceSetting extends React.Component { - @observable workspace = this.props.cluster.workspace; - render() { - return <> -

Cluster Workspace

-

Change cluster workspace:

- this.props.cluster.workspace = value} + options={workspaceStore.workspacesList.map(w => + ({value: w.id, label: w.name}) + )} + /> + + ); } } \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/install-feature.tsx b/src/renderer/components/+cluster-settings/components/install-feature.tsx new file mode 100644 index 0000000000..a04b8fed63 --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/install-feature.tsx @@ -0,0 +1,93 @@ +import React from "react"; +import { observable, reaction, comparer } from "mobx"; +import { observer, disposeOnUnmount } from "mobx-react"; +import { clusterIpc } from "../../../../common/cluster-ipc"; +import { Cluster } from "../../../../main/cluster"; +import { Button } from "../../button"; +import { Notifications } from "../../notifications"; +import { Spinner } from "../../spinner"; + +interface Props { + cluster: Cluster + feature: string +} + +@observer +export class InstallFeature extends React.Component { + @observable loading = false; + + componentDidMount() { + disposeOnUnmount(this, + reaction(() => this.props.cluster.features[this.props.feature], () => { + this.loading = false; + }, { equals: comparer.structural }) + ); + } + + getActionButtons() { + const { cluster, feature } = this.props; + const features = cluster.features[feature]; + const disabled = !cluster.isAdmin || this.loading; + const loadingIcon = this.loading ? : null; + if (!features) return null; + return ( +
+ {features.canUpgrade && + + } + {features.installed && + + } + {!features.installed && !features.canUpgrade && + + } + {loadingIcon} + {!cluster.isAdmin && Actions can only be performed by admins.} +
+ ); + } + + runAction(action: () => Promise): () => Promise { + return async () => { + try { + this.loading = true; + await action(); + } catch (err) { + Notifications.error(err.toString()); + } + }; + } + + render() { + return ( + <> + {this.props.children} +
{this.getActionButtons()}
+ + ); + } +} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/install-metrics.tsx b/src/renderer/components/+cluster-settings/components/install-metrics.tsx deleted file mode 100644 index 171adc6034..0000000000 --- a/src/renderer/components/+cluster-settings/components/install-metrics.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from "react"; -import { Cluster } from "../../../../main/cluster"; -import { Button } from "../../button"; -import { autobind } from "../../../utils"; -import { Tooltip, TooltipPosition } from "../../tooltip"; -import { MetricsFeature } from "../../../../features/metrics"; -import { Spinner } from "../../spinner"; -import { Icon } from "../../icon"; -import { clusterIpc } from "../../../../common/cluster-ipc"; -import { observable } from "mobx"; -import { ActionStatus } from "./statuses" -import { observer } from "mobx-react"; - -interface Props { - cluster: Cluster; -} - -@observer -export class InstallMetrics extends React.Component { - @observable status = ActionStatus.IDLE; - @observable errorText?: string; - - render() { - return <> -

Metrics

-

- User Mode feature enables non-admin users to see namespaces they have access to. - This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces. -

-
- {this.getActionButtons()} -
- ; - } - - getStatusIcon(): React.ReactNode { - switch (this.status) { - case ActionStatus.IDLE: - return null; - case ActionStatus.PROCESSING: - return ; - case ActionStatus.ERROR: - return - } - } - - getDisabledToolTip(id: string, action: string): React.ReactNode { - const { cluster } = this.props; - if (cluster.isAdmin) { - return null; - } - - return ( - - {action} only allowed by admins - - ); - } - - getActionButtons(): React.ReactNode[] { - const { cluster } = this.props - const buttons = []; - - if (cluster.features[MetricsFeature.id]?.canUpgrade) { - buttons.push( - - ); - } - - if (cluster.features[MetricsFeature.id]?.installed) { - buttons.push( - - ); - } else { - buttons.push( - - ); - } - - return buttons; - } - - runAction(action: keyof typeof clusterIpc): () => Promise { - return async () => { - const { cluster } = this.props; - console.log(`running ${action} ${MetricsFeature.id} onto ${cluster.preferences.clusterName}`); - - try { - this.status = ActionStatus.PROCESSING - await clusterIpc[action].invokeFromRenderer(cluster.id, MetricsFeature.id); - try { - await cluster.refresh(); - } catch (err) { - console.error(err); - } - this.status = ActionStatus.IDLE - } catch (err) { - this.status = ActionStatus.ERROR - this.errorText = err.toString() - } - }; - } -} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/install-user-mode.tsx b/src/renderer/components/+cluster-settings/components/install-user-mode.tsx deleted file mode 100644 index faf7562336..0000000000 --- a/src/renderer/components/+cluster-settings/components/install-user-mode.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import React from "react"; -import { Cluster } from "../../../../main/cluster"; -import { Button } from "../../button"; -import { autobind } from "../../../utils"; -import { Tooltip, TooltipPosition } from "../../tooltip"; -import { Spinner } from "../../spinner"; -import { Icon } from "../../icon"; -import { UserModeFeature } from "../../../../features/user-mode"; -import { clusterIpc } from "../../../../common/cluster-ipc"; -import { observable } from "mobx"; -import { ActionStatus } from "./statuses" -import { observer } from "mobx-react"; - -interface Props { - cluster: Cluster; -} - -@observer -export class InstallUserMode extends React.Component { - @observable status = ActionStatus.IDLE; - @observable errorText?: string; - - render() { - return <> -

User Mode

-

- User Mode feature enables non-admin users to see namespaces they have access to. - This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces. -

-
- {this.getActionButtons()} -
- ; - } - - - getStatusIcon(): React.ReactNode { - switch (this.status) { - case ActionStatus.IDLE: - return null; - case ActionStatus.PROCESSING: - return ; - case ActionStatus.ERROR: - return - } - } - - getDisabledToolTip(id: string, action: string): React.ReactNode { - const { cluster } = this.props; - if (cluster.isAdmin) { - return null; - } - - return - {action} only allowed by admins - ; - } - - getActionButtons(): React.ReactNode[] { - const { cluster } = this.props - const buttons = []; - - if (cluster.features[UserModeFeature.id]?.canUpgrade) { - buttons.push( - - ); - } - - if (cluster.features[UserModeFeature.id]?.installed) { - buttons.push( - - ); - } else { - buttons.push( - - ); - } - - return buttons; - } - - runAction(action: keyof typeof clusterIpc): () => Promise { - return async () => { - const { cluster } = this.props; - console.log(`running ${action} ${UserModeFeature.id} onto ${cluster.preferences.clusterName}`); - - try { - this.status = ActionStatus.PROCESSING - await clusterIpc[action].invokeFromRenderer(cluster.id, UserModeFeature.id); - try { - await cluster.refresh(); - } catch (err) { - console.error(err); - } - this.status = ActionStatus.IDLE - } catch (err) { - this.status = ActionStatus.ERROR - this.errorText = err.toString() - } - }; - } -} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx b/src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx index 0c57196330..fe62ef4899 100644 --- a/src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx +++ b/src/renderer/components/+cluster-settings/components/remove-cluster-button.tsx @@ -1,63 +1,37 @@ import React from "react"; -import { Cluster } from "../../../../main/cluster"; -import { Button } from "../../button"; -import { autobind } from "../../../utils"; -import { Spinner } from "../../spinner"; -import { Icon } from "../../icon"; -import { ConfirmDialog } from "../../confirm-dialog"; import { Trans } from "@lingui/macro"; +import { observer } from "mobx-react"; import { clusterIpc } from "../../../../common/cluster-ipc"; import { clusterStore } from "../../../../common/cluster-store"; -import { observable } from "mobx"; -import { observer } from "mobx-react"; -import { RemovalStatus } from "./statuses" +import { Cluster } from "../../../../main/cluster"; +import { autobind } from "../../../utils"; +import { Button } from "../../button"; +import { ConfirmDialog } from "../../confirm-dialog"; interface Props { - cluster: Cluster; + cluster: Cluster; } @observer export class RemoveClusterButton extends React.Component { - @observable status = RemovalStatus.PRESENT; - @observable errorText?: string; - - render() { - return ( -
- -
- ); - } - - getStatusIcon(): React.ReactNode { - switch (this.status) { - case RemovalStatus.PRESENT: - return null; - case RemovalStatus.PROCESSING: - return ; - case RemovalStatus.ERROR: - return ; - } - } - - @autobind() + @autobind() confirmRemoveCluster() { const { cluster } = this.props; - ConfirmDialog.open({ message:

Are you sure you want to remove {cluster.preferences.clusterName} from Lens?

, labelOk: Yes, labelCancel: No, ok: async () => { - try { - this.status = RemovalStatus.PROCESSING; - await clusterIpc.disconnect.invokeFromRenderer(cluster.id); - await clusterStore.removeById(cluster.id); - } catch (err) { - this.status = RemovalStatus.ERROR; - this.errorText = err.toString(); - } + await clusterStore.removeById(cluster.id); } }) } + + render() { + return ( + + ); + } } \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/components/statuses.ts b/src/renderer/components/+cluster-settings/components/statuses.ts deleted file mode 100644 index d9d897c430..0000000000 --- a/src/renderer/components/+cluster-settings/components/statuses.ts +++ /dev/null @@ -1,24 +0,0 @@ -export enum TextInputStatus { - CLEAN = "clean", - DIRTY = "dirty", - UPDATING = "updating", - ERROR = "error", - UPDATED = "updated", -} - -export enum GeneralInputStatus { - CLEAN = "clean", - ERROR = "error", -} - -export enum ActionStatus { - IDLE = "idle", - PROCESSING = "processing", - ERROR = "error" -} - -export enum RemovalStatus { - PRESENT = "present", - PROCESSING = "processing", - ERROR = "error", -} \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/features.tsx b/src/renderer/components/+cluster-settings/features.tsx index b049fa90ff..f1b3cdada8 100644 --- a/src/renderer/components/+cluster-settings/features.tsx +++ b/src/renderer/components/+cluster-settings/features.tsx @@ -1,7 +1,9 @@ import React from "react"; import { Cluster } from "../../../main/cluster"; -import { InstallMetrics } from "./components/install-metrics"; -import { InstallUserMode } from "./components/install-user-mode"; +import { InstallFeature } from "./components/install-feature"; +import { SubTitle } from "../layout/sub-title"; +import { MetricsFeature } from "../../../features/metrics"; +import { UserModeFeature } from "../../../features/user-mode"; interface Props { cluster: Cluster; @@ -11,10 +13,30 @@ export class Features extends React.Component { render() { const { cluster } = this.props; - return
-

Features

- - -
; + return ( +
+

Features

+ + <> + +

+ Enable timeseries data visualization (Prometheus stack) for your cluster. + Install this only if you don't have existing Prometheus stack installed. + You can see preview of manifests{" "} + here. +

+ +
+ + <> + +

+ User Mode feature enables non-admin users to see namespaces they have access to.{" "} + This is achieved by configuring RBAC rules so that every authenticated user is granted to list namespaces. +

+ +
+
+ ); } } \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/general.tsx b/src/renderer/components/+cluster-settings/general.tsx index e03c6e2195..5fb6e9b81f 100644 --- a/src/renderer/components/+cluster-settings/general.tsx +++ b/src/renderer/components/+cluster-settings/general.tsx @@ -15,8 +15,6 @@ export class General extends React.Component { render() { return

General

-
- diff --git a/src/renderer/components/+cluster-settings/removal.tsx b/src/renderer/components/+cluster-settings/removal.tsx index f1d613c694..7d97e9c515 100644 --- a/src/renderer/components/+cluster-settings/removal.tsx +++ b/src/renderer/components/+cluster-settings/removal.tsx @@ -3,16 +3,18 @@ import { Cluster } from "../../../main/cluster"; import { RemoveClusterButton } from "./components/remove-cluster-button"; interface Props { - cluster: Cluster; + cluster: Cluster; } export class Removal extends React.Component { render() { const { cluster } = this.props; - return
-

Removal

- -
; + return ( +
+

Removal

+ +
+ ); } } \ No newline at end of file diff --git a/src/renderer/components/+cluster-settings/status.tsx b/src/renderer/components/+cluster-settings/status.tsx index 6e0bf528fc..3136fc5962 100644 --- a/src/renderer/components/+cluster-settings/status.tsx +++ b/src/renderer/components/+cluster-settings/status.tsx @@ -1,41 +1,40 @@ import React from "react"; -import { Spinner } from "../spinner"; import { Cluster } from "../../../main/cluster"; +import { SubTitle } from "../layout/sub-title"; +import { Table, TableCell, TableRow } from "../table"; interface Props { cluster: Cluster; } export class Status extends React.Component { - renderStatusRows(): JSX.Element[] { + renderStatusRows() { const { cluster } = this.props; - - const rows: [string, React.ReactNode][] = [ + const rows = [ ["Online Status", cluster.online ? "online" : `offline (${cluster.failureReason || "unknown reason"}`], ["Distribution", cluster.distribution], ["Kerbel Version", cluster.version], ["API Address", cluster.apiUrl], + ["Nodes Count", cluster.nodes || "0"] ]; - - if (cluster.nodes > 0) { - rows.push(["Nodes Count", cluster.nodes]); - } - - return rows - .map(([header, value]) => [ -
{header}
, - {value} - ]) - .flat(); + return ( + + {rows.map(([name, value]) => { + return ( + + {name} + {value} + + ); + })} +
+ ); } render() { - const { cluster } = this.props; - return

Status

-
-

Cluster status

+

Cluster status information including: detected distribution, kernel version, and online status.

diff --git a/src/renderer/components/cluster-icon/cluster-icon.scss b/src/renderer/components/cluster-icon/cluster-icon.scss index f7e159a7c5..b8a87a07de 100644 --- a/src/renderer/components/cluster-icon/cluster-icon.scss +++ b/src/renderer/components/cluster-icon/cluster-icon.scss @@ -7,6 +7,12 @@ user-select: none; cursor: pointer; + &.interactive { + img { + opacity: .55; + } + } + &.active, &.interactive:hover { background-color: #fff; @@ -16,7 +22,6 @@ } img { - opacity: .55; width: var(--size); height: var(--size); } diff --git a/src/renderer/components/cluster-icon/cluster-icon.tsx b/src/renderer/components/cluster-icon/cluster-icon.tsx index 9d9a359670..ccc65892de 100644 --- a/src/renderer/components/cluster-icon/cluster-icon.tsx +++ b/src/renderer/components/cluster-icon/cluster-icon.tsx @@ -42,12 +42,12 @@ export class ClusterIcon extends React.Component { active: isActive, }); return ( -
+
{showTooltip && ( {clusterName} )} {icon && {clusterName}/} - {!icon && } + {!icon && } {showErrors && isAdmin && eventCount > 0 && ( { @observable showHint = true; showCluster = (clusterId: ClusterId) => { - if (clusterStore.activeClusterId === clusterId) { - navigate("/"); // redirect to index - } else { - clusterStore.activeClusterId = clusterId; - } + clusterStore.setActive(clusterId); + navigate("/"); // redirect to index } addCluster = () => { @@ -50,7 +47,10 @@ export class ClustersMenu extends React.Component { menu.append(new MenuItem({ label: _i18n._(t`Settings`), - click: () => navigate(clusterSettingsURL()) + click: () => { + clusterStore.setActive(cluster.id); + navigate(clusterSettingsURL()) + } })); if (cluster.online) { menu.append(new MenuItem({ diff --git a/src/renderer/components/file-picker/file-picker.scss b/src/renderer/components/file-picker/file-picker.scss index dbfada6c8e..63a9c2da74 100644 --- a/src/renderer/components/file-picker/file-picker.scss +++ b/src/renderer/components/file-picker/file-picker.scss @@ -1,14 +1,11 @@ .FilePicker { - input[type="file"] { - display: none; - } + input[type="file"] { + display: none; + } - label { - display: inline-block; - border: medium solid; - padding: 10px; - border-radius: 5px; - cursor: pointer; - margin: 5px; - } + 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 index 0290b5e645..5af7a176d1 100644 --- a/src/renderer/components/file-picker/file-picker.tsx +++ b/src/renderer/components/file-picker/file-picker.tsx @@ -43,7 +43,7 @@ export enum OverTotalSizeLimitStyle { export interface BaseProps { accept?: string; - labelText: string; + label: React.ReactNode; multiple?: boolean; // limit is the optional maximum number of files to upload @@ -175,10 +175,10 @@ export class FilePicker extends React.Component { } render() { - const { accept, labelText, multiple } = this.props; + const { accept, label, multiple } = this.props; return
- + { rows: multiLine ? (rows || 1) : null, ref: this.bindRef, type: "text", + spellCheck: "false", }); return ( diff --git a/src/renderer/components/layout/wizard-layout.scss b/src/renderer/components/layout/wizard-layout.scss index 5d7b95bbab..a81cdfd429 100644 --- a/src/renderer/components/layout/wizard-layout.scss +++ b/src/renderer/components/layout/wizard-layout.scss @@ -13,6 +13,11 @@ padding: $spacing; } + > .head-col { + position: sticky; + border-bottom: 1px solid $grey-800; + } + > .content-col { margin-right: $spacing; background-color: var(--clusters-menu-bgc); @@ -29,6 +34,10 @@ border-left: 1px solid #353a3e; } + p { + line-height: 140%; + } + a { color: $colorInfo; } diff --git a/src/renderer/components/layout/wizard-layout.tsx b/src/renderer/components/layout/wizard-layout.tsx index 107c8feffe..01b7f331d8 100644 --- a/src/renderer/components/layout/wizard-layout.tsx +++ b/src/renderer/components/layout/wizard-layout.tsx @@ -5,6 +5,8 @@ import { cssNames, IClassName } from "../../utils"; interface Props { className?: IClassName; + header?: React.ReactNode; + headerClass?: IClassName; contentClass?: IClassName; infoPanelClass?: IClassName; infoPanel?: React.ReactNode; @@ -13,9 +15,14 @@ interface Props { @observer export class WizardLayout extends React.Component { render() { - const { className, contentClass, infoPanelClass, infoPanel, children: content } = this.props; + const { className, contentClass, infoPanelClass, infoPanel, header, headerClass, children: content } = this.props; return (
+ {header && ( +
+ {header} +
+ )}
{content}
diff --git a/src/renderer/components/select/select.scss b/src/renderer/components/select/select.scss index 002c2a567c..20078215ba 100644 --- a/src/renderer/components/select/select.scss +++ b/src/renderer/components/select/select.scss @@ -31,7 +31,7 @@ html { border-radius: $radius; background: transparent; min-height: 0; - box-shadow: 0 0 0 1px $halfGray; + box-shadow: 0 0 0 1px $borderFaintColor; &--is-focused { box-shadow: 0 0 0 2px $primary; @@ -42,6 +42,10 @@ html { margin-bottom: 1px; } + &__single-value { + color: var(--textColorSecondary); + } + &__indicator { padding: $padding /2; opacity: .55; From 693017d2ec87a971d35a9dc1e628e507737f3e4d Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Fri, 7 Aug 2020 11:30:37 -0400 Subject: [PATCH 04/14] adding port-forward for containers in pods (#528) * adding port-forward for containers in pods address review comments use more idiomatic approach for async code move some files in advance of merge conflict with Lens restructure work * Separate the port forward links in the UI (so they don't all spin when one link is clicked) * minor fixes * addressed review comments (replaced

with

, moved key attribute to proper element) * fix lint issue * removed extraneous
from pod container port details Signed-off-by: Jim Ehrismann --- src/main/router.ts | 2 +- src/main/routes/port-forward.ts | 21 ++++---- .../+network-services/service-details.tsx | 10 +++- .../service-port-component.scss | 22 ++++++++ .../service-port-component.tsx | 48 +++++++++++++++++ .../+network-services/service-ports.scss | 24 --------- .../+network-services/service-ports.tsx | 54 ------------------- .../+workloads-pods/pod-container-port.scss | 23 ++++++++ .../+workloads-pods/pod-container-port.tsx | 54 +++++++++++++++++++ .../+workloads-pods/pod-details-container.tsx | 12 ++--- 10 files changed, 172 insertions(+), 98 deletions(-) create mode 100644 src/renderer/components/+network-services/service-port-component.scss create mode 100644 src/renderer/components/+network-services/service-port-component.tsx delete mode 100644 src/renderer/components/+network-services/service-ports.scss delete mode 100644 src/renderer/components/+network-services/service-ports.tsx create mode 100644 src/renderer/components/+workloads-pods/pod-container-port.scss create mode 100644 src/renderer/components/+workloads-pods/pod-container-port.tsx diff --git a/src/main/router.ts b/src/main/router.ts index 9ee3a47c73..3381fa139d 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -123,7 +123,7 @@ export class Router { this.router.add({ method: "post", path: `${apiBase}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)) // Port-forward API - this.router.add({ method: "post", path: `${apiBase}/services/{namespace}/{service}/port-forward/{port}` }, portForwardRoute.routeServicePortForward.bind(portForwardRoute)) + this.router.add({ method: "post", path: `${apiBase}/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)) diff --git a/src/main/routes/port-forward.ts b/src/main/routes/port-forward.ts index 27b1158700..ca222596a1 100644 --- a/src/main/routes/port-forward.ts +++ b/src/main/routes/port-forward.ts @@ -14,7 +14,7 @@ class PortForward { return PortForward.portForwards.find((pf) => { return ( pf.clusterId == forward.clusterId && - pf.kind == "service" && + pf.kind == forward.kind && pf.name == forward.name && pf.namespace == forward.namespace && pf.port == forward.port @@ -42,7 +42,7 @@ class PortForward { "--kubeconfig", this.kubeConfig, "port-forward", "-n", this.namespace, - `service/${this.name}`, + `${this.kind}/${this.name}`, `${this.localPort}:${this.port}` ] @@ -72,21 +72,22 @@ class PortForward { class PortForwardRoute extends LensApi { - public async routeServicePortForward(request: LensApiRequest) { + public async routePortForward(request: LensApiRequest) { const { params, response, cluster} = request + const { namespace, port, resourceType, resourceName } = params let portForward = PortForward.getPortforward({ - clusterId: cluster.id, kind: "service", name: params.service, - namespace: params.namespace, port: params.port + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace: namespace, port: port }) if (!portForward) { - logger.info(`Creating a new port-forward ${params.namespace}/${params.service}:${params.port}`) + logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`) portForward = new PortForward({ clusterId: cluster.id, - kind: "service", - namespace: params.namespace, - name: params.service, - port: params.port, + kind: resourceType, + namespace: namespace, + name: resourceName, + port: port, kubeConfig: cluster.proxyKubeconfigPath() }) const started = await portForward.start() diff --git a/src/renderer/components/+network-services/service-details.tsx b/src/renderer/components/+network-services/service-details.tsx index 0c158c9e6b..df37d6238e 100644 --- a/src/renderer/components/+network-services/service-details.tsx +++ b/src/renderer/components/+network-services/service-details.tsx @@ -11,7 +11,7 @@ import { Service, serviceApi, endpointApi } from "../../api/endpoints"; import { _i18n } from "../../i18n"; import { apiManager } from "../../api/api-manager"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; -import { ServicePorts } from "./service-ports"; +import { ServicePortComponent } from "./service-port-component"; import { endpointStore } from "../+network-endpoints/endpoints.store"; import { ServiceDetailsEndpoint } from "./service-details-endpoint"; @@ -61,7 +61,13 @@ export class ServiceDetails extends React.Component { )} Ports}> - +
+ { + service.getPorts().map((port) => ( + + )) + } +
{spec.type === "LoadBalancer" && spec.loadBalancerIP && ( diff --git a/src/renderer/components/+network-services/service-port-component.scss b/src/renderer/components/+network-services/service-port-component.scss new file mode 100644 index 0000000000..0e9945631d --- /dev/null +++ b/src/renderer/components/+network-services/service-port-component.scss @@ -0,0 +1,22 @@ +.ServicePortComponent { + &.waiting { + opacity: 0.5; + pointer-events: none; + } + + &:not(:last-child) { + margin-bottom: $margin; + } + + span { + cursor: pointer; + color: $primary; + text-decoration: underline; + } + + .Spinner { + --spinner-size: #{$unit * 2}; + margin-left: $margin; + position: absolute; + } +} diff --git a/src/renderer/components/+network-services/service-port-component.tsx b/src/renderer/components/+network-services/service-port-component.tsx new file mode 100644 index 0000000000..252bf8eb16 --- /dev/null +++ b/src/renderer/components/+network-services/service-port-component.tsx @@ -0,0 +1,48 @@ +import "./service-port-component.scss" + +import React from "react"; +import { observer } from "mobx-react"; +import { t } from "@lingui/macro"; +import { Service, ServicePort } from "../../api/endpoints"; +import { _i18n } from "../../i18n"; +import { apiBase } from "../../api" +import { observable } from "mobx"; +import { cssNames } from "../../utils"; +import { Notifications } from "../notifications"; +import { Spinner } from "../spinner" + +interface Props { + service: Service; + port: ServicePort; +} + +@observer +export class ServicePortComponent extends React.Component { + @observable waiting = false; + + async portForward() { + const { service, port } = this.props; + this.waiting = true; + try { + await apiBase.post(`/pods/${service.getNs()}/service/${service.getName()}/port-forward/${port.port}`, {}) + } catch(error) { + Notifications.error(error); + } finally { + this.waiting = false; + } + } + + render() { + const { port } = this.props; + return ( +
+ this.portForward() }> + {port.toString()} + {this.waiting && ( + + )} + +
+ ); + } +} diff --git a/src/renderer/components/+network-services/service-ports.scss b/src/renderer/components/+network-services/service-ports.scss deleted file mode 100644 index 5a683af86c..0000000000 --- a/src/renderer/components/+network-services/service-ports.scss +++ /dev/null @@ -1,24 +0,0 @@ -.ServicePorts { - &.waiting { - opacity: 0.5; - pointer-events: none; - } - - p { - &:not(:last-child) { - margin-bottom: $margin; - } - - span { - cursor: pointer; - color: $primary; - text-decoration: underline; - } - } - - .Spinner { - --spinner-size: #{$unit * 2}; - margin-left: $margin; - position: absolute; - } -} diff --git a/src/renderer/components/+network-services/service-ports.tsx b/src/renderer/components/+network-services/service-ports.tsx deleted file mode 100644 index 3335be6907..0000000000 --- a/src/renderer/components/+network-services/service-ports.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import "./service-ports.scss" - -import React from "react"; -import { observer } from "mobx-react"; -import { t } from "@lingui/macro"; -import { Service, ServicePort } from "../../api/endpoints"; -import { _i18n } from "../../i18n"; -import { apiBase } from "../../api" -import { observable } from "mobx"; -import { cssNames } from "../../utils"; -import { Notifications } from "../notifications"; -import { Spinner } from "../spinner" - -interface Props { - service: Service; -} - -@observer -export class ServicePorts extends React.Component { - @observable waiting = false; - - async portForward(port: ServicePort) { - const { service } = this.props; - this.waiting = true; - apiBase.post(`/services/${service.getNs()}/${service.getName()}/port-forward/${port.port}`, {}) - .catch(error => { - Notifications.error(error); - }) - .finally(() => { - this.waiting = false; - }); - } - - render() { - const { service } = this.props; - return ( -
- { - service.getPorts().map((port) => { - return( -

- this.portForward(port) }> - {port.toString()} - {this.waiting && ( - - )} - -

- ); - })} -
- ); - } -} diff --git a/src/renderer/components/+workloads-pods/pod-container-port.scss b/src/renderer/components/+workloads-pods/pod-container-port.scss new file mode 100644 index 0000000000..081f0b1090 --- /dev/null +++ b/src/renderer/components/+workloads-pods/pod-container-port.scss @@ -0,0 +1,23 @@ +.PodContainerPort { + &.waiting { + opacity: 0.5; + pointer-events: none; + } + + &:not(:last-child) { + margin-bottom: $margin; + } + + span { + cursor: pointer; + color: $primary; + text-decoration: underline; + position: relative; + } + + .Spinner { + --spinner-size: #{$unit * 2}; + margin-left: $margin; + position: absolute; + } +} \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx new file mode 100644 index 0000000000..9ebaed4fd7 --- /dev/null +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -0,0 +1,54 @@ +import "./pod-container-port.scss" + +import React from "react"; +import { observer } from "mobx-react"; +import { t } from "@lingui/macro"; +import { Pod, IPodContainer } from "../../api/endpoints"; +import { _i18n } from "../../i18n"; +import { apiBase } from "../../api" +import { observable } from "mobx"; +import { cssNames } from "../../utils"; +import { Notifications } from "../notifications"; +import { Spinner } from "../spinner" + +interface Props { + pod: Pod; + port: { + name?: string; + containerPort: number; + protocol: string; + } +} + +@observer +export class PodContainerPort extends React.Component { + @observable waiting = false; + + async portForward() { + const { pod, port } = this.props; + this.waiting = true; + try { + await apiBase.post(`/pods/${pod.getNs()}/pod/${pod.getName()}/port-forward/${port.containerPort}`, {}) + } catch(error) { + Notifications.error(error); + } finally { + this.waiting = false; + } + } + + render() { + const { port } = this.props; + const { name, containerPort, protocol } = port; + const text = (name ? name + ': ' : '')+`${containerPort}/${protocol}` + return ( +
+ this.portForward() }> + {text} + {this.waiting && ( + + )} + +
+ ) + } +} diff --git a/src/renderer/components/+workloads-pods/pod-details-container.tsx b/src/renderer/components/+workloads-pods/pod-details-container.tsx index a5cdcc1fbd..79180b1130 100644 --- a/src/renderer/components/+workloads-pods/pod-details-container.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-container.tsx @@ -8,6 +8,7 @@ import { cssNames } from "../../utils"; import { StatusBrick } from "../status-brick"; import { Badge } from "../badge"; import { ContainerEnvironment } from "./pod-container-env"; +import { PodContainerPort } from "./pod-container-port"; import { ResourceMetrics } from "../resource-metrics"; import { IMetrics } from "../../api/endpoints/metrics.api"; import { ContainerCharts } from "./container-charts"; @@ -64,13 +65,10 @@ export class PodDetailsContainer extends React.Component { {ports && ports.length > 0 && Ports}> { - ports.map(port => { - const { name, containerPort, protocol } = port; - const key = `${container.name}-port-${containerPort}-${protocol}` - return ( -
- {name ? name + ': ' : ''}{containerPort}/{protocol} -
+ ports.map((port) => { + const key = `${container.name}-port-${port.containerPort}-${port.protocol}` + return( + ) }) } From 858ab88940f3b208835c3ad70b7010ab700e38ee Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 7 Aug 2020 16:24:47 -0400 Subject: [PATCH 05/14] use the Kubernetes regex for matching system names (#659) * use the Kubernetes regex for matching system names Signed-off-by: Sebastian Malton Co-authored-by: Sebastian Malton --- locales/en/messages.po | 4 +- locales/fi/messages.po | 2 +- locales/ru/messages.po | 2 +- src/renderer/api/kube-api-parse.ts | 2 +- .../components/input/input.validators.ts | 5 +- .../components/input/input.validators_test.ts | 48 +++++++++++++++++++ 6 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 src/renderer/components/input/input.validators_test.ts diff --git a/locales/en/messages.po b/locales/en/messages.po index f2ca7973e8..6f2abc511f 100644 --- a/locales/en/messages.po +++ b/locales/en/messages.po @@ -2178,8 +2178,8 @@ msgid "This field is required" msgstr "This field is required" #: src/renderer/components/input/input.validators.ts:39 -msgid "This field must contain only lowercase latin characters, numbers and dash." -msgstr "This field must contain only lowercase latin characters, numbers and dash." +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/+network-policies/network-policy-details.tsx:59 msgid "To" diff --git a/locales/fi/messages.po b/locales/fi/messages.po index 4d81ae2a65..5cfa28d784 100644 --- a/locales/fi/messages.po +++ b/locales/fi/messages.po @@ -2161,7 +2161,7 @@ msgid "This field is required" msgstr "" #: src/renderer/components/input/input.validators.ts:39 -msgid "This field must contain only lowercase latin characters, numbers and dash." +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/+network-policies/network-policy-details.tsx:59 diff --git a/locales/ru/messages.po b/locales/ru/messages.po index 6e55ab9f73..d67fcd9ab1 100644 --- a/locales/ru/messages.po +++ b/locales/ru/messages.po @@ -2179,7 +2179,7 @@ msgid "This field is required" msgstr "Это обязательное поле" #: src/renderer/components/input/input.validators.ts:39 -msgid "This field must contain only lowercase latin characters, numbers and dash." +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/+network-policies/network-policy-details.tsx:59 diff --git a/src/renderer/api/kube-api-parse.ts b/src/renderer/api/kube-api-parse.ts index 97a7875322..1354f86b60 100644 --- a/src/renderer/api/kube-api-parse.ts +++ b/src/renderer/api/kube-api-parse.ts @@ -59,7 +59,7 @@ export function parseApi(path: string): IKubeApiLinkBase { * - `GROUP` is /^D(\.D)*$/ where D is `DNS_LABEL` and length <= 253 * * There is no well defined selection from an array of items that were - * seperated by '/' + * separated by '/' * * Solution is to create a huristic. Namely: * 1. if '.' in left[0] then apiGroup <- left[0] diff --git a/src/renderer/components/input/input.validators.ts b/src/renderer/components/input/input.validators.ts index b0b415ef9f..7db0934d4f 100644 --- a/src/renderer/components/input/input.validators.ts +++ b/src/renderer/components/input/input.validators.ts @@ -53,9 +53,10 @@ export const maxLength: Validator = { validate: (value, { maxLength }) => value.length <= maxLength, }; +const systemNameMatcher = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/; export const systemName: Validator = { - message: () => _i18n._(t`This field must contain only lowercase latin characters, numbers and dash.`), - validate: value => !!value.match(/^[a-z0-9-]+$/), + message: () => _i18n._(t`A System Name must be lowercase DNS labels separated by dots. DNS labels are alphanumerics and dashes enclosed by alphanumerics.`), + validate: value => !!value.match(systemNameMatcher), }; export const accountId: Validator = { diff --git a/src/renderer/components/input/input.validators_test.ts b/src/renderer/components/input/input.validators_test.ts new file mode 100644 index 0000000000..4477d63e93 --- /dev/null +++ b/src/renderer/components/input/input.validators_test.ts @@ -0,0 +1,48 @@ +import { isEmail, systemName } from "./input.validators"; + +describe("input validation tests", () => { + describe("isEmail tests", () => { + it("should be valid", () => { + expect(isEmail.validate("abc@news.com")).toBe(true); + expect(isEmail.validate("abc@news.co.uk")).toBe(true); + expect(isEmail.validate("abc1.3@news.co.uk")).toBe(true); + expect(isEmail.validate("abc1.3@news.name")).toBe(true); + }); + + it("should be invalid", () => { + expect(isEmail.validate("@news.com")).toBe(false); + expect(isEmail.validate("abcnews.co.uk")).toBe(false); + expect(isEmail.validate("abc1.3@news")).toBe(false); + expect(isEmail.validate("abc1.3@news.name.a.b.c.d.d")).toBe(false); + }); + }); + + describe("systemName tests", () => { + it("should be valid", () => { + expect(systemName.validate("a")).toBe(true); + expect(systemName.validate("ab")).toBe(true); + expect(systemName.validate("abc")).toBe(true); + expect(systemName.validate("1")).toBe(true); + expect(systemName.validate("12")).toBe(true); + expect(systemName.validate("123")).toBe(true); + expect(systemName.validate("1a2")).toBe(true); + expect(systemName.validate("1-2")).toBe(true); + expect(systemName.validate("1---------------2")).toBe(true); + expect(systemName.validate("1---------------2.a")).toBe(true); + expect(systemName.validate("1---------------2.a.1")).toBe(true); + expect(systemName.validate("1---------------2.9-a.1")).toBe(true); + }); + + it("should be invalid", () => { + expect(systemName.validate("")).toBe(false); + expect(systemName.validate("-")).toBe(false); + expect(systemName.validate(".")).toBe(false); + expect(systemName.validate("as.")).toBe(false); + expect(systemName.validate(".asd")).toBe(false); + expect(systemName.validate("a.-")).toBe(false); + expect(systemName.validate("a.1-")).toBe(false); + expect(systemName.validate("o.2-2.")).toBe(false); + expect(systemName.validate("o.2-2....")).toBe(false); + }); + }); +}); \ No newline at end of file From 5c1ded6c4b2fd7a7890eaf6ceccab7779e9a32e0 Mon Sep 17 00:00:00 2001 From: alexfront Date: Mon, 10 Aug 2020 12:16:24 +0300 Subject: [PATCH 06/14] Removing legacy browserCheck() utility Signed-off-by: alexfront --- src/renderer/browser-check.tsx | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 src/renderer/browser-check.tsx 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 From 0393d1f782b72c76a5c666a5b1708ef6d332f11f Mon Sep 17 00:00:00 2001 From: alexfront Date: Mon, 10 Aug 2020 12:17:04 +0300 Subject: [PATCH 07/14] Revert select box-shadow (outline) color Signed-off-by: alexfront --- src/renderer/components/select/select.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/select/select.scss b/src/renderer/components/select/select.scss index 20078215ba..1b5a081362 100644 --- a/src/renderer/components/select/select.scss +++ b/src/renderer/components/select/select.scss @@ -31,7 +31,7 @@ html { border-radius: $radius; background: transparent; min-height: 0; - box-shadow: 0 0 0 1px $borderFaintColor; + box-shadow: 0 0 0 1px $halfGray; &--is-focused { box-shadow: 0 0 0 2px $primary; From de46907956f0f1f7e896fe3881fc1b8d81cd274c Mon Sep 17 00:00:00 2001 From: alexfront Date: Mon, 10 Aug 2020 12:19:04 +0300 Subject: [PATCH 08/14] Allowing to save prometheus service address Signed-off-by: alexfront --- .../+cluster-settings/cluster-settings.scss | 12 ++- .../components/cluster-prometheus-setting.tsx | 77 +++++++++++++++++-- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/+cluster-settings/cluster-settings.scss b/src/renderer/components/+cluster-settings/cluster-settings.scss index e02a4da5f8..acb59ea0ac 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.scss +++ b/src/renderer/components/+cluster-settings/cluster-settings.scss @@ -57,6 +57,10 @@ font-size: smaller; opacity: 0.8; } + + p + p, .hint + p { + padding-top: $padding; + } } .status-table { @@ -79,7 +83,13 @@ } } - .Input,.Select { + .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/components/cluster-prometheus-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx index e4f81bb18a..ef0ec55551 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx @@ -1,10 +1,11 @@ import React from "react"; -import merge from "lodash/merge"; 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" }, @@ -17,6 +18,52 @@ interface Props { @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 ( <> @@ -26,18 +73,32 @@ export class ClusterPrometheusSetting extends React.Component { guide{" "} for possible configuration changes.

+

Prometheus installation method.

this.path = value} + onBlur={this.onSavePath} + placeholder="/:" + /> + + An address to an existing Prometheus installation{" "} + (<namespace>/<service>:<port>). Lens tries to auto-detect address if left empty. + + + )} ); } From ec1ed6379601fbddaad47a2210a9967fd5c4ad15 Mon Sep 17 00:00:00 2001 From: alexfront Date: Mon, 10 Aug 2020 14:14:41 +0300 Subject: [PATCH 09/14] Improving code readabilty Signed-off-by: alexfront --- .../+cluster-settings/components/cluster-prometheus-setting.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx index ef0ec55551..729090629d 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-prometheus-setting.tsx @@ -95,7 +95,7 @@ export class ClusterPrometheusSetting extends React.Component { /> An address to an existing Prometheus installation{" "} - (<namespace>/<service>:<port>). Lens tries to auto-detect address if left empty. + ({'/:'}). Lens tries to auto-detect address if left empty. )} From f7e823dcc34c0530378cced125ad2b1cb5afca5d Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 10 Aug 2020 15:53:42 +0300 Subject: [PATCH 10/14] Setting overflow:hidden on lens-view elem (#664) This prevents scrollbar flickering caused by Animation component with 'slide-left' appearing. Signed-off-by: alexfront --- src/renderer/components/cluster-manager/cluster-manager.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index 4b9040596d..82ad99a215 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -8,6 +8,7 @@ #lens-view { position: relative; grid-area: lens-view; + overflow: hidden; &.inactive { opacity: .85; From eea149c47467387f6b3b9558245669e2e4542900 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 10 Aug 2020 16:47:34 +0300 Subject: [PATCH 11/14] Fixing broken/missing icons (#665) * Normalizing titles size and weight Signed-off-by: alexfront * Replacing deployment scale icon Signed-off-by: alexfront * Replacing download logs icon Signed-off-by: alexfront * Making

tag bigger than

Signed-off-by: alexfront --- .../components/+workloads-deployments/deployments.tsx | 2 +- src/renderer/components/+workloads-pods/pod-logs-dialog.tsx | 2 +- src/renderer/components/app.scss | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) 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-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/app.scss b/src/renderer/components/app.scss index 3fcbce9613..59d9b632d0 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -82,7 +82,7 @@ hr { h1 { color: white; font-size: 28px; - font-weight: 300; + font-weight: normal; letter-spacing: -.010em; margin: 0; } @@ -99,13 +99,13 @@ h3 { h4 { @extend h3; - font-size: 16px; + font-size: 18px; } h5 { @extend h4; padding: $padding / 2 0; - font-size: 14px; + font-size: 16px; } h6 { From 5fc56da22b2864cadb38ec0a571ed307d0fa908d Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 12 Aug 2020 09:13:12 -0400 Subject: [PATCH 12/14] add some simple user store tests (#674) * add some simple user store tests Signed-off-by: Sebastian Malton Co-authored-by: Sebastian Malton --- src/common/user-store_test.ts | 102 ++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/common/user-store_test.ts 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 From 6725a6f7e76390434a2c7653340ad4dd6e56c64f Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 14 Aug 2020 15:21:30 +0300 Subject: [PATCH 13/14] PR comment fixes for @jari Signed-off-by: Roman --- src/common/base-store.ts | 4 ---- src/common/kube-helpers.ts | 5 +---- src/common/tracker.ts | 3 ++- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/common/base-store.ts b/src/common/base-store.ts index 722698fa67..0cc087eb9f 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -135,8 +135,4 @@ export class BaseStore extends Singleton { recurseEverything: true, }) } - - * [Symbol.iterator]() { - yield* Object.entries(this.toJSON()); - } } diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 3709f0ac94..9359ef4490 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -1,11 +1,10 @@ import { app, remote } from "electron"; import { KubeConfig, V1Node, V1Pod } from "@kubernetes/client-node" -import { ensureDirSync, readFile, writeFileSync } from "fs-extra"; +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"; -import fse from "fs-extra" function resolveTilde(filePath: string) { if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { @@ -135,8 +134,6 @@ export function podHasIssues(pod: V1Pod) { ) } -// 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: V1Node) { return node.status.conditions.filter(c => c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades" diff --git a/src/common/tracker.ts b/src/common/tracker.ts index c904d2c806..bc9a8e191b 100644 --- a/src/common/tracker.ts +++ b/src/common/tracker.ts @@ -3,6 +3,7 @@ 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"; export class Tracker extends Singleton { static readonly GA_ID = "UA-159377374-1" @@ -40,7 +41,7 @@ export class Tracker extends Singleton { ...otherParams, }).send() } catch (err) { - console.error(`Failed to track "${eventCategory}:${eventAction}"`, err) + logger.error(`Failed to track "${eventCategory}:${eventAction}"`, err) } } } From 592c8920b2d875cfda21a87d79789725298d999b Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 17 Aug 2020 09:37:57 -0400 Subject: [PATCH 14/14] add some basic workspace store tests (#680) * add some basic workspace store tests Signed-off-by: Sebastian Malton Co-authored-by: Sebastian Malton --- src/common/workspace-store.ts | 4 + src/common/workspace-store_test.ts | 128 +++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 src/common/workspace-store_test.ts diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts index 3935dda02e..dd10a4c433 100644 --- a/src/common/workspace-store.ts +++ b/src/common/workspace-store.ts @@ -51,6 +51,10 @@ export class WorkspaceStore extends BaseStore { @action setActive(id = WorkspaceStore.defaultId) { + if (!this.getById(id)) { + throw new Error(`workspace ${id} doesn't exist`); + } + this.currentWorkspaceId = id; } 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