From 12c538b0eb96e426a3142a79a1e37474aea7137f Mon Sep 17 00:00:00 2001 From: pashevskii <53330707+pashevskii@users.noreply.github.com> Date: Wed, 13 Jan 2021 11:38:20 +0400 Subject: [PATCH 001/219] Column filters (#1532) * Column filters Signed-off-by: Pavel Ashevskii * Add showWithColumn property Signed-off-by: Pavel Ashevskii --- src/common/user-store.ts | 2 + .../components/+workloads-pods/pods.tsx | 4 +- .../item-object-list/item-list-layout.tsx | 86 +++++++++++++++++-- .../item-object-list/table-menu.scss | 4 + src/renderer/components/menu/menu-actions.tsx | 5 +- src/renderer/components/table/table-cell.tsx | 3 +- 6 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 src/renderer/components/item-object-list/table-menu.scss diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 1e9f7646f3..cf271a011d 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -28,6 +28,7 @@ export interface UserPreferences { downloadBinariesPath?: string; kubectlBinariesPath?: string; openAtLogin?: boolean; + hiddenTableColumns?: Record } export class UserStore extends BaseStore { @@ -54,6 +55,7 @@ export class UserStore extends BaseStore { downloadMirror: "default", downloadKubectlBinaries: true, // Download kubectl binaries matching cluster version openAtLogin: false, + hiddenTableColumns: {}, }; protected async handleOnLoad() { diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx index 88981b968f..2296b98317 100644 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ b/src/renderer/components/+workloads-pods/pods.tsx @@ -74,6 +74,8 @@ export class Pods extends React.Component { pod.getName(), [sortBy.namespace]: (pod: Pod) => pod.getNs(), @@ -94,7 +96,7 @@ export class Pods extends React.Component { renderHeaderTitle="Pods" renderTableHeader={[ { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, + { className: "warning", showWithColumn: "name" }, { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, { title: "Containers", className: "containers", sortBy: sortBy.containers }, { title: "Restarts", className: "restarts", sortBy: sortBy.restarts }, 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 bdd6302f5a..38e0e0218d 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -1,4 +1,5 @@ import "./item-list-layout.scss"; +import "./table-menu.scss"; import groupBy from "lodash/groupBy"; import React, { ReactNode } from "react"; @@ -18,6 +19,11 @@ import { PageFiltersList } from "./page-filters-list"; import { PageFiltersSelect } from "./page-filters-select"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; import { themeStore } from "../../theme.store"; +import { MenuActions} from "../menu/menu-actions"; +import { MenuItem } from "../menu"; +import { Checkbox } from "../checkbox"; +import { userStore } from "../../../common/user-store"; +import logger from "../../../main/logger"; // todo: refactor, split to small re-usable components @@ -32,6 +38,7 @@ interface IHeaderPlaceholders { } export interface ItemListLayoutProps { + tableId?: string; className: IClassName; store: ItemStore; dependentStores?: ItemStore[]; @@ -50,6 +57,7 @@ export interface ItemListLayoutProps { isReady?: boolean; // show loading indicator while not ready isSelectable?: boolean; // show checkbox in rows for selecting items isSearchable?: boolean; // apply search-filter & add search-input + isConfigurable?: boolean; copyClassNameFromHeadCells?: boolean; sortingCallbacks?: { [sortBy: string]: TableSortCallback }; tableProps?: Partial; // low-level table configuration @@ -74,6 +82,7 @@ const defaultProps: Partial = { showHeader: true, isSearchable: true, isSelectable: true, + isConfigurable: false, copyClassNameFromHeadCells: true, dependentStores: [], filterItems: [], @@ -89,7 +98,7 @@ interface ItemListLayoutUserSettings { @observer export class ItemListLayout extends React.Component { static defaultProps = defaultProps as object; - + @observable hiddenColumnNames = new Set(); @observable isUnmounting = false; // default user settings (ui show-hide tweaks mostly) @@ -111,7 +120,10 @@ export class ItemListLayout extends React.Component { } async componentDidMount() { - const { store, dependentStores, isClusterScoped } = this.props; + const { store, dependentStores, isClusterScoped, tableId } = this.props; + + if (this.canBeConfigured) this.hiddenColumnNames = new Set(userStore.preferences?.hiddenTableColumns?.[tableId]); + const stores = [store, ...dependentStores]; if (!isClusterScoped) stores.push(namespaceStore); @@ -216,6 +228,42 @@ export class ItemListLayout extends React.Component { return this.applyFilters(filterItems, allItems); } + updateColumnFilter(checkboxValue: boolean, columnName: string) { + if (checkboxValue){ + this.hiddenColumnNames.delete(columnName); + } else { + this.hiddenColumnNames.add(columnName); + } + + if (this.canBeConfigured) { + userStore.preferences.hiddenTableColumns[this.props.tableId] = Array.from(this.hiddenColumnNames); + } + } + + columnIsVisible(index: number): boolean { + const {renderTableHeader} = this.props; + + if (!this.canBeConfigured) return true; + + return !this.hiddenColumnNames.has(renderTableHeader[index].showWithColumn ?? renderTableHeader[index].className); + } + + get canBeConfigured(): boolean { + const { isConfigurable, tableId, renderTableHeader } = this.props; + + if (!isConfigurable || !tableId) { + return false; + } + + if (!renderTableHeader?.every(({ className }) => className)) { + logger.warning("[ItemObjectList]: cannot configure an object list without all headers being identifiable"); + + return false; + } + + return true; + } + @autobind() getRow(uid: string) { const { @@ -259,7 +307,7 @@ export class ItemListLayout extends React.Component { } } - return ; + return this.columnIsVisible(index) ? : null; }) } {renderItemMenu && ( @@ -431,14 +479,19 @@ export class ItemListLayout extends React.Component { onClick={prevDefault(() => store.toggleSelectionAll(items))} /> )} - {renderTableHeader.map((cellProps, index) => )} - {renderItemMenu && } + {renderTableHeader.map((cellProps, index) => this.columnIsVisible(index) ? : null)} + { renderItemMenu && + + {this.canBeConfigured && this.renderColumnMenu()} + + } )} { !virtual && items.map(item => this.getRow(item.getId())) } + )} { ); } + renderColumnMenu() { + const { renderTableHeader} = this.props; + + return ( + + {renderTableHeader.map((cellProps, index) => ( + !cellProps.showWithColumn && + + `} + className = "MenuCheckbox" + value ={!this.hiddenColumnNames.has(cellProps.className)} + onChange = {(v) => this.updateColumnFilter(v, cellProps.className)} + /> + + ))} + + ); + } + renderFooter() { if (this.props.renderFooter) { return this.props.renderFooter(this); diff --git a/src/renderer/components/item-object-list/table-menu.scss b/src/renderer/components/item-object-list/table-menu.scss new file mode 100644 index 0000000000..b7e41f54ca --- /dev/null +++ b/src/renderer/components/item-object-list/table-menu.scss @@ -0,0 +1,4 @@ +.MenuCheckbox { + width: 100%; + height: 100%; +} diff --git a/src/renderer/components/menu/menu-actions.tsx b/src/renderer/components/menu/menu-actions.tsx index aa8191dd7f..4e46935aa4 100644 --- a/src/renderer/components/menu/menu-actions.tsx +++ b/src/renderer/components/menu/menu-actions.tsx @@ -13,6 +13,7 @@ import isString from "lodash/isString"; export interface MenuActionsProps extends Partial { className?: string; toolbar?: boolean; // display menu as toolbar with icons + autoCloseOnSelect?: boolean; triggerIcon?: string | IconProps | React.ReactNode; removeConfirmationMessage?: React.ReactNode | (() => React.ReactNode); updateAction?(): void; @@ -80,7 +81,7 @@ export class MenuActions extends React.Component { render() { const { - className, toolbar, children, updateAction, removeAction, triggerIcon, removeConfirmationMessage, + className, toolbar, autoCloseOnSelect, children, updateAction, removeAction, triggerIcon, removeConfirmationMessage, ...menuProps } = this.props; const menuClassName = cssNames("MenuActions flex", className, { @@ -98,7 +99,7 @@ export class MenuActions extends React.Component { className={menuClassName} usePortal={autoClose} closeOnScroll={autoClose} - closeOnClickItem={autoClose} + closeOnClickItem={autoCloseOnSelect ?? autoClose } closeOnClickOutside={autoClose} {...menuProps} > diff --git a/src/renderer/components/table/table-cell.tsx b/src/renderer/components/table/table-cell.tsx index a42db4c2be..97335078f1 100644 --- a/src/renderer/components/table/table-cell.tsx +++ b/src/renderer/components/table/table-cell.tsx @@ -15,6 +15,7 @@ export interface TableCellProps extends React.DOMAttributes { isChecked?: boolean; // mark checkbox as checked or not renderBoolean?: boolean; // show "true" or "false" for all of the children elements are "typeof boolean" sortBy?: TableSortBy; // column name, must be same as key in sortable object + showWithColumn?: string // className of another column, if it is not empty the current column is not shown in the filter menu, visibility of this one is the same as a specified column, applicable to headers only _sorting?: Partial; //
sorting state, don't use this prop outside (!) _sort?(sortBy: TableSortBy): void; //
sort function, don't use this prop outside (!) _nowrap?: boolean; // indicator, might come from parent , don't use this prop outside (!) @@ -63,7 +64,7 @@ export class TableCell extends React.Component { } render() { - const { className, checkbox, isChecked, sortBy, _sort, _sorting, _nowrap, children, title, renderBoolean: displayBoolean, ...cellProps } = this.props; + const { className, checkbox, isChecked, sortBy, _sort, _sorting, _nowrap, children, title, renderBoolean: displayBoolean, showWithColumn, ...cellProps } = this.props; const classNames = cssNames("TableCell", className, { checkbox, nowrap: _nowrap, From c48816ca5c226fb8e11a01434ff0ade8b91defd7 Mon Sep 17 00:00:00 2001 From: Violetta <38247153+vshakirova@users.noreply.github.com> Date: Thu, 14 Jan 2021 12:49:39 +0400 Subject: [PATCH 002/219] Add support for LimitRange (#1796) Signed-off-by: vshakirova --- integration/__tests__/app.tests.ts | 6 ++ src/common/rbac.ts | 3 +- src/extensions/renderer-api/k8s-api.ts | 2 + src/renderer/api/endpoints/index.ts | 1 + src/renderer/api/endpoints/limit-range.api.ts | 57 +++++++++++ .../components/+config-limit-ranges/index.ts | 3 + .../limit-range-details.scss | 12 +++ .../limit-range-details.tsx | 97 +++++++++++++++++++ .../limit-ranges.route.ts | 11 +++ .../+config-limit-ranges/limit-ranges.scss | 7 ++ .../limit-ranges.store.ts | 12 +++ .../+config-limit-ranges/limit-ranges.tsx | 53 ++++++++++ src/renderer/components/+config/config.tsx | 10 ++ .../+namespaces/namespace-details.tsx | 18 ++++ src/renderer/utils/rbac.ts | 1 + 15 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 src/renderer/api/endpoints/limit-range.api.ts create mode 100644 src/renderer/components/+config-limit-ranges/index.ts create mode 100644 src/renderer/components/+config-limit-ranges/limit-range-details.scss create mode 100644 src/renderer/components/+config-limit-ranges/limit-range-details.tsx create mode 100644 src/renderer/components/+config-limit-ranges/limit-ranges.route.ts create mode 100644 src/renderer/components/+config-limit-ranges/limit-ranges.scss create mode 100644 src/renderer/components/+config-limit-ranges/limit-ranges.store.ts create mode 100644 src/renderer/components/+config-limit-ranges/limit-ranges.tsx diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index 410712e4f0..d68ab2f011 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -313,6 +313,12 @@ describe("Lens integration tests", () => { expectedSelector: "h5.title", expectedText: "Resource Quotas" }, + { + name: "Limit Ranges", + href: "limitranges", + expectedSelector: "h5.title", + expectedText: "Limit Ranges" + }, { name: "HPA", href: "hpa", diff --git a/src/common/rbac.ts b/src/common/rbac.ts index bd003e87a1..fbcf7c98d8 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -1,7 +1,7 @@ import { getHostedCluster } from "./cluster-store"; export type KubeResource = - "namespaces" | "nodes" | "events" | "resourcequotas" | "services" | + "namespaces" | "nodes" | "events" | "resourcequotas" | "services" | "limitranges" | "secrets" | "configmaps" | "ingresses" | "networkpolicies" | "persistentvolumeclaims" | "persistentvolumes" | "storageclasses" | "pods" | "daemonsets" | "deployments" | "statefulsets" | "replicasets" | "jobs" | "cronjobs" | "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets"; @@ -23,6 +23,7 @@ export const apiResources: KubeApiResource[] = [ { resource: "horizontalpodautoscalers" }, { resource: "ingresses", group: "networking.k8s.io" }, { resource: "jobs", group: "batch" }, + { resource: "limitranges" }, { resource: "namespaces" }, { resource: "networkpolicies", group: "networking.k8s.io" }, { resource: "nodes" }, diff --git a/src/extensions/renderer-api/k8s-api.ts b/src/extensions/renderer-api/k8s-api.ts index fe04550fb7..071d8365ab 100644 --- a/src/extensions/renderer-api/k8s-api.ts +++ b/src/extensions/renderer-api/k8s-api.ts @@ -14,6 +14,7 @@ export { ConfigMap, configMapApi } from "../../renderer/api/endpoints"; export { Secret, secretsApi, ISecretRef } from "../../renderer/api/endpoints"; export { ReplicaSet, replicaSetApi } from "../../renderer/api/endpoints"; export { ResourceQuota, resourceQuotaApi } from "../../renderer/api/endpoints"; +export { LimitRange, limitRangeApi } from "../../renderer/api/endpoints"; export { HorizontalPodAutoscaler, hpaApi } from "../../renderer/api/endpoints"; export { PodDisruptionBudget, pdbApi } from "../../renderer/api/endpoints"; export { Service, serviceApi } from "../../renderer/api/endpoints"; @@ -46,6 +47,7 @@ export type { ConfigMapsStore } from "../../renderer/components/+config-maps/con export type { SecretsStore } from "../../renderer/components/+config-secrets/secrets.store"; export type { ReplicaSetStore } from "../../renderer/components/+workloads-replicasets/replicasets.store"; export type { ResourceQuotasStore } from "../../renderer/components/+config-resource-quotas/resource-quotas.store"; +export type { LimitRangesStore } from "../../renderer/components/+config-limit-ranges/limit-ranges.store"; export type { HPAStore } from "../../renderer/components/+config-autoscalers/hpa.store"; export type { PodDisruptionBudgetsStore } from "../../renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.store"; export type { ServiceStore } from "../../renderer/components/+network-services/services.store"; diff --git a/src/renderer/api/endpoints/index.ts b/src/renderer/api/endpoints/index.ts index f1202b9122..5ab54e7c3a 100644 --- a/src/renderer/api/endpoints/index.ts +++ b/src/renderer/api/endpoints/index.ts @@ -14,6 +14,7 @@ export * from "./events.api"; export * from "./hpa.api"; export * from "./ingress.api"; export * from "./job.api"; +export * from "./limit-range.api"; export * from "./namespaces.api"; export * from "./network-policy.api"; export * from "./nodes.api"; diff --git a/src/renderer/api/endpoints/limit-range.api.ts b/src/renderer/api/endpoints/limit-range.api.ts new file mode 100644 index 0000000000..bbb3941c87 --- /dev/null +++ b/src/renderer/api/endpoints/limit-range.api.ts @@ -0,0 +1,57 @@ +import { KubeObject } from "../kube-object"; +import { KubeApi } from "../kube-api"; +import { autobind } from "../../utils"; + +export enum LimitType { + CONTAINER = "Container", + POD = "Pod", + PVC = "PersistentVolumeClaim", +} + +export enum Resource { + MEMORY = "memory", + CPU = "cpu", + STORAGE = "storage", + EPHEMERAL_STORAGE = "ephemeral-storage", +} + +export enum LimitPart { + MAX = "max", + MIN = "min", + DEFAULT = "default", + DEFAULT_REQUEST = "defaultRequest", + MAX_LIMIT_REQUEST_RATIO = "maxLimitRequestRatio", +} + +type LimitRangeParts = Partial>>; + +export interface LimitRangeItem extends LimitRangeParts { + type: string +} + +@autobind() +export class LimitRange extends KubeObject { + static kind = "LimitRange"; + static namespaced = true; + static apiBase = "/api/v1/limitranges"; + + spec: { + limits: LimitRangeItem[]; + }; + + getContainerLimits() { + return this.spec.limits.filter(limit => limit.type === LimitType.CONTAINER); + } + + getPodLimits() { + return this.spec.limits.filter(limit => limit.type === LimitType.POD); + } + + getPVCLimits() { + return this.spec.limits.filter(limit => limit.type === LimitType.PVC); + } +} + +export const limitRangeApi = new KubeApi({ + objectConstructor: LimitRange, +}); diff --git a/src/renderer/components/+config-limit-ranges/index.ts b/src/renderer/components/+config-limit-ranges/index.ts new file mode 100644 index 0000000000..53308bdd18 --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/index.ts @@ -0,0 +1,3 @@ +export * from "./limit-ranges"; +export * from "./limit-ranges.route"; +export * from "./limit-range-details"; diff --git a/src/renderer/components/+config-limit-ranges/limit-range-details.scss b/src/renderer/components/+config-limit-ranges/limit-range-details.scss new file mode 100644 index 0000000000..ff39ec514a --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-range-details.scss @@ -0,0 +1,12 @@ +.LimitRangeDetails { + + .DrawerItem { + > .name { + font-weight: $font-weight-normal; + padding-left: 4px; + } + .DrawerItem { + padding-top: 4px; + } + } +} diff --git a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx new file mode 100644 index 0000000000..ab35505cc6 --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx @@ -0,0 +1,97 @@ +import "./limit-range-details.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { KubeObjectDetailsProps } from "../kube-object"; +import { LimitPart, LimitRange, LimitRangeItem, Resource } from "../../api/endpoints/limit-range.api"; +import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import { KubeObjectMeta } from "../kube-object/kube-object-meta"; +import { DrawerItem } from "../drawer/drawer-item"; +import { Badge } from "../badge"; + +interface Props extends KubeObjectDetailsProps { +} + +function renderLimit(limit: LimitRangeItem, part: LimitPart, resource: Resource) { + + const resourceLimit = limit[part]?.[resource]; + + if (!resourceLimit) { + return null; + } + + return ; +} + +function renderResourceLimits(limit: LimitRangeItem, resource: Resource) { + return ( + + {renderLimit(limit, LimitPart.MIN, resource)} + {renderLimit(limit, LimitPart.MAX, resource)} + {renderLimit(limit, LimitPart.DEFAULT, resource)} + {renderLimit(limit, LimitPart.DEFAULT_REQUEST, resource)} + {renderLimit(limit, LimitPart.MAX_LIMIT_REQUEST_RATIO, resource)} + + ); +} + +function renderLimitDetails(limits: LimitRangeItem[], resources: Resource[]) { + + return resources.map(resource => + + { + limits.map(limit => + renderResourceLimits(limit, resource) + ) + } + + ); +} + +@observer +export class LimitRangeDetails extends React.Component { + render() { + const { object: limitRange } = this.props; + + if (!limitRange) return null; + const containerLimits = limitRange.getContainerLimits(); + const podLimits = limitRange.getPodLimits(); + const pvcLimits = limitRange.getPVCLimits(); + + return ( +
+ + + {containerLimits.length > 0 && + + { + renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) + } + + } + {podLimits.length > 0 && + + { + renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) + } + + } + {pvcLimits.length > 0 && + + { + renderLimitDetails(pvcLimits, [Resource.STORAGE]) + } + + } +
+ ); + } +} + +kubeObjectDetailRegistry.add({ + kind: "LimitRange", + apiVersions: ["v1"], + components: { + Details: (props) => + } +}); diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.route.ts b/src/renderer/components/+config-limit-ranges/limit-ranges.route.ts new file mode 100644 index 0000000000..09e3052350 --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.route.ts @@ -0,0 +1,11 @@ +import type { RouteProps } from "react-router"; +import { buildURL } from "../../../common/utils/buildUrl"; + +export const limitRangesRoute: RouteProps = { + path: "/limitranges" +}; + +export interface LimitRangeRouteParams { +} + +export const limitRangeURL = buildURL(limitRangesRoute.path); diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.scss b/src/renderer/components/+config-limit-ranges/limit-ranges.scss new file mode 100644 index 0000000000..e5de19acfb --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.scss @@ -0,0 +1,7 @@ +.LimitRanges { + .TableCell { + &.warning { + @include table-cell-warning; + } + } +} diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts b/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts new file mode 100644 index 0000000000..bd760efadd --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.store.ts @@ -0,0 +1,12 @@ +import { autobind } from "../../../common/utils/autobind"; +import { KubeObjectStore } from "../../kube-object.store"; +import { apiManager } from "../../api/api-manager"; +import { LimitRange, limitRangeApi } from "../../api/endpoints/limit-range.api"; + +@autobind() +export class LimitRangesStore extends KubeObjectStore { + api = limitRangeApi; +} + +export const limitRangeStore = new LimitRangesStore(); +apiManager.registerStore(limitRangeStore); diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx new file mode 100644 index 0000000000..8bb498c1c0 --- /dev/null +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx @@ -0,0 +1,53 @@ +import "./limit-ranges.scss"; + +import { RouteComponentProps } from "react-router"; +import { observer } from "mobx-react"; +import { KubeObjectListLayout } from "../kube-object/kube-object-list-layout"; +import { limitRangeStore } from "./limit-ranges.store"; +import { LimitRangeRouteParams } from "./limit-ranges.route"; +import React from "react"; +import { KubeObjectStatusIcon } from "../kube-object-status-icon"; +import { LimitRange } from "../../api/endpoints/limit-range.api"; + +enum sortBy { + name = "name", + namespace = "namespace", + age = "age", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class LimitRanges extends React.Component { + render() { + return ( + item.getName(), + [sortBy.namespace]: (item: LimitRange) => item.getNs(), + [sortBy.age]: (item: LimitRange) => item.metadata.creationTimestamp, + }} + searchFilters={[ + (item: LimitRange) => item.getName(), + (item: LimitRange) => item.getNs(), + ]} + renderHeaderTitle={"Limit Ranges"} + renderTableHeader={[ + { title: "Name", className: "name", sortBy: sortBy.name }, + { className: "warning" }, + { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, + { title: "Age", className: "age", sortBy: sortBy.age }, + ]} + renderTableContents={(limitRange: LimitRange) => [ + limitRange.getName(), + , + limitRange.getNs(), + limitRange.getAge(), + ]} + /> + ); + } +} diff --git a/src/renderer/components/+config/config.tsx b/src/renderer/components/+config/config.tsx index bb70dd3fb5..e3158459ba 100644 --- a/src/renderer/components/+config/config.tsx +++ b/src/renderer/components/+config/config.tsx @@ -8,6 +8,7 @@ import { resourceQuotaRoute, ResourceQuotas, resourceQuotaURL } from "../+config import { pdbRoute, pdbURL, PodDisruptionBudgets } from "../+config-pod-disruption-budgets"; import { HorizontalPodAutoscalers, hpaRoute, hpaURL } from "../+config-autoscalers"; import { isAllowedResource } from "../../../common/rbac"; +import { LimitRanges, limitRangesRoute, limitRangeURL } from "../+config-limit-ranges"; @observer export class Config extends React.Component { @@ -42,6 +43,15 @@ export class Config extends React.Component { }); } + if (isAllowedResource("limitranges")) { + routes.push({ + title: "Limit Ranges", + component: LimitRanges, + url: limitRangeURL({ query }), + routePath: limitRangesRoute.path.toString(), + }); + } + if (isAllowedResource("horizontalpodautoscalers")) { routes.push({ title: "HPA", diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index f687630a96..5dfa93cea7 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -12,6 +12,7 @@ import { Spinner } from "../spinner"; import { resourceQuotaStore } from "../+config-resource-quotas/resource-quotas.store"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import { limitRangeStore } from "../+config-limit-ranges/limit-ranges.store"; interface Props extends KubeObjectDetailsProps { } @@ -24,8 +25,15 @@ export class NamespaceDetails extends React.Component { return resourceQuotaStore.getAllByNs(namespace); } + @computed get limitranges() { + const namespace = this.props.object.getName(); + + return limitRangeStore.getAllByNs(namespace); + } + componentDidMount() { resourceQuotaStore.loadAll(); + limitRangeStore.loadAll(); } render() { @@ -52,6 +60,16 @@ export class NamespaceDetails extends React.Component { ); })} + + {!this.limitranges && limitRangeStore.isLoading && } + {this.limitranges.map(limitrange => { + return ( + + {limitrange.getName()} + + ); + })} + ); } diff --git a/src/renderer/utils/rbac.ts b/src/renderer/utils/rbac.ts index 5f535c8109..36737ccf3a 100644 --- a/src/renderer/utils/rbac.ts +++ b/src/renderer/utils/rbac.ts @@ -25,4 +25,5 @@ export const ResourceNames: Record = { "horizontalpodautoscalers": "Horizontal Pod Autoscalers", "podsecuritypolicies": "Pod Security Policies", "poddisruptionbudgets": "Pod Disruption Budgets", + "limitranges": "Limit Ranges", }; From 83ed44f67011dfbd3387016212b74b268e815a30 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Fri, 15 Jan 2021 11:34:11 +0300 Subject: [PATCH 003/219] Adding logs tab bottom toolbar (#1951) * Adding bottom toolbar to logs tab Signed-off-by: Alex Andreev * Making bottom toolbar responsive Signed-off-by: Alex Andreev * Using generic search input clear button Signed-off-by: Alex Andreev * Fixing log test selectors Signed-off-by: Alex Andreev --- integration/__tests__/app.tests.ts | 14 +- src/extensions/renderer-api/components.ts | 2 +- src/renderer/components/checkbox/checkbox.tsx | 2 +- src/renderer/components/dock/dock-tabs.tsx | 4 +- src/renderer/components/dock/dock.tsx | 6 +- src/renderer/components/dock/info-panel.scss | 1 - .../components/dock/log-controls.scss | 6 + src/renderer/components/dock/log-controls.tsx | 68 ++++++++++ .../dock/{pod-log-list.scss => log-list.scss} | 2 +- .../dock/{pod-log-list.tsx => log-list.tsx} | 16 ++- ...ntrols.scss => log-resource-selector.scss} | 2 +- .../components/dock/log-resource-selector.tsx | 66 +++++++++ .../{pod-log-search.scss => log-search.scss} | 7 +- .../{pod-log-search.tsx => log-search.tsx} | 13 +- .../dock/{pod-logs.store.ts => log.store.ts} | 8 +- .../dock/{pod-logs.tsx => logs.tsx} | 70 ++++++---- .../components/dock/pod-log-controls.tsx | 126 ------------------ 17 files changed, 223 insertions(+), 190 deletions(-) create mode 100644 src/renderer/components/dock/log-controls.scss create mode 100644 src/renderer/components/dock/log-controls.tsx rename src/renderer/components/dock/{pod-log-list.scss => log-list.scss} (99%) rename src/renderer/components/dock/{pod-log-list.tsx => log-list.tsx} (94%) rename src/renderer/components/dock/{pod-log-controls.scss => log-resource-selector.scss} (62%) create mode 100644 src/renderer/components/dock/log-resource-selector.tsx rename src/renderer/components/dock/{pod-log-search.scss => log-search.scss} (61%) rename src/renderer/components/dock/{pod-log-search.tsx => log-search.tsx} (85%) rename src/renderer/components/dock/{pod-logs.store.ts => log.store.ts} (96%) rename src/renderer/components/dock/{pod-logs.tsx => logs.tsx} (62%) delete mode 100644 src/renderer/components/dock/pod-log-controls.tsx diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index d68ab2f011..7182a13107 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -505,16 +505,16 @@ describe("Lens integration tests", () => { await app.client.waitForVisible(".Drawer"); await app.client.click(".drawer-title .Menu li:nth-child(2)"); // Check if controls are available - await app.client.waitForVisible(".PodLogs .VirtualList"); - await app.client.waitForVisible(".PodLogControls"); - await app.client.waitForVisible(".PodLogControls .SearchInput"); - await app.client.waitForVisible(".PodLogControls .SearchInput input"); + await app.client.waitForVisible(".Logs .VirtualList"); + await app.client.waitForVisible(".LogResourceSelector"); + await app.client.waitForVisible(".LogResourceSelector .SearchInput"); + await app.client.waitForVisible(".LogResourceSelector .SearchInput input"); // Search for semicolon await app.client.keys(":"); - await app.client.waitForVisible(".PodLogs .list span.active"); + await app.client.waitForVisible(".Logs .list span.active"); // Click through controls - await app.client.click(".PodLogControls .timestamps-icon"); - await app.client.click(".PodLogControls .undo-icon"); + await app.client.click(".LogControls .show-timestamps"); + await app.client.click(".LogControls .show-previous"); }); }); diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 68dd5d6510..a9a519498b 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -38,4 +38,4 @@ export * from "../../renderer/components/+events/kube-event-details"; // specific exports export * from "../../renderer/components/status-brick"; export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store"; -export { createPodLogsTab } from "../../renderer/components/dock/pod-logs.store"; +export { createPodLogsTab } from "../../renderer/components/dock/log.store"; diff --git a/src/renderer/components/checkbox/checkbox.tsx b/src/renderer/components/checkbox/checkbox.tsx index 0831e6122f..f97740a874 100644 --- a/src/renderer/components/checkbox/checkbox.tsx +++ b/src/renderer/components/checkbox/checkbox.tsx @@ -30,7 +30,7 @@ export class Checkbox extends React.PureComponent { render() { const { label, inline, className, value, theme, children, ...inputProps } = this.props; - const componentClass = cssNames("Checkbox flex", className, { + const componentClass = cssNames("Checkbox flex align-center", className, { inline, checked: value, disabled: this.props.disabled, diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx index 54451ddd89..6bf9280d59 100644 --- a/src/renderer/components/dock/dock-tabs.tsx +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -7,7 +7,7 @@ import { DockTab } from "./dock-tab"; import { IDockTab } from "./dock.store"; import { isEditResourceTab } from "./edit-resource.store"; import { isInstallChartTab } from "./install-chart.store"; -import { isPodLogsTab } from "./pod-logs.store"; +import { isLogsTab } from "./log.store"; import { TerminalTab } from "./terminal-tab"; import { isTerminalTab } from "./terminal.store"; import { isUpgradeChartTab } from "./upgrade-chart.store"; @@ -33,7 +33,7 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: Props) = return } />; } - if (isPodLogsTab(tab)) { + if (isLogsTab(tab)) { return ; } }; diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index f02502eab7..c8adf82992 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -16,8 +16,8 @@ import { EditResource } from "./edit-resource"; import { isEditResourceTab } from "./edit-resource.store"; import { InstallChart } from "./install-chart"; import { isInstallChartTab } from "./install-chart.store"; -import { PodLogs } from "./pod-logs"; -import { isPodLogsTab } from "./pod-logs.store"; +import { Logs } from "./logs"; +import { isLogsTab } from "./log.store"; import { TerminalWindow } from "./terminal-window"; import { createTerminalTab, isTerminalTab } from "./terminal.store"; import { UpgradeChart } from "./upgrade-chart"; @@ -64,7 +64,7 @@ export class Dock extends React.Component { {isInstallChartTab(tab) && } {isUpgradeChartTab(tab) && } {isTerminalTab(tab) && } - {isPodLogsTab(tab) && } + {isLogsTab(tab) && } ); } diff --git a/src/renderer/components/dock/info-panel.scss b/src/renderer/components/dock/info-panel.scss index 23dcc52243..482dbee02d 100644 --- a/src/renderer/components/dock/info-panel.scss +++ b/src/renderer/components/dock/info-panel.scss @@ -2,7 +2,6 @@ @include hidden-scrollbar; background: $dockInfoBackground; - border-bottom: 1px solid $dockInfoBorderColor; padding: $padding $padding * 2; flex-shrink: 0; diff --git a/src/renderer/components/dock/log-controls.scss b/src/renderer/components/dock/log-controls.scss new file mode 100644 index 0000000000..e446cca235 --- /dev/null +++ b/src/renderer/components/dock/log-controls.scss @@ -0,0 +1,6 @@ +.LogControls { + @include hidden-scrollbar; + + background: $dockInfoBackground; + padding: $padding $padding * 2; +} \ No newline at end of file diff --git a/src/renderer/components/dock/log-controls.tsx b/src/renderer/components/dock/log-controls.tsx new file mode 100644 index 0000000000..cedff7fbb9 --- /dev/null +++ b/src/renderer/components/dock/log-controls.tsx @@ -0,0 +1,68 @@ +import "./log-controls.scss"; + +import React from "react"; +import { observer } from "mobx-react"; + +import { Pod } from "../../api/endpoints"; +import { cssNames, saveFileDialog } from "../../utils"; +import { IPodLogsData, podLogsStore } from "./log.store"; +import { Checkbox } from "../checkbox"; +import { Icon } from "../icon"; + +interface Props { + tabData: IPodLogsData + logs: string[] + save: (data: Partial) => void + reload: () => void +} + +export const LogControls = observer((props: Props) => { + const { tabData, save, reload, logs } = props; + const { showTimestamps, previous } = tabData; + const since = logs.length ? podLogsStore.getTimestamps(logs[0]) : null; + const pod = new Pod(tabData.pod); + + const toggleTimestamps = () => { + save({ showTimestamps: !showTimestamps }); + }; + + const togglePrevious = () => { + save({ previous: !previous }); + reload(); + }; + + const downloadLogs = () => { + const fileName = pod.getName(); + const logsToDownload = showTimestamps ? logs : podLogsStore.logsWithoutTimestamps; + + saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); + }; + + return ( +
+
+ {since && `Logs from ${new Date(since[0]).toLocaleString()}`} +
+
+ + + +
+
+ ); +}); diff --git a/src/renderer/components/dock/pod-log-list.scss b/src/renderer/components/dock/log-list.scss similarity index 99% rename from src/renderer/components/dock/pod-log-list.scss rename to src/renderer/components/dock/log-list.scss index 9b923b520b..8a39dcf925 100644 --- a/src/renderer/components/dock/pod-log-list.scss +++ b/src/renderer/components/dock/log-list.scss @@ -1,4 +1,4 @@ -.PodLogList { +.LogList { --overlay-bg: #8cc474b8; --overlay-active-bg: orange; diff --git a/src/renderer/components/dock/pod-log-list.tsx b/src/renderer/components/dock/log-list.tsx similarity index 94% rename from src/renderer/components/dock/pod-log-list.tsx rename to src/renderer/components/dock/log-list.tsx index c876d0c362..74d64d2f58 100644 --- a/src/renderer/components/dock/pod-log-list.tsx +++ b/src/renderer/components/dock/log-list.tsx @@ -1,4 +1,4 @@ -import "./pod-log-list.scss"; +import "./log-list.scss"; import React from "react"; import AnsiUp from "ansi_up"; @@ -14,7 +14,7 @@ import { Button } from "../button"; import { Icon } from "../icon"; import { Spinner } from "../spinner"; import { VirtualList } from "../virtual-list"; -import { podLogsStore } from "./pod-logs.store"; +import { podLogsStore } from "./log.store"; interface Props { logs: string[] @@ -26,7 +26,7 @@ interface Props { const colorConverter = new AnsiUp(); @observer -export class PodLogList extends React.Component { +export class LogList extends React.Component { @observable isJumpButtonVisible = false; @observable isLastLineVisible = true; @@ -206,19 +206,23 @@ export class PodLogList extends React.Component { const rowHeights = new Array(this.logs.length).fill(this.lineHeight); if (isInitLoading) { - return ; + return ( +
+ +
+ ); } if (!this.logs.length) { return ( -
+
There are no logs available for container
); } return ( -
+
) => void + reload: () => void +} + +export const LogResourceSelector = observer((props: Props) => { + const { tabData, save, reload } = props; + const { selectedContainer, containers, initContainers } = tabData; + const pod = new Pod(tabData.pod); + + const onContainerChange = (option: SelectOption) => { + const { containers, initContainers } = tabData; + + save({ + selectedContainer: containers + .concat(initContainers) + .find(container => container.name === option.value) + }); + reload(); + }; + + const getSelectOptions = (containers: IPodContainer[]) => { + return containers.map(container => { + return { + value: container.name, + label: container.name + }; + }); + }; + + const containerSelectOptions = [ + { + label: `Containers`, + options: getSelectOptions(containers) + }, + { + label: `Init Containers`, + options: getSelectOptions(initContainers), + } + ]; + + return ( +
+ Namespace + Pod + Container + -
- {since && ( - <> - Since{" "} - {new Date(since[0]).toLocaleString()} - - )} -
-
- - - - -
-
- ); -}); From 0117cecc332dfd016718b773dd948ac2feb9a990 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Fri, 15 Jan 2021 16:11:41 +0200 Subject: [PATCH 004/219] Prevent initializing clusters multiple times (#1950) * Prevent initializing clusters multiple times Signed-off-by: Lauri Nevala * Do not expose intializing to cluster state Signed-off-by: Lauri Nevala * Convert initializing to observable and ensure it is set to false after init Signed-off-by: Lauri Nevala --- src/main/cluster-manager.ts | 2 +- src/main/cluster.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 5717c7278d..1b468e3bb6 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -14,7 +14,7 @@ export class ClusterManager extends Singleton { // auto-init clusters autorun(() => { clusterStore.enabledClustersList.forEach(cluster => { - if (!cluster.initialized) { + if (!cluster.initialized && !cluster.initializing) { logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta()); cluster.init(port); } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index b9ff62e8ac..c6c14f6406 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -84,6 +84,14 @@ export class Cluster implements ClusterModel, ClusterState { whenInitialized = when(() => this.initialized); whenReady = when(() => this.ready); + /** + * Is cluster object initializinng on-going + * + * @observable + */ + @observable initializing = false; + + /** * Is cluster object initialized * @@ -273,6 +281,7 @@ export class Cluster implements ClusterModel, ClusterState { */ @action async init(port: number) { try { + this.initializing = true; this.contextHandler = new ContextHandler(this); this.kubeconfigManager = await KubeconfigManager.create(this, this.contextHandler, port); this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`; @@ -287,6 +296,8 @@ export class Cluster implements ClusterModel, ClusterState { id: this.id, error: err, }); + } finally { + this.initializing = false; } } From dfe6d72505e798486f5e8429827745ac9f64b27d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 15 Jan 2021 16:12:49 +0200 Subject: [PATCH 005/219] Bump ini from 1.3.5 to 1.3.8 (#1760) Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8. - [Release notes](https://github.com/isaacs/ini/releases) - [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 97eae9dc25..27d8c541ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6854,9 +6854,9 @@ inherits@2.0.3: integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== init-package-json@^1.10.3: version "1.10.3" From 3062fbe44af9b0cb2a89f2e3c64a2ccc70df3a24 Mon Sep 17 00:00:00 2001 From: pauljwil Date: Fri, 15 Jan 2021 09:40:19 -0800 Subject: [PATCH 006/219] Fix Electron 9.4 frame ipc bug (#1888) (#1789) * use pid+frameId Signed-off-by: Jari Kolehmainen * use correct process id Signed-off-by: Jari Kolehmainen Signed-off-by: Paul Williams Co-authored-by: Jari Kolehmainen --- .../guides/images/clusterpagemenus.png | Bin 0 -> 257440 bytes .../guides/images/globalpagemenus.png | Bin 0 -> 148787 bytes docs/extensions/guides/renderer-extension.md | 368 +++++++++--------- mkdocs.yml | 2 + 4 files changed, 195 insertions(+), 175 deletions(-) create mode 100644 docs/extensions/guides/images/clusterpagemenus.png create mode 100644 docs/extensions/guides/images/globalpagemenus.png diff --git a/docs/extensions/guides/images/clusterpagemenus.png b/docs/extensions/guides/images/clusterpagemenus.png new file mode 100644 index 0000000000000000000000000000000000000000..3ed1c79e5b96c28fefe9298caa2d4a60ccf540dc GIT binary patch literal 257440 zcmd3OcT|&m)@~5#AbL1RQxFRrL7Md5R1^eMq<4@SiW2E1paLq*21o}L3lKsJ5K2H% zIwaD2k&-~@ErgKdzVV!yZ)U!G*Wk=Q_Yc;}f{^!D_I~!>&wh5E+%VGPVC828fj}JB zu3owY0pAvp` z?#7j4bB}&I{p744qrmm_aTe9`^3{+pE4(va4`+skWM{ORU%Nk?)vx^a z;r7XbepU%n1N-7aTUddK>4$FO{lY%V!T^=npougHl4g8S`ZKQsh@Q6*#Q5{K2YC6JeLv?agT1n3o~Jx!v{7K0<(d=i-y->$1A4!x__)f@ zKK=k7^w&e>wLs~tL&snsqi4@go%y+pt#2U3g0YW3Klb1Y^I>Gx;))ajozzt-m72b^ z^O=KD`sg)!A4SO`?EB-tv`5}kAVW|(NRH0^$(_f~ow;6o12OJ7MP}Ugx%G>?Q=~tf z%kpzsh9G|0DPh^G$E!=SGpv5)jMyC_X^T08IV_~k6Sic4u-M6Kmu8GfrSrLkyap`p zpqEEG^D*|Lz$yK;KiVp?Wv2pWzJ&OA{^H(%<*STp!g`_(E#4Eo=w4yH8{9 z{=>WbxhmRj(0q4m?>VX~B7Vg*f%pDAq+Aw_5Xs%4RL{~S-DF30b}9O?cYZIQ{*qa; zSM&Yd2o7Ort59=mTyBou9_#zXz5QhXyEx?3ndGoo^K?qBniyRv}){$-rGGy@9ijvP&3!y`ZaqFMgwyoPJvA)occh)3Z1s7}$% zVV?hAuFl!M;9D7|X^f0?9sAvv?S^8%Uzl0>Cha_#26d@^Rr8Q3fg*~rTG%2Cgzy|JLTlzqMNhBiOuYaGi%E%3R7KE5|`;` zwKhoz2r_-^qLF6I&R+bV+n{<8J!Uxgo0&Ay>PdCGmmuHIuXk|7tLk*cpBl5#T4$f)C0 z&HHm2EmH7>0<~p?mM~d(7Vz{-|Al|4d(mS$2EEF4+OK`$)0mz$RD}+FfP?vuU%SH=HHg+rJ}*WaGIqpw-IX!;+;nLLhnZnFkA)9%Onh2e{PQD#Sa*vePmAI(s zyDz`VKSF(X4dWF$cw~`YW4z^xjp=b1noO1jyQz% z&eh9?6 z-K5K)^c$bdb@R8_GQK;V8u2Z`LD7n%@yDDW9USKwC$zk0UI=a`j);u3WCJLMbL5L0 zVD0ALh)Xb7T8}P@nlr@*>R$`aIvFw$4q?-GS%1pmxa@nV&-EH8{h3F_qw5QzX7!#w zA5U$Z%#?i}fBxXWfE#Z7!7UKlsqUHKE@VIpr?D=e8iY5D+qJv(JTph>lcWSn8CEuB5=7VGRR(IpWx6SQe z&&%30b#Nx~i{dL+Skf~3ir!X!uk@LLV=)kG-aw|fvncf#f2jsCo(wvvv9r;mvGd;w zVXG%$-gq@guesH*`?kmUKz;UtN%hRIN$vg*F;eKZ?%Nhl;x-4ibj3q*Xrfj~hfk`R z?O=P_1Lsy3O|%7gh#N&K&U^1#+B+qVsaLv+ZriwK z@TAh>GG_sqzAb5B?qBb2I7+#|bOr>=Q|N6e z*c*rh`>}DCWj6x1e6(HbNuRs8J7|donZ8u(8)9+hUbuebgRC6DwtH{TnN&_U z6*6A$-*2yBP)FHc+2gMZnfblQ{%|f6!8JmOA=j<>!?h4s)Y*L3;GNiLhj~lI>Uloj zW`Tp z=FsNZ)_H!(|Cw=9Ph1KNznQ@R{6q2cBe8yA@f;O&ToDSZFx`_665k61(hgX+`7W#0 z;5c!)qjCq9O?DJcw~O2IziS^CuQ-!%5Wja=WB)4Zwxx`A-_Q!TT}0L^4bto&R0E_g zF5J&5jg}~2#xvMV*7bP`@#OU2r^=zZ(&ANDtx}6w@O9maOaSJakR{gTMe4j7HTR2F z-8RJ*No2v19PrK$!-O>aa%zk9ns=DA)rQ6jNXC56U{QjNWus4&+B_&mGv78iZctp4}kbFn_ z=Putsq283?phbg|0ple>_@4X&~tENr;;u|+`ho|Of6E~y8q4v&GA zZ_Hb6*81QD@nhnHs}=KhSZNDrsOkVq8{f_l9J$%_ZO@yu_s^I}uCFb-n-VKfAC!MiXVXG~Xr)#5Z@ zhALu*Fnfmz3alL%GTgfLQ|1B>-K0qAkR_t8K03Vp`pJ37lJf_*S$$_b>!4zkJEs=< zaY8B(Cp@gGdsrJ_7FA8G5vk^i`d5si)Q|W$+nP3L0moP&$Z~BVa9-lt25U!=zk!+8 z@9OW(`iqOKPrXkVUC+cLd?9h|`KaJe+>1ST$9qgO-sSltUzy1d^?%7Ct!1uBGaljy zQr<7x+T{?Lj`+7X2yDo#PLZJsx-{EIgeD(eXkR z-9=dV+E*00Lm_3rXI{<^3EP)9SbBnNA5P8V9g?4sMyN8*qEC>FkT9ytJK9vcIjRze%IoVnmHy6*#i){kR@?D;;EveU*OvCWP*=%MVKx;@l*JkX7>#! zt-QY9cQ|)DI^EvJBif@^cH;qR_xJ_dUT38$xb{_bsBQ0W*Ni$U(9cy=t!YUv&OnYS zx7;u8U3il--QaB4+dgn&fBrufJe9Vo+SDOFuyPcZskZRl z;)%i4DlskJvG3%Fs=JmBAe8{K0#gt3$q)BQMPG-_{AWMO{>aaLH*(}O__0+XXU1lF zem1(U#Ut7UrlS_JbT;_DRp#vh#K)9OgR$Yjo`UlFb z?@L8Gg^OiocG+2`4pimeVUgZ@?kSXMM~+84DFaW^ zMo*4$d;y#KmczlrQ_xh^l-pR0Y!8P%nZeNs16?op0^cir9cchJhF zMsA7FY1!L1k2pB%>Ucq)*u`(CdKH0vLak%XM%SOTqSI%2@32Qgq5y1plIK zUjIOyK^38H=X5N~dA^+>@awUnKuJS?ce2K!rdv1YehC?|S4EMJk<=x*I6a=E+|PcA zo!FgD33fPG1{i;VK0U(%$n&$#VW^X$}QG_c1dz&e(-Dj9cP+?v1YJ>w+)?DzsEpQY48 zvtZGh6h3ZcJ4~GKA3$BM=&Ahl?nLUd(=Mh78gw2U5149=+(08IObQ|3$I7m*5Bxj7 zz8b#OThfXR^5L$V`C)RW>-~5DRW8id#tdwlGO!62k}YGqGrTAfAfWDRr)`O$FyLkJ zVY+4UEo&!|+*vdW8$P3iSpzg+DP9vzWgGLzolh=p{DoOikP5uy%LFvU>kXdrp0y+< zU+6@?ErXS~D4w{jZYnO#gnt`TWGFQAQg%s!l4rxwTs*$TWC#yTWEGtkx+J;@BD$X;m9iX_zb0f3Uz;;N>EMZ`p|i|i+LBFU+KSs zr`n+B8j?~`wB$}@5s0d%!FCnxM=I0XqiZG(zddtoB2&3g_0%j?$JeT&hn0}N>FF@2 zmel8WiXKyX?@5^_*@)CtM5_~T9~mHw?cb`+GRwGb)WuwFMg`sP_^}hIFhAsPXs|Tn z-{t71HJZ5}3|v%z&c;%tLP%ZOdRv*__>o#~e2={LXd0yaM7FT~y!>w66_Z^{EQU?d zU>H(687j!fV<9oTRT_NHoMC*R(qDr-pjCl_OIRY?-0FwX7Z5MXh^UJIYS((Z*cKw} z8qjH#pHukz2G+{UL*TNn3ZZ3RZ*TZc?p7gi}YqWK;(P0l*FO}PWFU&*`{yvM+6oVps28}0K6I^!9b^S zb215DzpX2|`8n)h(Uij2Ydt*X=ipV`@n~C znzm?m*-c`j!%a+1sXT<``HYPn!Ym62mQN(!P-mSvvhWYlZ*s=jTD1wLYcD(pwmXS@R^SBfnF=jhT9RxL#-pQ7N};Fn^rE~6o?$iMUTDl6Q5E6 z3Ig|Y(^q4azyds@bZ5BaSSv5UJraZL883pC+2sJH3}3nm&{a$KEJ_1HwL;xMDh<^a z9cm%jPOc)4&0Z+XytclAX8-5VrzHGEb0>6~KmL=hir>7h7y0 zCk%ygSI0m*JFoo>y$3?k8}g$|S{^0N)eO0cD?`4^_E!D8eo$<}Cs~~5gh5;fUUx;P z$XMhRc*45t7F6b|<@ph$ua7p8YzH18RGa`}{CHLNAxv8Exi`+!)IY=u%ME!+PN>$0JvKsoO(9rto`KAeLaU{4!$oJlx|skU%nQ6-qgK7oWckQW25-CYbc@ zD0k_$#rbl`a%GayYH_Zh56DV)CnO6fBiO4odd*YDHML}p$20u)DDIrZcx;SqridC~)f?evw zaYH(HSoae9DJ+lqmQtbz-?(-m$0&iJLNQN0++^uI2nHz!N*q!`#m5SfsUTWaupH7k zf4T5%UaVL@y*&0r?A@)qvPUI^ad&;3PLE$Hq%MXUk(yg{CpwV~x#mUVj?4C@4broe zZdOcrIqOoT_~3HI&R|0jW)$on6@Cc66wTQrYNx*`m=oUqp%5wwd_ZWv|iHQUy{htCsQW zF|zIzfcU@%;Y!P}-Q_JlKVI?HdRNoo-bWV}kr9j43jzia7A*&;sc0u0)De07a<5KA zy5rqYce>>$%+O#`O|k~nG>|z1By9;(A4@Na*0!!#d(ZrqQ@YS$kpYQj)xrl4`i{C~RRI}0 zHq#Ya-ba)poPK-7xMVa&Akqgs>9lM=S=Ko*cp@XeD&n<^#%s<4G1q5$wXB+LzMl>f z5t^#BsdY(m|BlGcbQkc(2b?IkLw~}qEClJ6X8lBIP5`B09@F|cAfL1JaeLV5uDDTe zK0`WO{L%wKc+0X%*WPd%B)nPkH#+yM-kx@%Mt?yU(I4r$=_7ttT~vJXgVghi#!?nN zy>dEuY;Nq2_pQ{-C!Z{LNN+U=lRhW_BhiIRtoc4emBxT)&(}b=_b(3w%PpzwJ%19Q zO168m>RYI|{2T15S2xzilVLtR=G&XRvDekV*Cw#c758XcpZc2?V6yxymPZ*B^vIG4G^pX-G}bwWM-c^$Gv$uX*pmb{Q)MJP1%XhB97f{Fau=wjK6;V_R4k!yjQ+5%w^vm)ZY9xy0=h=Z~GV z?y@@28`(Q84)@@HH3e>ZFsF`MdwU*~^->ewf|v1i^6BWz)X6DLx(%{wdjF{$a!LFK z9LV#)4LtCnQ&z(&=}n)uvhHp>@UHnWhkW#j&%}niZw1LTY`x*e%DXR)6{8W0C5)Nd z%KS*(xRqG_ZziyO-Og_B#ExL_h)rkjCis+E-gA3KLMP8W(+@ zjyFrZQgvNAt9qeOdxGWVhfxwv263pwE^a5Z=WVIRXs7aa&DQ&!X_;|H@9}|2P=%oG z!?BPE4xgk9Y5A#rXVL2j5`1}I96~@)_cES_sD)SC26~vjMA*_VxL&wFJXksqE?-xS z^_pCJjh5+J2WX~YvVK~YKt&f&d)9VkF6rbbh=5>p*y>03bG+wIxb)j26U>g8Z|^y3bpH8{Vq`^2Lcg+xKCQ*YgxaEItLeb4Gq$6d zSa^ZGyZ7L;`#WR7Z}Pm#ZJSDgx{iGQ#mM{oosjVrpqBThArGMwNJZb6zUz{aKMI?6 zUM}B}kU7_j3ht+PoLk!ANU|Q4w*l|;&S@Ra70Np9cG4ORH%&kkAQ;hl3E2(_sh=Ku zHTP+P#s?`$e5%8OT=hpdQZkS|?L7-=#GRbyd>=~(WFUK2ueOym_flgSvd+}oku^Th zxjAdWZJ~e2^k<0Ut-|MZlY9?hY8tx1x2!5{4Q&da@}%~U&Wx8hTyCUv6X%GnB;j;r zx9$V!DT11`jI`6pRDz-F8^sPpM$XajEmt9G

izn5IAov0%sraFpjcrqJw~U5)2U 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 literal 0 HcmV?d00001 diff --git a/docs/extensions/guides/images/globalpagemenus.png b/docs/extensions/guides/images/globalpagemenus.png new file mode 100644 index 0000000000000000000000000000000000000000..e986cc32e9ba8aa3852bdb73f66d6fabb926e364 GIT binary patch literal 148787 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 literal 0 HcmV?d00001 diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index f64cb5fd49..c3ff953b2a 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -1,13 +1,26 @@ # Renderer Extension -The renderer extension api is the interface to Lens's renderer process (Lens runs in main and renderer processes). -It allows you to access, configure, and customize Lens data, add custom Lens UI elements, and generally run custom code in Lens's renderer process. -The custom Lens UI elements that can be added include global pages, cluster pages, cluster page menus, cluster features, app preferences, status bar items, KubeObject menu items, and KubeObject details items. -These UI elements are based on React components. +The Renderer Extension API is the interface to Lens's renderer process. Lens runs in both the main and renderer processes. The Renderer Extension API allows you to access, configure, and customize Lens data, add custom Lens UI elements, and run custom code in Lens's renderer process. + +The custom Lens UI elements that you can add include: + +* [Cluster pages](#clusterpages) +* [Cluster page menus](#clusterpagemenus) +* [Global pages](#globalpages) +* [Global page menus](#globalpagemenus) +* [Cluster features](#clusterfeatures) +* [App preferences](#apppreferences) +* [Status bar items](#statusbaritems) +* [KubeObject menu items](#kubeobjectmenuitems) +* [KubeObject detail items](#kubeobjectdetailitems) + +All UI elements are based on React components. ## `LensRendererExtension` Class -To create a renderer extension simply extend the `LensRendererExtension` class: +### `onActivate()` and `onDeactivate()` Methods + +To create a renderer extension, extend the `LensRendererExtension` class: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -23,21 +36,21 @@ export default class ExampleExtensionMain extends LensRendererExtension { } ``` -There are two methods that you can implement to facilitate running your custom code. -`onActivate()` is called when your extension has been successfully enabled. -By implementing `onActivate()` you can initiate your custom code. -`onDeactivate()` is called when the extension is disabled (typically from the [Lens Extensions Page]()) and when implemented gives you a chance to clean up after your extension, if necessary. -The example above simply logs messages when the extension is enabled and disabled. +Two methods enable you to run custom code: `onActivate()` and `onDeactivate()`. Enabling your extension calls `onActivate()` and disabling your extension calls `onDeactivate()`. You can initiate custom code by implementing `onActivate()`. Implementing `onDeactivate()` gives you the opportunity to clean up after your extension. + +!!! info + Disable extensions from the Lens Extensions page: + + 1. Navigate to **File** > **Extensions** in the top menu bar. (On Mac, it is **Lens** > **Extensions**.) + 2. Click **Disable** on the extension you want to disable. + +The example above logs messages when the extension is enabled and disabled. ### `clusterPages` -Cluster pages appear as part of the cluster dashboard. -They are accessible from the side bar, and are shown in the menu list after *Custom Resources*. -It is conventional to use a cluster page to show information or provide functionality pertaining to the active cluster, along with custom data and functionality your extension may have. -However, it is not limited to the active cluster. -Also, your extension can gain access to the Kubernetes resources in the active cluster in a straightforward manner using the [`clusterStore`](../stores#clusterstore). +Cluster pages appear in the cluster dashboard. Use cluster pages to display information about or add functionality to the active cluster. It is also possible to include custom details from other clusters. Use your extension to access Kubernetes resources in the active cluster with [`clusterStore`](../stores#clusterstore). -The following example adds a cluster page definition to a `LensRendererExtension` subclass: +Add a cluster page definition to a `LensRendererExtension` subclass with the following example: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -56,12 +69,13 @@ export default class ExampleExtension extends LensRendererExtension { } ``` -Cluster pages are objects matching the `PageRegistration` interface. -The `id` field identifies the page, and at its simplest is just a string identifier, as shown in the example above. -The 'id' field can also convey route path details, such as variable parameters provided to a page ([See example below]()). -The `components` field matches the `PageComponents` interface for wich there is one field, `Page`. -`Page` is of type ` React.ComponentType`, which gives you great flexibility in defining the appearance and behaviour of your page. -For the example above `ExamplePage` can be defined in `page.tsx`: +`clusterPages` is an array of objects that satisfy the `PageRegistration` interface. The properties of the `clusterPages` array objects are defined as follows: + +* `id` is a string that identifies the page. +* `components` matches the `PageComponents` interface for which there is one field, `Page`. +* `Page` is of type ` React.ComponentType`. It offers flexibility in defining the appearance and behavior of your page. + +`ExamplePage` in the example above can be defined in `page.tsx`: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -78,14 +92,15 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens } ``` -Note that the `ExamplePage` class defines a property named `extension`. -This allows the `ExampleExtension` object to be passed in React-style in the cluster page definition, so that `ExamplePage` can access any `ExampleExtension` subclass data. +Note that the `ExamplePage` class defines the `extension` property. This allows the `ExampleExtension` object to be passed in the cluster page definition in the React style. This way, `ExamplePage` can access all `ExampleExtension` subclass data. + +The above example shows how to create a cluster page, but not how to make that page available to the Lens user. Use `clusterPageMenus`, covered in the next section, to add cluster pages to the Lens UI. ### `clusterPageMenus` -The above example code shows how to create a cluster page but not how to make it available to the Lens user. -Cluster pages are typically made available through a menu item in the cluster dashboard sidebar. -Expanding on the above example a cluster page menu is added to the `ExampleExtension` definition: +`clusterPageMenus` allows you to add cluster page menu items to the secondary left nav. + +By expanding on the above example, you can add a cluster page menu item to the `ExampleExtension` definition: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -114,14 +129,16 @@ export default class ExampleExtension extends LensRendererExtension { } ``` -Cluster page menus are objects matching the `ClusterPageMenuRegistration` interface. -They define the appearance of the cluster page menu item in the cluster dashboard sidebar and the behaviour when the cluster page menu item is activated (typically by a mouse click). -The example above uses the `target` field to set the behaviour as a link to the cluster page with `id` of `"hello"`. -This is done by setting `target`'s `pageId` field to `"hello"`. -The cluster page menu item's appearance is defined by setting the `title` field to the text that is to be displayed in the cluster dashboard sidebar. -The `components` field is used to set an icon that appears to the left of the `title` text in the sidebar. -Thus when the `"Hello World"` menu item is activated the cluster dashboard will show the contents of `ExamplePage`. -This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`: +`clusterPageMenus` is an array of objects that satisfy the `ClusterPageMenuRegistration` interface. This element defines how the cluster page menu item will appear and what it will do when you click it. The properties of the `clusterPageMenus` array objects are defined as follows: + +* `target` links to the relevant cluster page using `pageId`. +* `pageId` takes the value of the relevant cluster page's `id` property. +* `title` sets the name of the cluster page menu item that will appear in the left side menu. +* `components` is used to set an icon that appears to the left of the `title` text in the left side menu. + +The above example creates a menu item that reads **Hello World**. When users click **Hello World**, the cluster dashboard will show the contents of `Example Page`. + +This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`, as follows: ``` typescript import { LensRendererExtension, Component } from "@k8slens/extensions"; @@ -142,14 +159,13 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens } ``` -`ExampleIcon` introduces one of Lens's built-in components available to extension developers, the `Component.Icon`. -Built in are the [Material Design](https://material.io) [icons](https://material.io/resources/icons/). -One can be selected by name via the `material` field. -`ExampleIcon` also sets a tooltip, shown when the Lens user hovers over the icon with a mouse, by setting the `tooltip` field. +Lens includes various built-in components available for extension developers to use. One of these is the `Component.Icon`, introduced in `ExampleIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io). The properties that `Component.Icon` uses are defined as follows: + +* `material` takes the name of the icon you want to use. +* `tooltip` sets the text you want to appear when a user hovers over the icon. + +`clusterPageMenus` can also be used to define sub menu items, so that you can create groups of cluster pages. The following example groups two sub menu items under one parent menu item: -A cluster page menu can also be used to define a foldout submenu in the cluster dashboard sidebar. -This enables the grouping of cluster pages. -The following example shows how to specify a submenu having two menu items: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -200,24 +216,17 @@ export default class ExampleExtension extends LensRendererExtension { } ``` -The above defines two cluster pages and three cluster page menu objects. -The cluster page definitons are straightforward. -The first cluster page menu object defines the parent of a foldout submenu. -Setting the `id` field in a cluster page menu definition implies that it is defining a foldout submenu. -Also note that the `target` field is not specified (it is ignored if the `id` field is specified). -This cluster page menu object specifies the `title` and `components` fields, which are used in displaying the menu item in the cluster dashboard sidebar. -Initially the submenu is hidden. -Activating this menu item toggles on and off the appearance of the submenu below it. -The remaining two cluster page menu objects define the contents of the submenu. -A cluster page menu object is defined to be a submenu item by setting the `parentId` field to the id of the parent of a foldout submenu, `"example"` in this case +The above defines two cluster pages and three cluster page menu objects. The three cluster page menu objects include one parent menu item and two sub menu items. Parent items require an `id` value, whereas sub items require a `parentId` value. The value of the sub item `parentId` will match the value of the corresponding parent item `id`. Parent items don't require a `target` value. Assign values to the remaining properties as explained above. + +This is what the example will look like, including how the menu item will appear in the secondary left nav: + +![clusterPageMenus](images/clusterpagemenus.png) ### `globalPages` -Global pages appear independently of the cluster dashboard and they fill the Lens UI space. -A global page is typically triggered from the cluster menu using a [global page menu](#globalpagemenus). -They can also be triggered by a [custom app menu selection](../main-extension#appmenus) from a Main Extension or a [custom status bar item](#statusbaritems). -Global pages can appear even when there is no active cluster, unlike cluster pages. -It is conventional to use a global page to show information and provide functionality relevant across clusters, along with custom data and functionality that your extension may have. +Global pages are independent of the cluster dashboard and can fill the entire Lens UI. Their primary use is to display information and provide functionality across clusters, including customized data and functionality unique to your extension. + +Typically, you would use a [global page menu](#globalpagemenus) located in the left nav to trigger a global page. You can also trigger a global page with a [custom app menu selection](../main-extension#appmenus) from a Main Extension or a [custom status bar item](#statusbaritems). Unlike cluster pages, users can trigger global pages even when there is no active cluster. The following example defines a `LensRendererExtension` subclass with a single global page definition: @@ -238,12 +247,13 @@ export default class HelpExtension extends LensRendererExtension { } ``` -Global pages are objects matching the `PageRegistration` interface. -The `id` field identifies the page, and at its simplest is just a string identifier, as shown in the example above. -The 'id' field can also convey route path details, such as variable parameters provided to a page ([See example below]()). -The `components` field matches the `PageComponents` interface for which there is one field, `Page`. -`Page` is of type ` React.ComponentType`, which gives you great flexibility in defining the appearance and behaviour of your page. -For the example above `HelpPage` can be defined in `page.tsx`: +`globalPages` is an array of objects that satisfy the `PageRegistration` interface. The properties of the `globalPages` array objects are defined as follows: + +* `id` is a string that identifies the page. +* `components` matches the `PageComponents` interface for which there is one field, `Page`. +* `Page` is of type `React.ComponentType`. It offers flexibility in defining the appearance and behavior of your page. + +`HelpPage` in the example above can be defined in `page.tsx`: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -260,20 +270,19 @@ export class HelpPage extends React.Component<{ extension: LensRendererExtension } ``` -Note that the `HelpPage` class defines a property named `extension`. -This allows the `HelpExtension` object to be passed in React-style in the global page definition, so that `HelpPage` can access any `HelpExtension` subclass data. +Note that the `HelpPage` class defines the `extension` property. This allows the `HelpExtension` object to be passed in the global page definition in the React-style. This way, `HelpPage` can access all `HelpExtension` subclass data. -This example code shows how to create a global page but not how to make it available to the Lens user. -Global pages are typically made available through a number of ways. -Menu items can be added to the Lens app menu system and set to open a global page when activated (See [`appMenus` in the Main Extension guide](../main-extension#appmenus)). -Interactive elements can be placed on the status bar (the blue strip along the bottom of the Lens UI) and can be configured to link to a global page when activated (See [`statusBarItems`](#statusbaritems)). -As well, global pages can be made accessible from the cluster menu, which is the vertical strip along the left side of the Lens UI showing the available cluster icons, and the Add Cluster icon. -Global page menu icons that are defined using [`globalPageMenus`](#globalpagemenus) appear below the Add Cluster icon. +This example code shows how to create a global page, but not how to make that page available to the Lens user. Global pages can be made available in the following ways: + +* To add global pages to the top menu bar, see [`appMenus`](../main-extension#appmenus) in the Main Extension guide. +* To add global pages as an interactive element in the blue status bar along the bottom of the Lens UI, see [`statusBarItems`](#statusbaritems). +* To add global pages to the left side menu, see [`globalPageMenus`](#globalpagemenus). ### `globalPageMenus` -Global page menus connect a global page to the cluster menu, which is the vertical strip along the left side of the Lens UI showing the available cluster icons, and the Add Cluster icon. -Expanding on the example from [`globalPages`](#globalPages) a global page menu is added to the `HelpExtension` definition: +`globalPageMenus` allows you to add global page menu items to the left nav. + +By expanding on the above example, you can add a global page menu item to the `HelpExtension` definition: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -302,14 +311,16 @@ export default class HelpExtension extends LensRendererExtension { } ``` -Global page menus are objects matching the `PageMenuRegistration` interface. -They define the appearance of the global page menu item in the cluster menu and the behaviour when the global page menu item is activated (typically by a mouse click). -The example above uses the `target` field to set the behaviour as a link to the global page with `id` of `"help"`. -This is done by setting `target`'s `pageId` field to `"help"`. -The global page menu item's appearance is defined by setting the `title` field to the text that is to be displayed as a tooltip in the cluster menu. -The `components` field is used to set an icon that appears in the cluster menu. -Thus when the `"Help"` icon is activated the contents of `ExamplePage` will be shown. -This example requires the definition of another React-based component, `HelpIcon`, which has been added to `page.tsx`: +`globalPageMenus` is an array of objects that satisfy the `PageMenuRegistration` interface. This element defines how the global page menu item will appear and what it will do when you click it. The properties of the `globalPageMenus` array objects are defined as follows: + +* `target` links to the relevant global page using `pageId`. +* `pageId` takes the value of the relevant global page's `id` property. +* `title` sets the name of the global page menu item that will display as a tooltip in the left nav. +* `components` is used to set an icon that appears in the left nav. + +The above example creates a "Help" icon menu item. When users click the icon, the Lens UI will display the contents of `ExamplePage`. + +This example requires the definition of another React-based component, `HelpIcon`. Update `page.tsx` from the example above with the `HelpIcon` definition, as follows: ``` typescript import { LensRendererExtension, Component } from "@k8slens/extensions"; @@ -330,14 +341,21 @@ export class HelpPage extends React.Component<{ extension: LensRendererExtension } ``` -`HelpIcon` introduces one of Lens's built-in components available to extension developers, the `Component.Icon`. -Built in are the [Material Design](https://material.io) [icons](https://material.io/resources/icons/). -One can be selected by name via the `material` field. +Lens includes various built-in components available for extension developers to use. One of these is the `Component.Icon`, introduced in `HelpIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io). The property that `Component.Icon` uses is defined as follows: + +* `material` takes the name of the icon you want to use. + +This is what the example will look like, including how the menu item will appear in the left nav: + +![globalPageMenus](images/globalpagemenus.png) ### `clusterFeatures` Cluster features are Kubernetes resources that can be applied to and managed within the active cluster. -They can be installed/uninstalled by the Lens user from the [cluster settings page](). +They can be installed and uninstalled by the Lens user from the cluster **Settings** page. + +!!! info + To access the cluster **Settings** page, right-click the relevant cluster in the left side menu and click **Settings**. The following example shows how to add a cluster feature as part of a `LensRendererExtension`: @@ -364,8 +382,11 @@ export default class ExampleFeatureExtension extends LensRendererExtension { ]; } ``` -The `title` and `components.Description` fields provide content that appears on the cluster settings page, in the **Features** section. -The `feature` field must specify an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implement the following methods: + +The properties of the `clusterFeatures` array objects are defined as follows: + +* `title` and `components.Description` provide content that appears on the cluster settings page, in the **Features** section. +* `feature` specifies an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implements the following methods: ``` typescript abstract install(cluster: Cluster): Promise; @@ -374,20 +395,21 @@ The `feature` field must specify an instance which extends the abstract class `C abstract updateStatus(cluster: Cluster): Promise; ``` -The `install()` method is typically called by Lens when a user has indicated that this feature is to be installed (i.e. clicked **Install** for the feature on the cluster settings page). -The implementation of this method should install kubernetes resources using the `applyResources()` method, or by directly accessing the kubernetes api ([`K8sApi`](tbd)). +The four methods listed above are defined as follows: -The `upgrade()` method is typically called by Lens when a user has indicated that this feature is to be upgraded (i.e. clicked **Upgrade** for the feature on the cluster settings page). -The implementation of this method should upgrade the kubernetes resources already installed, if relevant to the feature. +* The `install()` method installs Kubernetes resources using the `applyResources()` method, or by directly accessing the [Kubernetes API](../api/README.md). This method is typically called when a user indicates that they want to install the feature (i.e., by clicking **Install** for the feature in the cluster settings page). -The `uninstall()` method is typically called by Lens when a user has indicated that this feature is to be uninstalled (i.e. clicked **Uninstall** for the feature on the cluster settings page). -The implementation of this method should uninstall kubernetes resources using the kubernetes api (`K8sApi`) +* The `upgrade()` method upgrades the Kubernetes resources already installed, if they are relevant to the feature. This method is typically called when a user indicates that they want to upgrade the feature (i.e., by clicking **Upgrade** for the feature in the cluster settings page). -The `updateStatus()` method is called periodically by Lens to determine details about the feature's current status. -The implementation of this method should provide the current status information in the `status` field of the `ClusterFeature.Feature` parent class. -The `status.currentVersion` and `status.latestVersion` fields may be displayed by Lens in describing the feature. -The `status.installed` field should be set to true if the feature is currently installed, otherwise false. -Also, Lens relies on the `status.canUpgrade` field to determine if the feature can be upgraded (i.e a new version could be available) so the implementation should set the `status.canUpgrade` field according to specific rules for the feature, if relevant. +* The `uninstall()` method uninstalls Kubernetes resources using the [Kubernetes API](../api/README.md). This method is typically called when a user indicates that they want to uninstall the feature (i.e., by clicking **Uninstall** for the feature in the cluster settings page). + +* The `updateStatus()` method provides the current status information in the `status` field of the `ClusterFeature.Feature` parent class. Lens periodically calls this method to determine details about the feature's current status. Consider using the following properties with `updateStatus()`: + + * `status.currentVersion` and `status.latestVersion` may be displayed by Lens in the feature's description. + + * `status.installed` should be set to `true` if the feature is installed, and `false` otherwise. + + * `status.canUpgrade` is set according to a rule meant to determine whether the feature can be upgraded. This rule can involve `status.currentVersion` and `status.latestVersion`, if desired. The following shows a very simple implementation of a `ClusterFeature`: @@ -435,9 +457,9 @@ export class ExampleFeature extends ClusterFeature.Feature { } ``` -This example implements the `install()` method by simply invoking the helper `applyResources()` method. +This example implements the `install()` method by invoking the helper `applyResources()` method. `applyResources()` tries to apply all resources read from all files found in the folder path provided. -In this case this folder path is the `../resources` subfolder relative to current source code's folder. +In this case the folder path is the `../resources` subfolder relative to the current source code's folder. The file `../resources/example-pod.yml` could contain: ``` yaml @@ -451,18 +473,18 @@ spec: image: nginx ``` -The `upgrade()` method in the example above is implemented by simply invoking the `install()` method. -Depending on the feature to be supported by an extension, upgrading may require additional and/or different steps. +The example above implements the four methods as follows: -The `uninstall()` method is implemented in the example above by utilizing the [`K8sApi`](tbd) provided by Lens to simply delete the `example-pod` pod applied by the `install()` method. +* It implements `upgrade()` by invoking the `install()` method. Depending on the feature to be supported by an extension, upgrading may require additional and/or different steps. -The `updateStatus()` method is implemented above by using the [`K8sApi`](tbd) as well, this time to get information from the `example-pod` pod, in particular to determine if it is installed, what version is associated with it, and if it can be upgraded. -How the status is updated for a specific cluster feature is up to the implementation. +* It implements `uninstall()` by utilizing the [Kubernetes API](../api/README.md) which Lens provides to delete the `example-pod` applied by the `install()` method. + +* It implements `updateStatus()` by using the [Kubernetes API](../api/README.md) which Lens provides to determine whether the `example-pod` is installed, what version is associated with it, and whether it can be upgraded. The implementation determines what the status is for a specific cluster feature. ### `appPreferences` -The Preferences page is a built-in global page. -Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global options, for Lens and extensions alike. +The Lens **Preferences** page is a built-in global page. You can use Lens extensions to add custom preferences to the Preferences page, providing a single location for users to configure global options. + The following example demonstrates adding a custom preference: ``` typescript @@ -487,13 +509,20 @@ export default class ExampleRendererExtension extends LensRendererExtension { } ``` -App preferences are objects matching the `AppPreferenceRegistration` interface. -The `title` field specifies the text to show as the heading on the Preferences page. -The `components` field specifies two `React.Component` objects defining the interface for the preference. -`Input` should specify an interactive input element for your preference and `Hint` should provide descriptive information for the preference, which is shown below the `Input` element. -`ExamplePreferenceInput` expects its React props set to an `ExamplePreferenceProps` instance, which is how `ExampleRendererExtension` handles the state of the preference input. -`ExampleRendererExtension` has the field `preference`, which is provided to `ExamplePreferenceInput` when it is created. -In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx`: +`appPreferences` is an array of objects that satisfies the `AppPreferenceRegistration` interface. The properties of the `appPreferences` array objects are defined as follows: + +* `title` sets the heading text displayed on the Preferences page. +* `components` specifies two `React.Component` objects that define the interface for the preference. + * `Input` specifies an interactive input element for the preference. + * `Hint` provides descriptive information for the preference, shown below the `Input` element. + +!!! note + Note that the input and the hint can be comprised of more sophisticated elements, according to the needs of the extension. + +`ExamplePreferenceInput` expects its React props to be set to an `ExamplePreferenceProps` instance. This is how `ExampleRendererExtension` handles the state of the preference input. +`ExampleRendererExtension` has a `preference` field, which you will add to `ExamplePreferenceInput`. + +In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx` as follows: ``` typescript import { Component } from "@k8slens/extensions"; @@ -530,28 +559,26 @@ export class ExamplePreferenceHint extends React.Component { } ``` -`ExamplePreferenceInput` implements a simple checkbox (using Lens's `Component.Checkbox`). -It provides `label` as the text to display next to the checkbox and an `onChange` function, which reacts to the checkbox state change. -The checkbox's `value` is initially set to `preference.enabled`. -`ExamplePreferenceInput` is defined with React props of `ExamplePreferenceProps` type, which is an object with a single field, `enabled`. -This is used to indicate the state of the preference, and is bound to the checkbox state in `onChange`. +`ExamplePreferenceInput` implements a simple checkbox using Lens's `Component.Checkbox` using the following properties: + +* `label` sets the text that displays next to the checkbox. +* `value` is initially set to `preference.enabled`. +* `onChange` is a function that responds when the state of the checkbox changes. + +`ExamplePreferenceInput` is defined with the `ExamplePreferenceProps` React props. This is an object with the single `enabled` property. It is used to indicate the state of the preference, and it is bound to the checkbox state in `onChange`. + `ExamplePreferenceHint` is a simple text span. -Note that the input and the hint could comprise of more sophisticated elements, according to the needs of the extension. -Note that the above example introduces decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. -`mobx` simplifies state management and without it this example would not visually update the checkbox properly when the user activates it. -[Lens uses `mobx` extensively for state management](../working-with-mobx) of its own UI elements and it is recommended that extensions rely on it too. -Alternatively, React's state management can be used, though `mobx` is typically simpler to use. +The above example introduces the decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. `mobx` simplifies state management. Without it, this example would not visually update the checkbox properly when the user activates it. [Lens uses `mobx`](../working-with-mobx) extensively for state management of its own UI elements. We recommend that extensions rely on it, as well. +Alternatively, you can use React's state management, though `mobx` is typically simpler to use. -Also note that an extension's state data can be managed using an `ExtensionStore` object, which conveniently handles persistence and synchronization. -The example above defined a `preference` field in the `ExampleRendererExtension` class definition to hold the extension's state primarily to simplify the code for this guide, but it is recommended to manage your extension's state data using [`ExtensionStore`](../stores#extensionstore) +Note that you can manage an extension's state data using an `ExtensionStore` object, which conveniently handles persistence and synchronization. To simplify this guide, the example above defines a `preference` field in the `ExampleRendererExtension` class definition to hold the extension's state. However, we recommend that you manage your extension's state data using [`ExtensionStore`](../stores#extensionstore). ### `statusBarItems` -The Status bar is the blue strip along the bottom of the Lens UI. -Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page, or even an external page. +The status bar is the blue strip along the bottom of the Lens UI. `statusBarItems` are `React.ReactNode` types. They can be used to display status information, or act as links to global pages as well as external pages. -The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and configures the status bar item to navigate to the global page upon activation (normally a mouse click): +The following example adds a `statusBarItems` definition and a `globalPages` definition to a `LensRendererExtension` subclass. It configures the status bar item to navigate to the global page upon activation (normally a mouse click): ``` typescript import { LensRendererExtension } from '@k8slens/extensions'; @@ -584,23 +611,23 @@ export default class HelpExtension extends LensRendererExtension { } ``` -The `item` field of a status bar item specifies the `React.Component` to be shown on the status bar. -By default items are added starting from the right side of the status bar. -Typically, `item` would specify an icon and/or a short string of text, considering the limited space on the status bar. -In the example above the `HelpIcon` from the [`globalPageMenus` guide](#globalpagemenus) is reused. -Also, the `item` provides a link to the global page by setting the `onClick` property to a function that calls the `LensRendererExtension` `navigate()` method. -`navigate()` takes as a parameter the id of the global page, which is shown when `navigate()` is called. +The properties of the `statusBarItems` array objects are defined as follows: + +* `item` specifies the `React.Component` that will be shown on the status bar. By default, items are added starting from the right side of the status bar. Due to limited space in the status bar, `item` will typically specify only an icon or a short string of text. The example above reuses the `HelpIcon` from the [`globalPageMenus` guide](#globalpagemenus). +* `onClick` determines what the `statusBarItem` does when it is clicked. In the example, `onClick` is set to a function that calls the `LensRendererExtension` `navigate()` method. `navigate` takes the `id` of the associated global page as a parameter. Thus, clicking the status bar item activates the associated global pages. ### `kubeObjectMenuItems` -An extension can add custom menu items (including actions) for specific Kubernetes resource kinds/apiVersions. -These menu items appear under the `...` for each listed resource in the cluster dashboard, and on the title bar of the details page for a specific resource: +An extension can add custom menu items (`kubeObjectMenuItems`) for specific Kubernetes resource kinds and apiVersions. +`kubeObjectMenuItems` appear under the vertical ellipsis for each listed resource in the cluster dashboard: ![List](images/kubeobjectmenuitem.png) +They also appear on the title bar of the details page for specific resources: + ![Details](images/kubeobjectmenuitemdetail.png) -The following example shows how to add a menu for Namespace resources, and associate an action with it: +The following example shows how to add a `kubeObjectMenuItems` for namespace resources with an associated action: ``` typescript import React from "react" @@ -621,12 +648,13 @@ export default class ExampleExtension extends LensRendererExtension { ``` -Kube object menu items are objects matching the `KubeObjectMenuRegistration` interface. -The `kind` field specifies the kubernetes resource type to apply this menu item to, and the `apiVersion` field specifies the kubernetes api to use in relation to this resource type. -This example adds a menu item for namespaces in the cluster dashboard. -The `components` field defines the menu item's appearance and behaviour. -The `MenuItem` field provides a function that returns a `React.Component` given a set of menu item properties. -In this example a `NamespaceMenuItem` object is returned. +`kubeObjectMenuItems` is an array of objects matching the `KubeObjectMenuRegistration` interface. The example above adds a menu item for namespaces in the cluster dashboard. The properties of the `kubeObjectMenuItems` array objects are defined as follows: + +* `kind` specifies the Kubernetes resource type the menu item will apply to. +* `apiVersion` specifies the Kubernetes API version number to use with the resource type. +* `components` defines the menu item's appearance and behavior. +* `MenuItem` provides a function that returns a `React.Component` given a set of menu item properties. In this example a `NamespaceMenuItem` object is returned. + `NamespaceMenuItem` is defined in `./src/namespace-menu-item.tsx`: ```typescript @@ -661,22 +689,18 @@ export function NamespaceMenuItem(props: Component.KubeObjectMenuProps>` it can access the current namespace object (type `K8sApi.Namespace`) through `this.props.object`. -This object can be queried for many details about the current namespace. -In this example the namespace's name is obtained in `componentDidMount()` using the `K8sApi.Namespace` `getName()` method. -The namespace's name is needed to limit the list of pods to only those in this namespace. -To get the list of pods this example uses the kubernetes pods api, specifically the `K8sApi.podsApi.list()` method. -The `K8sApi.podsApi` is automatically configured for the currently active cluster. +Since `NamespaceDetailsItem` extends `React.Component>`, it can access the current namespace object (type `K8sApi.Namespace`) through `this.props.object`. You can query this object for many details about the current namespace. In the example above, `componentDidMount()` gets the namespace's name using the `K8sApi.Namespace` `getName()` method. Use the namespace's name to limit the list of pods only to those in the relevant namespace. To get this list of pods, this example uses the Kubernetes pods API `K8sApi.podsApi.list()` method. The `K8sApi.podsApi` is automatically configured for the active cluster. -Note that `K8sApi.podsApi.list()` is an asynchronous method, and ideally getting the pods list should be done before rendering the `NamespaceDetailsItem`. -It is a common technique in React development to await async calls in `componentDidMount()`. -However, `componentDidMount()` is called right after the first call to `render()`. -In order to effect a subsequent `render()` call React must be made aware of a state change. -Like in the [`appPreferences` guide](#apppreferences), [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) are used to ensure `NamespaceDetailsItem` renders when the pods list is updated. -This is done simply by marking the `pods` field as an `observable` and the `NamespaceDetailsItem` class itself as an `observer`. +Note that `K8sApi.podsApi.list()` is an asynchronous method. Getting the pods list should occur prior to rendering the `NamespaceDetailsItem`. It is a common technique in React development to await async calls in `componentDidMount()`. However, `componentDidMount()` is called right after the first call to `render()`. In order to effect a subsequent `render()` call, React must be made aware of a state change. Like in the [`appPreferences` guide](#apppreferences), [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) are used to ensure `NamespaceDetailsItem` renders when the pods list updates. This is done simply by marking the `pods` field as an `observable` and the `NamespaceDetailsItem` class itself as an `observer`. -Finally, the `NamespaceDetailsItem` is rendered using the `render()` method. +Finally, the `NamespaceDetailsItem` renders using the `render()` method. Details are placed in drawers, and using `Component.DrawerTitle` provides a separator from details above this one. Multiple details in a drawer can be placed in `` elements for further separation, if desired. The rest of this example's details are defined in `PodsDetailsList`, found in `./pods-details-list.tsx`: @@ -800,6 +815,9 @@ export class PodsDetailsList extends React.Component { ![DetailsWithPods](images/kubeobjectdetailitemwithpods.png) - For each pod the name, age, and status are obtained using the `K8sApi.Pod` methods. +Obtain the name, age, and status for each pod using the `K8sApi.Pod` methods. Construct the table using the `Component.Table` and related elements. + + +For each pod the name, age, and status are obtained using the `K8sApi.Pod` methods. The table is constructed using the `Component.Table` and related elements. -See [`Component` documentation](url?) for further details. \ No newline at end of file +See [`Component` documentation](https://docs.k8slens.dev/master/extensions/api/modules/_renderer_api_components_/) for further details. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index ad3e19572e..69839db487 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,8 @@ markdown_extensions: - toc: permalink: "#" toc_depth: 3 + - admonition: {} + - pymdownx.details: {} extra: generator: false From 3e6d8fc7326ed73e201d2c694b50cbe8754bd2e4 Mon Sep 17 00:00:00 2001 From: pauljwil Date: Fri, 15 Jan 2021 09:41:03 -0800 Subject: [PATCH 007/219] Rework extensions guides (#1802) * Rework extensions guides * Edited MobX guide. * Changed MobX in mkdocs.yml nav. Signed-off-by: Paul Williams * split line by sentances Signed-off-by: Sebastian Malton Co-authored-by: Paul Williams Co-authored-by: Sebastian Malton --- docs/extensions/guides/working-with-mobx.md | 35 +++++++++++---------- mkdocs.yml | 2 +- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/docs/extensions/guides/working-with-mobx.md b/docs/extensions/guides/working-with-mobx.md index 5577ff6bdc..41ddc487a6 100644 --- a/docs/extensions/guides/working-with-mobx.md +++ b/docs/extensions/guides/working-with-mobx.md @@ -1,23 +1,26 @@ -# Working with mobx +# Working with MobX ## Introduction -Lens uses `mobx` as its state manager on top of React's state management system. -This helps with having a more declarative style of managing state, as opposed to `React`'s native `setState` mechanism. -You should already have a basic understanding of how `React` handles state ([read here](https://reactjs.org/docs/faq-state.html) for more information). -However, if you do not, here is a quick overview. +Lens uses MobX on top of React's state management system. +The result is a more declarative state management style, rather than React's native `setState` mechanism. -- A `React.Component` is generic over both `Props` and `State` (with default empty object types). -- `Props` should be considered read-only from the point of view of the component and is the mechanism for passing in "arguments" to a component. -- `State` is a component's internal state and can be read by accessing the parent field `state`. -- `State` **must** be updated using the `setState` parent method which merges the new data with the old state. -- `React` does do some optimizations around re-rendering components after quick successions of `setState` calls. +You can review how React handles state management [here](https://reactjs.org/docs/faq-state.html). -## How mobx works: +The following is a quick overview: -`mobx` is a package that provides an abstraction over `React`'s state management. The three main concepts are: -- `observable`: data stored in the component's `state` -- `action`: a function that modifies any `observable` data -- `computed`: data that is derived from `observable` data but is not actually stored. Think of this as computing `isEmpty` vs an `observable` field called `count`. +* `React.Component` is generic with respect to both `props` and `state` (which default to the empty object type). +* `props` should be considered read-only from the point of view of the component, and it is the mechanism for passing in arguments to a component. +* `state` is a component's internal state, and can be read by accessing the super-class field `state`. +* `state` **must** be updated using the `setState` parent method which merges the new data with the old state. +* React does some optimizations around re-rendering components after quick successions of `setState` calls. -Further reading is available from `mobx`'s [website](https://mobx.js.org/the-gist-of-mobx.html). +## How MobX Works: + +MobX is a package that provides an abstraction over React's state management system. The three main concepts are: + +* `observable` is a marker for data stored in the component's `state`. +* `action` is a function that modifies any `observable` data. +* `computed` is a marker for data that is derived from `observable` data, but that is not actually stored. Think of this as computing `isEmpty` rather than an observable field called `count`. + +Further reading is available on the [MobX website](https://mobx.js.org/the-gist-of-mobx.html). diff --git a/mkdocs.yml b/mkdocs.yml index 69839db487..dd72a677ea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -34,7 +34,7 @@ nav: - Main Extension: extensions/guides/main-extension.md - Renderer Extension: extensions/guides/renderer-extension.md - Stores: extensions/guides/stores.md - - Working with mobx: extensions/guides/working-with-mobx.md + - Working with MobX: extensions/guides/working-with-mobx.md - Testing and Publishing: - Testing Extensions: extensions/testing-and-publishing/testing.md - Publishing Extensions: extensions/testing-and-publishing/publishing.md From 5dba28ab5e6741d754fc30db29c49a8060046849 Mon Sep 17 00:00:00 2001 From: pauljwil Date: Fri, 15 Jan 2021 09:42:38 -0800 Subject: [PATCH 008/219] Rework extensions guides (#1803) * Edited Stores extensions guide. Signed-off-by: Paul Williams Co-authored-by: Paul Williams --- docs/extensions/guides/stores.md | 46 +++++++++++--------------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/docs/extensions/guides/stores.md b/docs/extensions/guides/stores.md index 981a7cda3e..0319ee0aab 100644 --- a/docs/extensions/guides/stores.md +++ b/docs/extensions/guides/stores.md @@ -1,18 +1,16 @@ # Stores -Stores are components that persist and synchronize state data. Lens utilizes a number of stores for maintaining a variety of state information. -A few of these are exposed by the extensions api for use by the extension developer. +Stores are components that persist and synchronize state data. Lens uses a number of stores to maintain various kinds of state information, including: -- The `ClusterStore` manages cluster state data such as cluster details, and which cluster is active. -- The `WorkspaceStore` similarly manages workspace state data, such as workspace name, and which clusters belong to a given workspace. -- The `ExtensionStore` is a store for managing custom extension state data. +* The `ClusterStore` manages cluster state data (such as cluster details), and it tracks which cluster is active. +* The `WorkspaceStore` manages workspace state data (such as the workspace name), and and it tracks which clusters belong to a given workspace. +* The `ExtensionStore` manages custom extension state data. + +This guide focuses on the `ExtensionStore`. ## ExtensionStore -Extension developers can create their own store for managing state data by extending the `ExtensionStore` class. -This guide shows how to create a store for the [`appPreferences` guide example](../renderer-extension#apppreferences), which demonstrates how to add a custom preference to the Preferences page. -The preference is a simple boolean that indicates whether something is enabled or not. -The problem with that example is that the enabled state is not stored anywhere, and reverts to the default the next time Lens is started. +Extension developers can create their own store for managing state data by extending the `ExtensionStore` class. This guide shows how to create a store for the [`appPreferences`](../renderer-extension#apppreferences) guide example, which demonstrates how to add a custom preference to the **Preferences** page. The preference is a simple boolean that indicates whether or not something is enabled. However, in the example, the enabled state is not stored anywhere, and it reverts to the default when Lens is restarted. The following example code creates a store for the `appPreferences` guide example: @@ -53,25 +51,14 @@ export class ExamplePreferencesStore extends Store.ExtensionStore(); ``` -First the extension's data model is defined using a simple type, `ExamplePreferencesModel`, which has a single field, `enabled`, representing the preference's state. -`ExamplePreferencesStore` extends `Store.ExtensionStore`, based on the `ExamplePreferencesModel`. -The field `enabled` is added to the `ExamplePreferencesStore` class to hold the "live" or current state of the preference. -Note the use of the `observer` decorator on the `enabled` field. -As for the [`appPreferences` guide example](../renderer-extension#apppreferences), [`mobx`](https://mobx.js.org/README.html) is used for the UI state management, ensuring the checkbox updates when activated by the user. +First, our example defines the extension's data model using the simple `ExamplePreferencesModel` type. This has a single field, `enabled`, which represents the preference's state. `ExamplePreferencesStore` extends `Store.ExtensionStore`, which is based on the `ExamplePreferencesModel`. The `enabled` field is added to the `ExamplePreferencesStore` class to hold the "live" or current state of the preference. Note the use of the `observable` decorator on the `enabled` field. The [`appPreferences`](../renderer-extension#apppreferences) guide example uses [MobX](https://mobx.js.org/README.html) for the UI state management, ensuring the checkbox updates when it's activated by the user. -Then the constructor and two abstract methods are implemented. -In the constructor, the name of the store (`"example-preferences-store"`), and the default (initial) value for the preference state (`enabled: false`) are specified. -The `fromStore()` method is called by Lens internals when the store is loaded, and gives the extension the opportunity to retrieve the stored state data values based on the defined data model. -Here, the `enabled` field of the `ExamplePreferencesStore` is set to the value from the store whenever `fromStore()` is invoked. -The `toJSON()` method is complementary to `fromStore()`, and is called when the store is being saved. -`toJSON()` must provide a JSON serializable object, facilitating its storage in JSON format. -The `toJS()` function from [`mobx`](https://mobx.js.org/README.html) is convenient for this purpose, and is used here. +Next, our example implements the constructor and two abstract methods. The constructor specifies the name of the store (`"example-preferences-store"`) and the default (initial) value for the preference state (`enabled: false`). Lens internals call the `fromStore()` method when the store loads. It gives the extension the opportunity to retrieve the stored state data values based on the defined data model. The `enabled` field of the `ExamplePreferencesStore` is set to the value from the store whenever `fromStore()` is invoked. The `toJSON()` method is complementary to `fromStore()`. It is called when the store is being saved. +`toJSON()` must provide a JSON serializable object, facilitating its storage in JSON format. The `toJS()` function from [`mobx`](https://mobx.js.org/README.html) is convenient for this purpose, and is used here. -Finally, `examplePreferencesStore` is created by calling `ExamplePreferencesStore.getInstance()`, and exported for use by other parts of the extension. -Note that `examplePreferencesStore` is a singleton, calling this function again will not create a new store. +Finally, `examplePreferencesStore` is created by calling `ExamplePreferencesStore.getInstance()`, and exported for use by other parts of the extension. Note that `examplePreferencesStore` is a singleton. Calling this function again will not create a new store. -The following example code, modified from the [`appPreferences` guide example](../renderer-extension#apppreferences) demonstrates how to use the extension store. -`examplePreferencesStore` must be loaded in the main process, where loaded stores are automatically saved when exiting Lens. This can be done in `./main.ts`: +The following example code, modified from the [`appPreferences`](../renderer-extension#apppreferences) guide demonstrates how to use the extension store. `examplePreferencesStore` must be loaded in the main process, where loaded stores are automatically saved when exiting Lens. This can be done in `./main.ts`: ``` typescript import { LensMainExtension } from "@k8slens/extensions"; @@ -84,8 +71,8 @@ export default class ExampleMainExtension extends LensMainExtension { } ``` -Here, `examplePreferencesStore` is loaded with `examplePreferencesStore.loadExtension(this)`, which is conveniently called from the `onActivate()` method of `ExampleMainExtension`. -Similarly, `examplePreferencesStore` must be loaded in the renderer process where the `appPreferences` are handled. This can be done in `./renderer.ts`: +Here, `examplePreferencesStore` loads with `examplePreferencesStore.loadExtension(this)`, which is conveniently called from the `onActivate()` method of `ExampleMainExtension`. +Similarly, `examplePreferencesStore` must load in the renderer process where the `appPreferences` are handled. This can be done in `./renderer.ts`: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -111,8 +98,7 @@ export default class ExampleRendererExtension extends LensRendererExtension { } ``` -Again, `examplePreferencesStore.loadExtension(this)` is called to load `examplePreferencesStore`, this time from the `onActivate()` method of `ExampleRendererExtension`. -Also, there is no longer the need for the `preference` field in the `ExampleRendererExtension` class, as the props for `ExamplePreferenceInput` is now `examplePreferencesStore`. +Again, `examplePreferencesStore.loadExtension(this)` is called to load `examplePreferencesStore`, this time from the `onActivate()` method of `ExampleRendererExtension`. There is no longer the need for the `preference` field in the `ExampleRendererExtension` class because the props for `ExamplePreferenceInput` is now `examplePreferencesStore`. `ExamplePreferenceInput` is defined in `./src/example-preference.tsx`: ``` typescript @@ -151,5 +137,5 @@ export class ExamplePreferenceHint extends React.Component { ``` The only change here is that `ExamplePreferenceProps` defines its `preference` field as an `ExamplePreferencesStore` type. -Everything else works as before except now the `enabled` state persists across Lens restarts because it is managed by the +Everything else works as before, except that now the `enabled` state persists across Lens restarts because it is managed by the `examplePreferencesStore`. \ No newline at end of file From 080dab0b7133b4a0168bb7ce4a29645f5f659d76 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 18 Jan 2021 02:47:16 -0500 Subject: [PATCH 009/219] Generate docs only for releases (#1972) Signed-off-by: Sebastian Malton --- .github/workflows/main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0c6340d6a2..afbcc74367 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,7 @@ jobs: uses: actions/setup-python@v2 with: python-version: '3.x' - + - name: Checkout Release from lens uses: actions/checkout@v2 with: @@ -83,12 +83,12 @@ jobs: mike deploy --push master - name: Get the release version - if: contains(github.ref, 'refs/tags/v') # && !github.event.release.prerelease (generate pre-release docs until Lens 4.0.0 is GA, see #1408) + if: contains(github.ref, 'refs/tags/v') && !github.event.release.prerelease id: get_version run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} - name: mkdocs deploy new release - if: contains(github.ref, 'refs/tags/v') # && !github.event.release.prerelease (generate pre-release docs until Lens 4.0.0 is GA, see #1408) + if: contains(github.ref, 'refs/tags/v') && !github.event.release.prerelease run: | mike deploy --push --update-aliases ${{ steps.get_version.outputs.VERSION }} latest mike set-default --push ${{ steps.get_version.outputs.VERSION }} From 487338269a5c8e68eb6f7bf9430614e7a17e2b04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Jan 2021 15:03:22 +0200 Subject: [PATCH 010/219] Bump @types/hapi from 18.0.3 to 18.0.5 (#1963) Bumps [@types/hapi](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/hapi) from 18.0.3 to 18.0.5. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/hapi) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 57 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 7d00909318..798bfb7193 100644 --- a/package.json +++ b/package.json @@ -244,7 +244,7 @@ "@types/electron-devtools-installer": "^2.2.0", "@types/electron-window-state": "^2.0.34", "@types/fs-extra": "^9.0.1", - "@types/hapi": "^18.0.3", + "@types/hapi": "^18.0.5", "@types/hoist-non-react-statics": "^3.3.1", "@types/html-webpack-plugin": "^3.2.3", "@types/http-proxy": "^1.17.4", diff --git a/yarn.lock b/yarn.lock index 27d8c541ce..dc50f585ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -529,6 +529,13 @@ "@hapi/pez" "^5.0.1" "@hapi/wreck" "17.x.x" +"@hapi/topo@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.0.0.tgz#c19af8577fa393a06e9c77b60995af959be721e7" + integrity sha512-tFJlT47db0kMqVm3H4nQYgn6Pwg10GTZHb1pwmSiv1K4ks6drQOtfEF5ZnPjkvC+y4/bUPHK+bc87QvLcL+WMw== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/vise@4.x.x": version "4.0.0" resolved "https://registry.yarnpkg.com/@hapi/vise/-/vise-4.0.0.tgz#c6a94fe121b94a53bf99e7489f7fcc74c104db02" @@ -900,6 +907,23 @@ schema-utils "^2.6.5" source-map "^0.7.3" +"@sideway/address@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.0.tgz#0b301ada10ac4e0e3fa525c90615e0b61a72b78d" + integrity sha512-wAH/JYRXeIFQRsxerIuLjgUu2Xszam+O5xKeatJ4oudShOOirfmsQ1D6LL54XOU2tizpCYku+s1wmU0SYdpoSA== + dependencies: + "@hapi/hoek" "^9.0.0" + +"@sideway/formula@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.0.tgz#fe158aee32e6bd5de85044be615bc08478a0a13c" + integrity sha512-vHe7wZ4NOXVfkoRb8T5otiENVlT7a3IAiw7H5M2+GO+9CDgcVUUsX1zalAztCmwyOr2RUTGJdgB+ZvSVqmdHmg== + +"@sideway/pinpoint@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" + integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -1175,19 +1199,19 @@ dependencies: "@types/node" "*" -"@types/hapi@^18.0.3": - version "18.0.3" - resolved "https://registry.yarnpkg.com/@types/hapi/-/hapi-18.0.3.tgz#e74c019f6a1b1c7f647fe014d3890adec9c0214a" - integrity sha512-UM03myDZ2UWbpqLSZqboK4L98F9r4GCcd9JOr2auhgC3iOd/69mvDggivOHhOYJKWHeCW/dECPHIB7DwQz00dw== +"@types/hapi@^18.0.5": + version "18.0.5" + resolved "https://registry.yarnpkg.com/@types/hapi/-/hapi-18.0.5.tgz#7573fc83ec1d8ecf127b93bd326266da533fe30b" + integrity sha512-OnBslvAL//tsTZemW8wSGrVUj7BgtS16kBicgKCtEpHle9A3pOd7X4ViykKv/X3rh7g636mDzoqQ27+Ols5GQw== dependencies: "@types/boom" "*" "@types/catbox" "*" "@types/iron" "*" - "@types/joi" "*" "@types/mimos" "*" "@types/node" "*" "@types/podium" "*" "@types/shot" "*" + joi "^17.3.0" "@types/history@*", "@types/history@^4.7.3": version "4.7.6" @@ -1299,11 +1323,6 @@ jest-diff "^25.2.1" pretty-format "^25.2.1" -"@types/joi@*": - version "14.3.4" - resolved "https://registry.yarnpkg.com/@types/joi/-/joi-14.3.4.tgz#eed1e14cbb07716079c814138831a520a725a1e0" - integrity sha512-1TQNDJvIKlgYXGNIABfgFp9y0FziDpuGrd799Q5RcnsDu+krD+eeW/0Fs5PHARvWWFelOhIG2OPCo6KbadBM4A== - "@types/js-yaml@^3.12.1", "@types/js-yaml@^3.12.4": version "3.12.4" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.4.tgz#7d3b534ec35a0585128e2d332db1403ebe057e25" @@ -1406,12 +1425,7 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944" integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA== -"@types/node@^12.0.12": - version "12.12.44" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.44.tgz#0d400a1453adcb359b133acceae4dd8bb0e0a159" - integrity sha512-jM6QVv0Sm5d3nW+nUD5jSzPcO6oPqboitSNcwgBay9hifVq/Rauq1PYnROnsmuw45JMBiTnsPAno0bKu2e2xrg== - -"@types/node@^12.12.45": +"@types/node@^12.0.12", "@types/node@^12.12.45": version "12.12.45" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.45.tgz#33d550d6da243652004b00cbf4f15997456a38e3" integrity sha512-9w50wqeS0qQH9bo1iIRcQhDXRxoDzyAqCL5oJG+Nuu7cAoe6omGo+YDE0spAGK5sPrdLDhQLbQxq0DnxyndPKA== @@ -7877,6 +7891,17 @@ jest@^26.0.1: import-local "^3.0.2" jest-cli "^26.0.1" +joi@^17.3.0: + version "17.3.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.3.0.tgz#f1be4a6ce29bc1716665819ac361dfa139fff5d2" + integrity sha512-Qh5gdU6niuYbUIUV5ejbsMiiFmBdw8Kcp8Buj2JntszCkCfxJ9Cz76OtHxOZMPXrt5810iDIXs+n1nNVoquHgg== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.0" + "@sideway/formula" "^3.0.0" + "@sideway/pinpoint" "^2.0.0" + jose@^1.27.1: version "1.27.1" resolved "https://registry.yarnpkg.com/jose/-/jose-1.27.1.tgz#a1de2ecb5b3ae1ae28f0d9d0cc536349ada27ec8" From 41e012d83f3f3431e7ad74fb7cbb21a90b9f1fc4 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 18 Jan 2021 11:23:50 -0500 Subject: [PATCH 011/219] do not assume that bitnami is guanteed to be in the helm repo list (#1960) * do not assume that bitnami is guanteed to be in the helm repo list * more robust test by checking for added helm repos Co-authored-by: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Signed-off-by: Sebastian Malton --- integration/__tests__/app.tests.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index 7182a13107..c986e4804e 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -5,8 +5,11 @@ cluster and vice versa. */ import { Application } from "spectron"; -import * as util from "../helpers/utils"; -import { spawnSync } from "child_process"; +import * as utils from "../helpers/utils"; +import { spawnSync, exec } from "child_process"; +import * as util from "util"; + +export const promiseExec = util.promisify(exec); jest.setTimeout(60000); @@ -16,7 +19,7 @@ describe("Lens integration tests", () => { const BACKSPACE = "\uE003"; let app: Application; const appStart = async () => { - app = util.setup(); + app = utils.setup(); await app.start(); // Wait for splash screen to be closed while (await app.client.getWindowCount() > 1); @@ -71,7 +74,7 @@ describe("Lens integration tests", () => { afterAll(async () => { if (app?.isRunning()) { - await util.tearDown(app); + await utils.tearDown(app); } }); @@ -93,7 +96,10 @@ describe("Lens integration tests", () => { }); it("ensures helm repos", async () => { - await app.client.waitUntilTextExists("div.repos #message-bitnami", "bitnami"); // wait for the helm-cli to fetch the bitnami repo + const { stdout: reposJson } = await promiseExec("helm repo list -o json"); + const repos = JSON.parse(reposJson); + + await app.client.waitUntilTextExists("div.repos #message-bitnami", repos[0].name); // wait for the helm-cli to fetch the repo(s) await app.client.click("#HelmRepoSelect"); // click the repo select to activate the drop-down await app.client.waitUntilTextExists("div.Select__option", ""); // wait for at least one option to appear (any text) }); @@ -105,12 +111,12 @@ describe("Lens integration tests", () => { }); }); - util.describeIf(ready)("workspaces", () => { + utils.describeIf(ready)("workspaces", () => { beforeAll(appStart, 20000); afterAll(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); @@ -169,7 +175,7 @@ describe("Lens integration tests", () => { await app.client.waitUntilTextExists("span.link-text", "Cluster"); }; - util.describeIf(ready)("cluster tests", () => { + utils.describeIf(ready)("cluster tests", () => { let clusterAdded = false; const addCluster = async () => { await clickWhatsNew(app); @@ -184,7 +190,7 @@ describe("Lens integration tests", () => { afterAll(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); @@ -207,7 +213,7 @@ describe("Lens integration tests", () => { afterAll(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); @@ -489,7 +495,7 @@ describe("Lens integration tests", () => { afterEach(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); @@ -523,7 +529,7 @@ describe("Lens integration tests", () => { afterEach(async () => { if (app && app.isRunning()) { - return util.tearDown(app); + return utils.tearDown(app); } }); From 31aa3cb571810c7e4ca55be18c6a3fbff1eb2e28 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 19 Jan 2021 06:30:05 +0200 Subject: [PATCH 012/219] Enable default workspace on first boot (#1965) * enable default workspace on first boot Signed-off-by: Jari Kolehmainen * refactor Signed-off-by: Jari Kolehmainen * use get/set Signed-off-by: Jari Kolehmainen --- src/common/__tests__/workspace-store.test.ts | 7 ++++ src/common/workspace-store.ts | 44 ++++++++++++-------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/common/__tests__/workspace-store.test.ts b/src/common/__tests__/workspace-store.test.ts index e69ebda0aa..355eb8b2ce 100644 --- a/src/common/__tests__/workspace-store.test.ts +++ b/src/common/__tests__/workspace-store.test.ts @@ -36,6 +36,13 @@ describe("workspace store tests", () => { expect(ws.getById(WorkspaceStore.defaultId)).not.toBe(null); }); + it("default workspace should be enabled", () => { + const ws = WorkspaceStore.getInstance(); + + expect(ws.workspaces.size).toBe(1); + expect(ws.getById(WorkspaceStore.defaultId).enabled).toBe(true); + }); + it("cannot remove the default workspace", () => { const ws = WorkspaceStore.getInstance(); diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts index 7688516af2..921a9126a9 100644 --- a/src/common/workspace-store.ts +++ b/src/common/workspace-store.ts @@ -58,14 +58,7 @@ export class Workspace implements WorkspaceModel, WorkspaceState { * @observable */ @observable ownerRef?: string; - /** - * Is workspace enabled - * - * Workspaces that don't have ownerRef will be enabled by default. Workspaces with ownerRef need to explicitly enable a workspace. - * - * @observable - */ - @observable enabled: boolean; + /** * Last active cluster id * @@ -73,6 +66,9 @@ export class Workspace implements WorkspaceModel, WorkspaceState { */ @observable lastActiveClusterId?: ClusterId; + + @observable private _enabled: boolean; + constructor(data: WorkspaceModel) { Object.assign(this, data); @@ -83,6 +79,21 @@ export class Workspace implements WorkspaceModel, WorkspaceState { } } + /** + * Is workspace enabled + * + * Workspaces that don't have ownerRef will be enabled by default. Workspaces with ownerRef need to explicitly enable a workspace. + * + * @observable + */ + get enabled(): boolean { + return !this.isManaged || this._enabled; + } + + set enabled(enabled: boolean) { + this._enabled = enabled; + } + /** * Is workspace managed by an extension */ @@ -134,10 +145,18 @@ export class WorkspaceStore extends BaseStore { static readonly defaultId: WorkspaceId = "default"; private static stateRequestChannel = "workspace:states"; + @observable currentWorkspaceId = WorkspaceStore.defaultId; + @observable workspaces = observable.map(); + private constructor() { super({ configName: "lens-workspace-store", }); + + this.workspaces.set(WorkspaceStore.defaultId, new Workspace({ + id: WorkspaceStore.defaultId, + name: "default" + })); } async load() { @@ -186,15 +205,6 @@ export class WorkspaceStore extends BaseStore { ipcRenderer.removeAllListeners("workspace:state"); } - @observable currentWorkspaceId = WorkspaceStore.defaultId; - - @observable workspaces = observable.map({ - [WorkspaceStore.defaultId]: new Workspace({ - id: WorkspaceStore.defaultId, - name: "default" - }) - }); - @computed get currentWorkspace(): Workspace { return this.getById(this.currentWorkspaceId); } From d8e088f352b63550108094fe1f3a8e595abb902b Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 19 Jan 2021 14:15:37 +0200 Subject: [PATCH 013/219] fix: chart.digest is the same for all charts and not suited as unique id (#1964) Signed-off-by: Roman --- src/renderer/api/endpoints/helm-charts.api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index a1fd497798..8beff01779 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -86,7 +86,7 @@ export class HelmChart { tillerVersion?: string; getId() { - return this.digest; + return `${this.apiVersion}/${this.name}@${this.getAppVersion()}`; } getName() { From 1e8359851c1276ef75aef5b2b0093571810aeafa Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Wed, 20 Jan 2021 13:17:16 +0200 Subject: [PATCH 014/219] Upgrade shell-env to 3.0.1 (#1994) Signed-off-by: Lauri Nevala --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 798bfb7193..57c7a8a657 100644 --- a/package.json +++ b/package.json @@ -219,7 +219,7 @@ "request-promise-native": "^1.0.8", "semver": "^7.3.2", "serializr": "^2.0.3", - "shell-env": "^3.0.0", + "shell-env": "^3.0.1", "spdy": "^4.0.2", "tar": "^6.0.5", "tcp-port-used": "^1.0.1", diff --git a/yarn.lock b/yarn.lock index dc50f585ef..4cfa01fdf4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12141,10 +12141,10 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shell-env@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shell-env/-/shell-env-3.0.0.tgz#42484ebd0798ee321ba69f6151f2aeab13fde1d4" - integrity sha512-zE0lGldowbCLnnorLnOUO6gLSwEoW4u+qWcEV1HH2qje5sIg0PvBd+8ro74EgSZv0MBEP2dROD6vSKhGDbUIMQ== +shell-env@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/shell-env/-/shell-env-3.0.1.tgz#515a62f6cbd5e139365be2535745e8e53438ce77" + integrity sha512-b09fpMipAQ9ObwvIeKoQFLDXcEcCpYUUZanlad4OYQscw2I49C/u97OPQg9jWYo36bRDn62fbe07oWYqovIvKA== dependencies: default-shell "^1.0.1" execa "^1.0.0" From 55759fb3b8e7061f67ff1414160430c7910fbe27 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 20 Jan 2021 14:31:20 +0200 Subject: [PATCH 015/219] Add age column to cluster overview (#1970) Signed-off-by: Alex Culliere --- .../components/+cluster/cluster-issues.tsx | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/+cluster/cluster-issues.tsx b/src/renderer/components/+cluster/cluster-issues.tsx index eb85bf79f8..0aabebaa27 100644 --- a/src/renderer/components/+cluster/cluster-issues.tsx +++ b/src/renderer/components/+cluster/cluster-issues.tsx @@ -23,11 +23,13 @@ interface IWarning extends ItemObject { kind: string; message: string; selfLink: string; + age: string | number; } enum sortBy { type = "type", - object = "object" + object = "object", + age = "age", } @observer @@ -35,6 +37,7 @@ export class ClusterIssues extends React.Component { private sortCallbacks = { [sortBy.type]: (warning: IWarning) => warning.kind, [sortBy.object]: (warning: IWarning) => warning.getName(), + [sortBy.age]: (warning: IWarning) => warning.age || "", }; @computed get warnings() { @@ -42,15 +45,16 @@ export class ClusterIssues extends React.Component { // Node bad conditions nodesStore.items.forEach(node => { - const { kind, selfLink, getId, getName } = node; + const { kind, selfLink, getId, getName, getAge } = node; node.getWarningConditions().forEach(({ message }) => { warnings.push({ - kind, + age: getAge(), getId, getName, - selfLink, + kind, message, + selfLink, }); }); }); @@ -59,12 +63,13 @@ export class ClusterIssues extends React.Component { const events = eventStore.getWarnings(); events.forEach(error => { - const { message, involvedObject } = error; + const { message, involvedObject, getAge } = error; const { uid, name, kind } = involvedObject; warnings.push({ getId: () => uid, getName: () => name, + age: getAge(), message, kind, selfLink: lookupApiLink(involvedObject, error), @@ -78,7 +83,7 @@ export class ClusterIssues extends React.Component { getTableRow(uid: string) { const { warnings } = this; const warning = warnings.find(warn => warn.getId() == uid); - const { getId, getName, message, kind, selfLink } = warning; + const { getId, getName, message, kind, selfLink, age } = warning; return ( { {kind} + + {age} + ); } @@ -139,6 +147,7 @@ export class ClusterIssues extends React.Component { Message Object Type + Age

From 64be4ee948344c427cd23aecd8c3ef144063def7 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Thu, 21 Jan 2021 10:38:49 +0300 Subject: [PATCH 016/219] Fixing log tab layout colors (#1995) * Making "since" date visible as bolded text Signed-off-by: Alex Andreev * Fixing colors in log tab elements Signed-off-by: Alex Andreev --- src/renderer/components/dock/info-panel.scss | 14 +++++++++----- src/renderer/components/dock/log-controls.tsx | 7 ++++++- src/renderer/components/select/select.scss | 5 +---- src/renderer/themes/lens-dark.json | 2 ++ src/renderer/themes/lens-light.json | 4 +++- src/renderer/themes/theme-vars.scss | 4 +++- 6 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/dock/info-panel.scss b/src/renderer/components/dock/info-panel.scss index 482dbee02d..cf9b3268b2 100644 --- a/src/renderer/components/dock/info-panel.scss +++ b/src/renderer/components/dock/info-panel.scss @@ -1,12 +1,16 @@ .InfoPanel { @include hidden-scrollbar; - background: $dockInfoBackground; - padding: $padding $padding * 2; + background: var(--dockInfoBackground); + padding: var(--padding) calc(var(--padding) * 2); flex-shrink: 0; .Spinner { - margin-right: $padding; + margin-right: var(--padding); + } + + .Badge { + background-color: var(--dockBadgeBackground); } > .controls { @@ -15,8 +19,8 @@ &:not(:empty) + .info { min-height: 25px; - padding-left: $padding; - padding-right: $padding; + padding-left: var(--padding); + padding-right: var(--padding); } } } \ No newline at end of file diff --git a/src/renderer/components/dock/log-controls.tsx b/src/renderer/components/dock/log-controls.tsx index cedff7fbb9..06bbf12863 100644 --- a/src/renderer/components/dock/log-controls.tsx +++ b/src/renderer/components/dock/log-controls.tsx @@ -41,7 +41,12 @@ export const LogControls = observer((props: Props) => { return (
- {since && `Logs from ${new Date(since[0]).toLocaleString()}`} + {since && ( + + Logs from{" "} + {new Date(since[0]).toLocaleString()} + + )}
Date: Thu, 21 Jan 2021 08:09:41 -0500 Subject: [PATCH 017/219] enfore unix line endings and always ending files with line endings (#1997) Signed-off-by: Sebastian Malton --- .eslintrc.js | 6 ++++++ __mocks__/imageMock.ts | 2 +- __mocks__/styleMock.ts | 2 +- extensions/kube-object-event-status/src/resolver.tsx | 2 +- src/common/__tests__/search-store.test.ts | 2 +- src/common/__tests__/user-store.test.ts | 2 +- src/common/custom-errors.ts | 2 +- src/common/kube-helpers.ts | 2 +- src/common/prometheus-providers.ts | 2 +- src/common/search-store.ts | 2 +- src/common/utils/__tests__/splitArray.test.ts | 2 +- src/common/utils/singleton.ts | 2 +- src/extensions/interfaces/index.ts | 2 +- src/extensions/interfaces/registrations.ts | 2 +- src/extensions/renderer-api/kube-object-status.ts | 2 +- src/extensions/renderer-api/theming.ts | 2 +- src/main/cluster-detectors/base-cluster-detector.ts | 2 +- src/main/cluster-detectors/cluster-id-detector.ts | 2 +- src/main/cluster-detectors/detector-registry.ts | 2 +- src/main/cluster-detectors/last-seen-detector.ts | 2 +- src/main/cluster-detectors/nodes-count-detector.ts | 2 +- src/main/cluster-detectors/version-detector.ts | 2 +- src/migrations/cluster-store/index.ts | 2 +- src/renderer/api/__tests__/kube-api.test.ts | 2 +- src/renderer/api/workload-kube-object.ts | 2 +- .../components/+apps-helm-charts/helm-charts.route.ts | 2 +- src/renderer/components/+apps-helm-charts/index.ts | 2 +- src/renderer/components/+apps-releases/index.ts | 2 +- src/renderer/components/+apps/index.ts | 2 +- .../components/cluster-home-dir-setting.tsx | 2 +- .../+cluster-settings/components/cluster-name-setting.tsx | 2 +- .../+cluster-settings/components/cluster-proxy-setting.tsx | 2 +- src/renderer/components/+cluster-settings/general.tsx | 2 +- src/renderer/components/+cluster-settings/removal.tsx | 2 +- src/renderer/components/+cluster-settings/status.tsx | 2 +- src/renderer/components/+cluster/cluster-metrics.tsx | 2 +- src/renderer/components/+landing-page/index.tsx | 2 +- src/renderer/components/+pod-security-policies/index.ts | 2 +- .../components/+user-management-roles/roles.store.ts | 2 +- .../components/+user-management-service-accounts/index.ts | 2 +- src/renderer/components/+user-management/index.ts | 2 +- src/renderer/components/+workloads-pods/index.ts | 2 +- .../components/+workloads-pods/pod-details-statuses.tsx | 2 +- src/renderer/components/+workloads-statefulsets/index.ts | 2 +- src/renderer/components/ace-editor/ace-editor.tsx | 2 +- src/renderer/components/ace-editor/index.ts | 2 +- src/renderer/components/add-remove-buttons/index.ts | 2 +- src/renderer/components/animate/index.ts | 2 +- src/renderer/components/chart/background-block.plugin.ts | 2 +- src/renderer/components/chart/bar-chart.tsx | 2 +- src/renderer/components/chart/chart.tsx | 2 +- src/renderer/components/chart/index.ts | 2 +- src/renderer/components/chart/pie-chart.tsx | 2 +- src/renderer/components/chart/useRealTimeMetrics.ts | 2 +- src/renderer/components/chart/zebra-stripes.plugin.ts | 2 +- src/renderer/components/checkbox/checkbox.tsx | 2 +- src/renderer/components/checkbox/index.ts | 2 +- src/renderer/components/cluster-icon/index.ts | 2 +- src/renderer/components/confirm-dialog/index.ts | 2 +- src/renderer/components/dock/dock-tabs.tsx | 2 +- src/renderer/components/editable-list/index.ts | 2 +- src/renderer/components/error-boundary/index.ts | 2 +- src/renderer/components/file-picker/file-picker.tsx | 2 +- src/renderer/components/file-picker/index.ts | 2 +- src/renderer/components/icon/index.ts | 2 +- src/renderer/components/input/search-input-url.tsx | 2 +- src/renderer/components/item-object-list/index.tsx | 2 +- src/renderer/components/kube-object-status-icon/index.ts | 2 +- src/renderer/components/kubeconfig-dialog/index.ts | 2 +- .../components/layout/__test__/main-layout-header.test.tsx | 2 +- src/renderer/components/layout/login-layout.tsx | 2 +- src/renderer/components/layout/sidebar-context.ts | 2 +- src/renderer/components/line-progress/index.ts | 2 +- src/renderer/components/markdown-viewer/index.ts | 2 +- src/renderer/components/markdown-viewer/markdown-viewer.tsx | 2 +- src/renderer/components/radio/index.ts | 2 +- src/renderer/components/resource-metrics/index.ts | 2 +- src/renderer/components/slider/index.ts | 2 +- src/renderer/components/status-brick/index.ts | 2 +- src/renderer/components/status-brick/status-brick.tsx | 2 +- src/renderer/components/virtual-list/index.ts | 2 +- src/renderer/components/wizard/index.ts | 2 +- src/renderer/hooks/useInterval.ts | 2 +- src/renderer/hooks/useOnUnmount.ts | 2 +- src/renderer/hooks/useStorage.ts | 2 +- src/renderer/navigation/events.ts | 2 +- src/renderer/navigation/helpers.ts | 2 +- src/renderer/navigation/index.ts | 2 +- src/renderer/utils/__tests__/metricUnitsToNumber.test.ts | 2 +- src/renderer/utils/formatDuration.ts | 2 +- src/renderer/utils/jsonPath.ts | 2 +- types/command-exists.d.ts | 2 +- 92 files changed, 97 insertions(+), 91 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 3fd52c2465..57ee07348f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -46,6 +46,8 @@ module.exports = { "avoidEscape": true, "allowTemplateLiterals": true, }], + "linebreak-style": ["error", "unix"], + "eol-last": ["error", "always"], "semi": ["error", "always"], "object-shorthand": "error", "prefer-template": "error", @@ -101,6 +103,8 @@ module.exports = { }], "semi": "off", "@typescript-eslint/semi": ["error"], + "linebreak-style": ["error", "unix"], + "eol-last": ["error", "always"], "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", @@ -162,6 +166,8 @@ module.exports = { }], "semi": "off", "@typescript-eslint/semi": ["error"], + "linebreak-style": ["error", "unix"], + "eol-last": ["error", "always"], "object-shorthand": "error", "prefer-template": "error", "template-curly-spacing": "error", diff --git a/__mocks__/imageMock.ts b/__mocks__/imageMock.ts index a099545376..f053ebf797 100644 --- a/__mocks__/imageMock.ts +++ b/__mocks__/imageMock.ts @@ -1 +1 @@ -module.exports = {}; \ No newline at end of file +module.exports = {}; diff --git a/__mocks__/styleMock.ts b/__mocks__/styleMock.ts index a099545376..f053ebf797 100644 --- a/__mocks__/styleMock.ts +++ b/__mocks__/styleMock.ts @@ -1 +1 @@ -module.exports = {}; \ No newline at end of file +module.exports = {}; diff --git a/extensions/kube-object-event-status/src/resolver.tsx b/extensions/kube-object-event-status/src/resolver.tsx index 69691c2e79..5e9151288f 100644 --- a/extensions/kube-object-event-status/src/resolver.tsx +++ b/extensions/kube-object-event-status/src/resolver.tsx @@ -56,4 +56,4 @@ export function resolveStatusForCronJobs(cronJob: K8sApi.CronJob): K8sApi.KubeOb text: `${event.message}`, timestamp: event.metadata.creationTimestamp }; -} \ No newline at end of file +} diff --git a/src/common/__tests__/search-store.test.ts b/src/common/__tests__/search-store.test.ts index 7939ef1d8c..d361c858fd 100644 --- a/src/common/__tests__/search-store.test.ts +++ b/src/common/__tests__/search-store.test.ts @@ -77,4 +77,4 @@ describe("search store tests", () => { searchStore.onSearch(logs, "Starting"); expect(searchStore.totalFinds).toBe(2); }); -}); \ No newline at end of file +}); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 08ca359ce5..b74941a790 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -101,4 +101,4 @@ describe("user store tests", () => { expect(us.lastSeenAppVersion).toBe("0.0.0"); }); }); -}); \ No newline at end of file +}); diff --git a/src/common/custom-errors.ts b/src/common/custom-errors.ts index 9bcf3a998a..177ef7578f 100644 --- a/src/common/custom-errors.ts +++ b/src/common/custom-errors.ts @@ -10,4 +10,4 @@ export class ExecValidationNotFoundError extends Error { this.name = this.constructor.name; Error.captureStackTrace(this, this.constructor); } -} \ No newline at end of file +} diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index bb0e6b86d2..02a9faef92 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -175,4 +175,4 @@ export function validateKubeConfig (config: KubeConfig) { throw new ExecValidationNotFoundError(execCommand, isAbsolute); } } -} \ No newline at end of file +} diff --git a/src/common/prometheus-providers.ts b/src/common/prometheus-providers.ts index a5c515b338..5496163c38 100644 --- a/src/common/prometheus-providers.ts +++ b/src/common/prometheus-providers.ts @@ -10,4 +10,4 @@ import { PrometheusProviderRegistry } from "../main/prometheus/provider-registry PrometheusProviderRegistry.registerProvider(provider.id, provider); }); -export const prometheusProviders = PrometheusProviderRegistry.getProviders(); \ No newline at end of file +export const prometheusProviders = PrometheusProviderRegistry.getProviders(); diff --git a/src/common/search-store.ts b/src/common/search-store.ts index a3aba9dcbe..eb2517ca0f 100644 --- a/src/common/search-store.ts +++ b/src/common/search-store.ts @@ -133,4 +133,4 @@ export class SearchStore { } } -export const searchStore = new SearchStore; \ No newline at end of file +export const searchStore = new SearchStore; diff --git a/src/common/utils/__tests__/splitArray.test.ts b/src/common/utils/__tests__/splitArray.test.ts index a401e07701..1e1589fee2 100644 --- a/src/common/utils/__tests__/splitArray.test.ts +++ b/src/common/utils/__tests__/splitArray.test.ts @@ -28,4 +28,4 @@ describe("split array on element tests", () => { test("ten elements, in end array", () => { expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 9)).toStrictEqual([[0, 1, 2, 3, 4, 5, 6, 7, 8], [], true]); }); -}); \ No newline at end of file +}); diff --git a/src/common/utils/singleton.ts b/src/common/utils/singleton.ts index 61269d10b1..caa5471072 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -26,4 +26,4 @@ class Singleton { } export { Singleton }; -export default Singleton; \ No newline at end of file +export default Singleton; diff --git a/src/extensions/interfaces/index.ts b/src/extensions/interfaces/index.ts index c91d8cdd19..7b1c601537 100644 --- a/src/extensions/interfaces/index.ts +++ b/src/extensions/interfaces/index.ts @@ -1 +1 @@ -export * from "./registrations"; \ No newline at end of file +export * from "./registrations"; diff --git a/src/extensions/interfaces/registrations.ts b/src/extensions/interfaces/registrations.ts index 47c63062ea..ff51d9a824 100644 --- a/src/extensions/interfaces/registrations.ts +++ b/src/extensions/interfaces/registrations.ts @@ -5,4 +5,4 @@ export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../re export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry"; export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; -export type { StatusBarRegistration } from "../registries/status-bar-registry"; \ No newline at end of file +export type { StatusBarRegistration } from "../registries/status-bar-registry"; diff --git a/src/extensions/renderer-api/kube-object-status.ts b/src/extensions/renderer-api/kube-object-status.ts index f609d736fe..616ead1bb2 100644 --- a/src/extensions/renderer-api/kube-object-status.ts +++ b/src/extensions/renderer-api/kube-object-status.ts @@ -8,4 +8,4 @@ export enum KubeObjectStatusLevel { INFO = 1, WARNING = 2, CRITICAL = 3 -} \ No newline at end of file +} diff --git a/src/extensions/renderer-api/theming.ts b/src/extensions/renderer-api/theming.ts index f819036803..b3da69bdbc 100644 --- a/src/extensions/renderer-api/theming.ts +++ b/src/extensions/renderer-api/theming.ts @@ -2,4 +2,4 @@ import { themeStore } from "../../renderer/theme.store"; export function getActiveTheme() { return themeStore.activeTheme; -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/base-cluster-detector.ts b/src/main/cluster-detectors/base-cluster-detector.ts index 9d52e1a70e..885f96c33e 100644 --- a/src/main/cluster-detectors/base-cluster-detector.ts +++ b/src/main/cluster-detectors/base-cluster-detector.ts @@ -31,4 +31,4 @@ export class BaseClusterDetector { }, }); } -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/cluster-id-detector.ts b/src/main/cluster-detectors/cluster-id-detector.ts index 2e0cc694ff..810955afae 100644 --- a/src/main/cluster-detectors/cluster-id-detector.ts +++ b/src/main/cluster-detectors/cluster-id-detector.ts @@ -23,4 +23,4 @@ export class ClusterIdDetector extends BaseClusterDetector { return response.metadata.uid; } -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/detector-registry.ts b/src/main/cluster-detectors/detector-registry.ts index 43c56153c9..b1d1b73447 100644 --- a/src/main/cluster-detectors/detector-registry.ts +++ b/src/main/cluster-detectors/detector-registry.ts @@ -48,4 +48,4 @@ detectorRegistry.add(ClusterIdDetector); detectorRegistry.add(LastSeenDetector); detectorRegistry.add(VersionDetector); detectorRegistry.add(DistributionDetector); -detectorRegistry.add(NodesCountDetector); \ No newline at end of file +detectorRegistry.add(NodesCountDetector); diff --git a/src/main/cluster-detectors/last-seen-detector.ts b/src/main/cluster-detectors/last-seen-detector.ts index e648d5f2f9..0a9bcf9f74 100644 --- a/src/main/cluster-detectors/last-seen-detector.ts +++ b/src/main/cluster-detectors/last-seen-detector.ts @@ -11,4 +11,4 @@ export class LastSeenDetector extends BaseClusterDetector { return { value: new Date().toJSON(), accuracy: 100 }; } -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/nodes-count-detector.ts b/src/main/cluster-detectors/nodes-count-detector.ts index 0ece5dd080..45584df5bd 100644 --- a/src/main/cluster-detectors/nodes-count-detector.ts +++ b/src/main/cluster-detectors/nodes-count-detector.ts @@ -16,4 +16,4 @@ export class NodesCountDetector extends BaseClusterDetector { return response.items.length; } -} \ No newline at end of file +} diff --git a/src/main/cluster-detectors/version-detector.ts b/src/main/cluster-detectors/version-detector.ts index 8080ef57a1..b19979db8a 100644 --- a/src/main/cluster-detectors/version-detector.ts +++ b/src/main/cluster-detectors/version-detector.ts @@ -16,4 +16,4 @@ export class VersionDetector extends BaseClusterDetector { return response.gitVersion; } -} \ No newline at end of file +} diff --git a/src/migrations/cluster-store/index.ts b/src/migrations/cluster-store/index.ts index c546fdaeda..4a71d4f7ad 100644 --- a/src/migrations/cluster-store/index.ts +++ b/src/migrations/cluster-store/index.ts @@ -18,4 +18,4 @@ export default { ...version270Beta1, ...version360Beta1, ...snap -}; \ No newline at end of file +}; diff --git a/src/renderer/api/__tests__/kube-api.test.ts b/src/renderer/api/__tests__/kube-api.test.ts index 41078e77a3..7481bd096a 100644 --- a/src/renderer/api/__tests__/kube-api.test.ts +++ b/src/renderer/api/__tests__/kube-api.test.ts @@ -79,4 +79,4 @@ describe("KubeApi", () => { expect(kubeApi.apiPrefix).toEqual("/apis"); expect(kubeApi.apiGroup).toEqual("extensions"); }); -}); \ No newline at end of file +}); diff --git a/src/renderer/api/workload-kube-object.ts b/src/renderer/api/workload-kube-object.ts index e0c6d3f121..c6786aa99f 100644 --- a/src/renderer/api/workload-kube-object.ts +++ b/src/renderer/api/workload-kube-object.ts @@ -82,4 +82,4 @@ export class WorkloadKubeObject extends KubeObject { return Object.keys(affinity).length; } -} \ No newline at end of file +} diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.route.ts b/src/renderer/components/+apps-helm-charts/helm-charts.route.ts index 9b8aecc499..97a0923d97 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.route.ts +++ b/src/renderer/components/+apps-helm-charts/helm-charts.route.ts @@ -11,4 +11,4 @@ export interface IHelmChartsRouteParams { repo?: string; } -export const helmChartsURL = buildURL(helmChartsRoute.path); \ No newline at end of file +export const helmChartsURL = buildURL(helmChartsRoute.path); diff --git a/src/renderer/components/+apps-helm-charts/index.ts b/src/renderer/components/+apps-helm-charts/index.ts index a9403c097c..c0649f3f38 100644 --- a/src/renderer/components/+apps-helm-charts/index.ts +++ b/src/renderer/components/+apps-helm-charts/index.ts @@ -1,2 +1,2 @@ export * from "./helm-charts"; -export * from "./helm-charts.route"; \ No newline at end of file +export * from "./helm-charts.route"; diff --git a/src/renderer/components/+apps-releases/index.ts b/src/renderer/components/+apps-releases/index.ts index 32a4871769..bd80c60404 100644 --- a/src/renderer/components/+apps-releases/index.ts +++ b/src/renderer/components/+apps-releases/index.ts @@ -1,2 +1,2 @@ export * from "./releases"; -export * from "./release.route"; \ No newline at end of file +export * from "./release.route"; diff --git a/src/renderer/components/+apps/index.ts b/src/renderer/components/+apps/index.ts index 70c0169777..330891b2b1 100644 --- a/src/renderer/components/+apps/index.ts +++ b/src/renderer/components/+apps/index.ts @@ -1,2 +1,2 @@ export * from "./apps"; -export * from "./apps.route"; \ No newline at end of file +export * from "./apps.route"; 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 35c18cc5e5..10aabf3ff7 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 @@ -48,4 +48,4 @@ export class ClusterHomeDirSetting extends React.Component { ); } -} \ 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 631c6d54ef..9d953ef9ca 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-name-setting.tsx @@ -45,4 +45,4 @@ export class ClusterNameSetting extends React.Component { ); } -} \ 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 3887487816..eb122ac444 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx @@ -45,4 +45,4 @@ export class ClusterProxySetting extends React.Component { ); } -} \ 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 1d498bc94b..91fce05164 100644 --- a/src/renderer/components/+cluster-settings/general.tsx +++ b/src/renderer/components/+cluster-settings/general.tsx @@ -25,4 +25,4 @@ export class General extends React.Component {
; } -} \ No newline at end of file +} diff --git a/src/renderer/components/+cluster-settings/removal.tsx b/src/renderer/components/+cluster-settings/removal.tsx index 7d97e9c515..495fb71fe8 100644 --- a/src/renderer/components/+cluster-settings/removal.tsx +++ b/src/renderer/components/+cluster-settings/removal.tsx @@ -17,4 +17,4 @@ export class Removal extends React.Component {
); } -} \ 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 7f21d19aba..d43cfe5c35 100644 --- a/src/renderer/components/+cluster-settings/status.tsx +++ b/src/renderer/components/+cluster-settings/status.tsx @@ -58,4 +58,4 @@ export class Status extends React.Component { ; } -} \ No newline at end of file +} diff --git a/src/renderer/components/+cluster/cluster-metrics.tsx b/src/renderer/components/+cluster/cluster-metrics.tsx index 6461bae7f3..2bdaded8b7 100644 --- a/src/renderer/components/+cluster/cluster-metrics.tsx +++ b/src/renderer/components/+cluster/cluster-metrics.tsx @@ -95,4 +95,4 @@ export const ClusterMetrics = observer(() => { {renderMetrics()} ); -}); \ No newline at end of file +}); diff --git a/src/renderer/components/+landing-page/index.tsx b/src/renderer/components/+landing-page/index.tsx index 4bdb2a706c..c7eacf1bd0 100644 --- a/src/renderer/components/+landing-page/index.tsx +++ b/src/renderer/components/+landing-page/index.tsx @@ -1,2 +1,2 @@ export * from "./landing-page.route"; -export * from "./landing-page"; \ No newline at end of file +export * from "./landing-page"; diff --git a/src/renderer/components/+pod-security-policies/index.ts b/src/renderer/components/+pod-security-policies/index.ts index d037873b5b..c9379d3381 100644 --- a/src/renderer/components/+pod-security-policies/index.ts +++ b/src/renderer/components/+pod-security-policies/index.ts @@ -1,3 +1,3 @@ export * from "./pod-security-policies.route"; export * from "./pod-security-policies"; -export * from "./pod-security-policy-details"; \ No newline at end of file +export * from "./pod-security-policy-details"; diff --git a/src/renderer/components/+user-management-roles/roles.store.ts b/src/renderer/components/+user-management-roles/roles.store.ts index 6af33deacb..7b6c6c2397 100644 --- a/src/renderer/components/+user-management-roles/roles.store.ts +++ b/src/renderer/components/+user-management-roles/roles.store.ts @@ -49,4 +49,4 @@ export const rolesStore = new RolesStore(); apiManager.registerStore(rolesStore, [ roleApi, clusterRoleApi, -]); \ No newline at end of file +]); diff --git a/src/renderer/components/+user-management-service-accounts/index.ts b/src/renderer/components/+user-management-service-accounts/index.ts index fd45e28288..bd81292bf1 100644 --- a/src/renderer/components/+user-management-service-accounts/index.ts +++ b/src/renderer/components/+user-management-service-accounts/index.ts @@ -1,3 +1,3 @@ export * from "./service-accounts"; export * from "./service-accounts-details"; -export * from "./create-service-account-dialog"; \ No newline at end of file +export * from "./create-service-account-dialog"; diff --git a/src/renderer/components/+user-management/index.ts b/src/renderer/components/+user-management/index.ts index 6f29869b9b..4ff825df97 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.route"; \ No newline at end of file +export * from "./user-management.route"; diff --git a/src/renderer/components/+workloads-pods/index.ts b/src/renderer/components/+workloads-pods/index.ts index f3181cb3a2..cc7782911c 100644 --- a/src/renderer/components/+workloads-pods/index.ts +++ b/src/renderer/components/+workloads-pods/index.ts @@ -1,2 +1,2 @@ export * from "./pods"; -export * from "./pod-details"; \ No newline at end of file +export * from "./pod-details"; diff --git a/src/renderer/components/+workloads-pods/pod-details-statuses.tsx b/src/renderer/components/+workloads-pods/pod-details-statuses.tsx index 5ce8465e72..1e0f765381 100644 --- a/src/renderer/components/+workloads-pods/pod-details-statuses.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-statuses.tsx @@ -27,4 +27,4 @@ export class PodDetailsStatuses extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/+workloads-statefulsets/index.ts b/src/renderer/components/+workloads-statefulsets/index.ts index 1cb72d701a..af942b604f 100644 --- a/src/renderer/components/+workloads-statefulsets/index.ts +++ b/src/renderer/components/+workloads-statefulsets/index.ts @@ -1,2 +1,2 @@ export * from "./statefulsets"; -export * from "./statefulset-details"; \ No newline at end of file +export * from "./statefulset-details"; diff --git a/src/renderer/components/ace-editor/ace-editor.tsx b/src/renderer/components/ace-editor/ace-editor.tsx index 68ef92635a..2dceb48bba 100644 --- a/src/renderer/components/ace-editor/ace-editor.tsx +++ b/src/renderer/components/ace-editor/ace-editor.tsx @@ -154,4 +154,4 @@ export class AceEditor extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/ace-editor/index.ts b/src/renderer/components/ace-editor/index.ts index 7bfc7c01ea..173845abab 100644 --- a/src/renderer/components/ace-editor/index.ts +++ b/src/renderer/components/ace-editor/index.ts @@ -1 +1 @@ -export * from "./ace-editor"; \ No newline at end of file +export * from "./ace-editor"; diff --git a/src/renderer/components/add-remove-buttons/index.ts b/src/renderer/components/add-remove-buttons/index.ts index 825c59d7d2..fa2deb84ec 100644 --- a/src/renderer/components/add-remove-buttons/index.ts +++ b/src/renderer/components/add-remove-buttons/index.ts @@ -1 +1 @@ -export * from "./add-remove-buttons"; \ No newline at end of file +export * from "./add-remove-buttons"; diff --git a/src/renderer/components/animate/index.ts b/src/renderer/components/animate/index.ts index 080c5446c8..36d812de20 100644 --- a/src/renderer/components/animate/index.ts +++ b/src/renderer/components/animate/index.ts @@ -1 +1 @@ -export * from "./animate"; \ No newline at end of file +export * from "./animate"; diff --git a/src/renderer/components/chart/background-block.plugin.ts b/src/renderer/components/chart/background-block.plugin.ts index 1d39f71aed..ff4816c4dd 100644 --- a/src/renderer/components/chart/background-block.plugin.ts +++ b/src/renderer/components/chart/background-block.plugin.ts @@ -39,4 +39,4 @@ export const BackgroundBlock = { ctx.stroke(); ctx.restore(); } -}; \ No newline at end of file +}; diff --git a/src/renderer/components/chart/bar-chart.tsx b/src/renderer/components/chart/bar-chart.tsx index 69b65b10c9..4f80258703 100644 --- a/src/renderer/components/chart/bar-chart.tsx +++ b/src/renderer/components/chart/bar-chart.tsx @@ -220,4 +220,4 @@ export const cpuOptions: ChartOptions = { } } } -}; \ No newline at end of file +}; diff --git a/src/renderer/components/chart/chart.tsx b/src/renderer/components/chart/chart.tsx index b7e621be22..e42a308848 100644 --- a/src/renderer/components/chart/chart.tsx +++ b/src/renderer/components/chart/chart.tsx @@ -213,4 +213,4 @@ export class Chart extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/chart/index.ts b/src/renderer/components/chart/index.ts index a9db66c298..d75ddf7a2f 100644 --- a/src/renderer/components/chart/index.ts +++ b/src/renderer/components/chart/index.ts @@ -1,3 +1,3 @@ export * from "./chart"; export * from "./pie-chart"; -export * from "./bar-chart"; \ No newline at end of file +export * from "./bar-chart"; diff --git a/src/renderer/components/chart/pie-chart.tsx b/src/renderer/components/chart/pie-chart.tsx index 1c629ab505..939d6bb612 100644 --- a/src/renderer/components/chart/pie-chart.tsx +++ b/src/renderer/components/chart/pie-chart.tsx @@ -64,4 +64,4 @@ export class PieChart extends React.Component { ChartJS.Tooltip.positioners.cursor = function (elements: any, position: { x: number; y: number }) { return position; -}; \ No newline at end of file +}; diff --git a/src/renderer/components/chart/useRealTimeMetrics.ts b/src/renderer/components/chart/useRealTimeMetrics.ts index 69e4e3da7f..b01629e8e9 100644 --- a/src/renderer/components/chart/useRealTimeMetrics.ts +++ b/src/renderer/components/chart/useRealTimeMetrics.ts @@ -42,4 +42,4 @@ export function useRealTimeMetrics(metrics: IMetricValues, chartData: IChartData } return data; -} \ No newline at end of file +} diff --git a/src/renderer/components/chart/zebra-stripes.plugin.ts b/src/renderer/components/chart/zebra-stripes.plugin.ts index f934f88fb2..3a85f8d0a2 100644 --- a/src/renderer/components/chart/zebra-stripes.plugin.ts +++ b/src/renderer/components/chart/zebra-stripes.plugin.ts @@ -95,4 +95,4 @@ export const ZebraStripes = { cover.style.backgroundPositionX = `${-step * minutes}px`; } } -}; \ No newline at end of file +}; diff --git a/src/renderer/components/checkbox/checkbox.tsx b/src/renderer/components/checkbox/checkbox.tsx index f97740a874..8d452a1198 100644 --- a/src/renderer/components/checkbox/checkbox.tsx +++ b/src/renderer/components/checkbox/checkbox.tsx @@ -50,4 +50,4 @@ export class Checkbox extends React.PureComponent { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/checkbox/index.ts b/src/renderer/components/checkbox/index.ts index 7af8873e06..057f167821 100644 --- a/src/renderer/components/checkbox/index.ts +++ b/src/renderer/components/checkbox/index.ts @@ -1 +1 @@ -export * from "./checkbox"; \ No newline at end of file +export * from "./checkbox"; diff --git a/src/renderer/components/cluster-icon/index.ts b/src/renderer/components/cluster-icon/index.ts index 4e1858939f..7879490b85 100644 --- a/src/renderer/components/cluster-icon/index.ts +++ b/src/renderer/components/cluster-icon/index.ts @@ -1 +1 @@ -export * from "./cluster-icon"; \ No newline at end of file +export * from "./cluster-icon"; diff --git a/src/renderer/components/confirm-dialog/index.ts b/src/renderer/components/confirm-dialog/index.ts index dfcd83ded3..4627fd6882 100644 --- a/src/renderer/components/confirm-dialog/index.ts +++ b/src/renderer/components/confirm-dialog/index.ts @@ -1 +1 @@ -export * from "./confirm-dialog"; \ No newline at end of file +export * from "./confirm-dialog"; diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx index 6bf9280d59..554411024b 100644 --- a/src/renderer/components/dock/dock-tabs.tsx +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -48,4 +48,4 @@ export const DockTabs = ({ tabs, autoFocus, selectedTab, onChangeTab }: Props) = {tabs.map(tab => {renderTab(tab)})} ); -}; \ No newline at end of file +}; diff --git a/src/renderer/components/editable-list/index.ts b/src/renderer/components/editable-list/index.ts index cc0293acd6..1dc93d5df7 100644 --- a/src/renderer/components/editable-list/index.ts +++ b/src/renderer/components/editable-list/index.ts @@ -1 +1 @@ -export * from "./editable-list"; \ No newline at end of file +export * from "./editable-list"; diff --git a/src/renderer/components/error-boundary/index.ts b/src/renderer/components/error-boundary/index.ts index cdcf838466..90e954fb2e 100644 --- a/src/renderer/components/error-boundary/index.ts +++ b/src/renderer/components/error-boundary/index.ts @@ -1 +1 @@ -export * from "./error-boundary"; \ No newline at end of file +export * from "./error-boundary"; diff --git a/src/renderer/components/file-picker/file-picker.tsx b/src/renderer/components/file-picker/file-picker.tsx index 14cd6c07e8..1a52fe4973 100644 --- a/src/renderer/components/file-picker/file-picker.tsx +++ b/src/renderer/components/file-picker/file-picker.tsx @@ -209,4 +209,4 @@ export class FilePicker extends React.Component { 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 index f58aec1470..28c490afab 100644 --- a/src/renderer/components/file-picker/index.ts +++ b/src/renderer/components/file-picker/index.ts @@ -1 +1 @@ -export * from "./file-picker"; \ No newline at end of file +export * from "./file-picker"; diff --git a/src/renderer/components/icon/index.ts b/src/renderer/components/icon/index.ts index 5cdcefa69c..b975409af4 100644 --- a/src/renderer/components/icon/index.ts +++ b/src/renderer/components/icon/index.ts @@ -1 +1 @@ -export * from "./icon"; \ No newline at end of file +export * from "./icon"; diff --git a/src/renderer/components/input/search-input-url.tsx b/src/renderer/components/input/search-input-url.tsx index 2b1045ede2..c0e00d6e56 100644 --- a/src/renderer/components/input/search-input-url.tsx +++ b/src/renderer/components/input/search-input-url.tsx @@ -54,4 +54,4 @@ export class SearchInputUrl extends React.Component { /> ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/item-object-list/index.tsx b/src/renderer/components/item-object-list/index.tsx index b0b106b298..87ba0e908a 100644 --- a/src/renderer/components/item-object-list/index.tsx +++ b/src/renderer/components/item-object-list/index.tsx @@ -1 +1 @@ -export * from "./item-list-layout"; \ No newline at end of file +export * from "./item-list-layout"; diff --git a/src/renderer/components/kube-object-status-icon/index.ts b/src/renderer/components/kube-object-status-icon/index.ts index 36751596a0..3ef2e6b29c 100644 --- a/src/renderer/components/kube-object-status-icon/index.ts +++ b/src/renderer/components/kube-object-status-icon/index.ts @@ -1 +1 @@ -export * from "./kube-object-status-icon"; \ No newline at end of file +export * from "./kube-object-status-icon"; diff --git a/src/renderer/components/kubeconfig-dialog/index.ts b/src/renderer/components/kubeconfig-dialog/index.ts index fdd244fe98..cb8c90cc14 100644 --- a/src/renderer/components/kubeconfig-dialog/index.ts +++ b/src/renderer/components/kubeconfig-dialog/index.ts @@ -1 +1 @@ -export * from "./kubeconfig-dialog"; \ No newline at end of file +export * from "./kubeconfig-dialog"; diff --git a/src/renderer/components/layout/__test__/main-layout-header.test.tsx b/src/renderer/components/layout/__test__/main-layout-header.test.tsx index b2a7bb5d93..499839072c 100644 --- a/src/renderer/components/layout/__test__/main-layout-header.test.tsx +++ b/src/renderer/components/layout/__test__/main-layout-header.test.tsx @@ -46,4 +46,4 @@ describe("", () => { expect(getByText("minikube")).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/src/renderer/components/layout/login-layout.tsx b/src/renderer/components/layout/login-layout.tsx index 8aa9c08e0b..669f783769 100755 --- a/src/renderer/components/layout/login-layout.tsx +++ b/src/renderer/components/layout/login-layout.tsx @@ -34,4 +34,4 @@ export class LoginLayout extends React.Component { ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/layout/sidebar-context.ts b/src/renderer/components/layout/sidebar-context.ts index fff192cba3..7001bbc319 100644 --- a/src/renderer/components/layout/sidebar-context.ts +++ b/src/renderer/components/layout/sidebar-context.ts @@ -4,4 +4,4 @@ export const SidebarContext = React.createContext({ pinned: export type SidebarContextValue = { pinned: boolean; -}; \ No newline at end of file +}; diff --git a/src/renderer/components/line-progress/index.ts b/src/renderer/components/line-progress/index.ts index 91942d706a..bd76106dbb 100644 --- a/src/renderer/components/line-progress/index.ts +++ b/src/renderer/components/line-progress/index.ts @@ -1 +1 @@ -export * from "./line-progress"; \ No newline at end of file +export * from "./line-progress"; diff --git a/src/renderer/components/markdown-viewer/index.ts b/src/renderer/components/markdown-viewer/index.ts index e82c6ba3c3..3c42af15f4 100644 --- a/src/renderer/components/markdown-viewer/index.ts +++ b/src/renderer/components/markdown-viewer/index.ts @@ -1 +1 @@ -export * from "./markdown-viewer"; \ No newline at end of file +export * from "./markdown-viewer"; diff --git a/src/renderer/components/markdown-viewer/markdown-viewer.tsx b/src/renderer/components/markdown-viewer/markdown-viewer.tsx index b1a2334b97..08478cb5a9 100644 --- a/src/renderer/components/markdown-viewer/markdown-viewer.tsx +++ b/src/renderer/components/markdown-viewer/markdown-viewer.tsx @@ -34,4 +34,4 @@ export class MarkdownViewer extends Component { /> ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/radio/index.ts b/src/renderer/components/radio/index.ts index 577923ef9c..0df0f30e50 100644 --- a/src/renderer/components/radio/index.ts +++ b/src/renderer/components/radio/index.ts @@ -1 +1 @@ -export * from "./radio"; \ No newline at end of file +export * from "./radio"; diff --git a/src/renderer/components/resource-metrics/index.ts b/src/renderer/components/resource-metrics/index.ts index 5438f760b4..a50f74ea8e 100644 --- a/src/renderer/components/resource-metrics/index.ts +++ b/src/renderer/components/resource-metrics/index.ts @@ -1,2 +1,2 @@ export * from "./resource-metrics"; -export * from "./resource-metrics-text"; \ No newline at end of file +export * from "./resource-metrics-text"; diff --git a/src/renderer/components/slider/index.ts b/src/renderer/components/slider/index.ts index bc79daa3ff..67c45bb063 100644 --- a/src/renderer/components/slider/index.ts +++ b/src/renderer/components/slider/index.ts @@ -1 +1 @@ -export * from "./slider"; \ No newline at end of file +export * from "./slider"; diff --git a/src/renderer/components/status-brick/index.ts b/src/renderer/components/status-brick/index.ts index e16a2a8093..cc6d3e8879 100644 --- a/src/renderer/components/status-brick/index.ts +++ b/src/renderer/components/status-brick/index.ts @@ -1 +1 @@ -export * from "./status-brick"; \ No newline at end of file +export * from "./status-brick"; diff --git a/src/renderer/components/status-brick/status-brick.tsx b/src/renderer/components/status-brick/status-brick.tsx index 34c835c9fa..ced04acca4 100644 --- a/src/renderer/components/status-brick/status-brick.tsx +++ b/src/renderer/components/status-brick/status-brick.tsx @@ -19,4 +19,4 @@ export class StatusBrick extends React.Component { /> ); } -} \ No newline at end of file +} diff --git a/src/renderer/components/virtual-list/index.ts b/src/renderer/components/virtual-list/index.ts index 4e5b065f43..3fad81848e 100644 --- a/src/renderer/components/virtual-list/index.ts +++ b/src/renderer/components/virtual-list/index.ts @@ -1 +1 @@ -export * from "./virtual-list"; \ No newline at end of file +export * from "./virtual-list"; diff --git a/src/renderer/components/wizard/index.ts b/src/renderer/components/wizard/index.ts index b217e311a9..da693bd87f 100644 --- a/src/renderer/components/wizard/index.ts +++ b/src/renderer/components/wizard/index.ts @@ -1 +1 @@ -export * from "./wizard"; \ No newline at end of file +export * from "./wizard"; diff --git a/src/renderer/hooks/useInterval.ts b/src/renderer/hooks/useInterval.ts index d195fa279f..7ab604511b 100644 --- a/src/renderer/hooks/useInterval.ts +++ b/src/renderer/hooks/useInterval.ts @@ -16,4 +16,4 @@ export function useInterval(callback: () => void, delay: number) { return () => clearInterval(id); }, [delay]); -} \ No newline at end of file +} diff --git a/src/renderer/hooks/useOnUnmount.ts b/src/renderer/hooks/useOnUnmount.ts index 5af04e39b1..a8b6fdd1b4 100644 --- a/src/renderer/hooks/useOnUnmount.ts +++ b/src/renderer/hooks/useOnUnmount.ts @@ -2,4 +2,4 @@ import { useEffect } from "react"; export function useOnUnmount(callback: () => void) { useEffect(() => callback, []); -} \ No newline at end of file +} diff --git a/src/renderer/hooks/useStorage.ts b/src/renderer/hooks/useStorage.ts index 2af730fec8..97b0588d29 100644 --- a/src/renderer/hooks/useStorage.ts +++ b/src/renderer/hooks/useStorage.ts @@ -10,4 +10,4 @@ export function useStorage(key: string, initialValue?: T, options?: IStorageH }; return [storageValue, setValue] as [T, (value: T) => void]; -} \ No newline at end of file +} diff --git a/src/renderer/navigation/events.ts b/src/renderer/navigation/events.ts index 971465706d..1766a1e0d3 100644 --- a/src/renderer/navigation/events.ts +++ b/src/renderer/navigation/events.ts @@ -28,4 +28,4 @@ export function bindEvents() { subscribeToBroadcast("renderer:reload", () => { location.reload(); }); -} \ No newline at end of file +} diff --git a/src/renderer/navigation/helpers.ts b/src/renderer/navigation/helpers.ts index 378f6edb96..0eda77c629 100644 --- a/src/renderer/navigation/helpers.ts +++ b/src/renderer/navigation/helpers.ts @@ -33,4 +33,4 @@ export function getMatchedClusterId(): string { }); return matched?.params.clusterId; -} \ No newline at end of file +} diff --git a/src/renderer/navigation/index.ts b/src/renderer/navigation/index.ts index 70959c2dbd..94930fc994 100644 --- a/src/renderer/navigation/index.ts +++ b/src/renderer/navigation/index.ts @@ -5,4 +5,4 @@ import { bindEvents } from "./events"; export * from "./history"; export * from "./helpers"; -bindEvents(); \ No newline at end of file +bindEvents(); diff --git a/src/renderer/utils/__tests__/metricUnitsToNumber.test.ts b/src/renderer/utils/__tests__/metricUnitsToNumber.test.ts index e94c2f3b67..a22aa46790 100644 --- a/src/renderer/utils/__tests__/metricUnitsToNumber.test.ts +++ b/src/renderer/utils/__tests__/metricUnitsToNumber.test.ts @@ -12,4 +12,4 @@ describe("metricUnitsToNumber tests", () => { test("with m suffix", () => { expect(metricUnitsToNumber("124m")).toStrictEqual(124000000); }); -}); \ No newline at end of file +}); diff --git a/src/renderer/utils/formatDuration.ts b/src/renderer/utils/formatDuration.ts index 8864aba393..87da6cfa64 100644 --- a/src/renderer/utils/formatDuration.ts +++ b/src/renderer/utils/formatDuration.ts @@ -83,4 +83,4 @@ function getMeaningfulValues(values: number[], suffixes: string[], separator = " .filter(([dur]) => dur > 0) .map(([dur, suf]) => dur + suf) .join(separator); -} \ No newline at end of file +} diff --git a/src/renderer/utils/jsonPath.ts b/src/renderer/utils/jsonPath.ts index ea31ffa80e..79075500f9 100644 --- a/src/renderer/utils/jsonPath.ts +++ b/src/renderer/utils/jsonPath.ts @@ -32,4 +32,4 @@ function convertToIndexNotation(key: string, firstItem = false) { return `${prefix}${key}`; } -} \ No newline at end of file +} diff --git a/types/command-exists.d.ts b/types/command-exists.d.ts index b5375ae390..634d2a035e 100644 --- a/types/command-exists.d.ts +++ b/types/command-exists.d.ts @@ -13,4 +13,4 @@ declare function commandExists( declare namespace commandExists { function sync(commandName: string): boolean; -} \ No newline at end of file +} From a92ed46f0d0c0bb9fd247bb01094781de4efc243 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Fri, 22 Jan 2021 10:27:54 +0300 Subject: [PATCH 018/219] Fixing tolerations list layout (#2002) * Expanding tolerations div width Signed-off-by: Alex Andreev * Adding tolerations table Signed-off-by: Alex Andreev * Fixing tolerations table styles Signed-off-by: Alex Andreev * Adding tests Signed-off-by: Alex Andreev * Add new line at the end of the file for linter Signed-off-by: Alex Andreev --- src/renderer/api/workload-kube-object.ts | 2 +- .../__tests__/pod-tolerations.test.tsx | 59 +++++++++++++++++ .../pod-details-tolerations.scss | 22 ++++++- .../pod-details-tolerations.tsx | 20 ++---- .../+workloads-pods/pod-tolerations.scss | 14 +++++ .../+workloads-pods/pod-tolerations.tsx | 63 +++++++++++++++++++ .../components/drawer/drawer-item.scss | 4 +- .../drawer/drawer-param-toggler.tsx | 2 +- src/renderer/components/drawer/drawer.scss | 1 - 9 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx create mode 100644 src/renderer/components/+workloads-pods/pod-tolerations.scss create mode 100644 src/renderer/components/+workloads-pods/pod-tolerations.tsx diff --git a/src/renderer/api/workload-kube-object.ts b/src/renderer/api/workload-kube-object.ts index c6786aa99f..185d3d502c 100644 --- a/src/renderer/api/workload-kube-object.ts +++ b/src/renderer/api/workload-kube-object.ts @@ -1,7 +1,7 @@ import get from "lodash/get"; import { KubeObject } from "./kube-object"; -interface IToleration { +export interface IToleration { key?: string; operator?: string; effect?: string; diff --git a/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx new file mode 100644 index 0000000000..dbde813e5a --- /dev/null +++ b/src/renderer/components/+workloads-pods/__tests__/pod-tolerations.test.tsx @@ -0,0 +1,59 @@ +/** + * @jest-environment jsdom + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { fireEvent, render } from "@testing-library/react"; +import { IToleration } from "../../../api/workload-kube-object"; +import { PodTolerations } from "../pod-tolerations"; + +const tolerations: IToleration[] =[ + { + key: "CriticalAddonsOnly", + operator: "Exist", + effect: "NoExecute", + tolerationSeconds: 3600 + }, + { + key: "node.kubernetes.io/not-ready", + operator: "NoExist", + effect: "NoSchedule", + tolerationSeconds: 7200 + }, +]; + +describe("", () => { + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("shows all tolerations", () => { + const { container } = render(); + const rows = container.querySelectorAll(".TableRow"); + + expect(rows[0].querySelector(".key").textContent).toBe("CriticalAddonsOnly"); + expect(rows[0].querySelector(".operator").textContent).toBe("Exist"); + expect(rows[0].querySelector(".effect").textContent).toBe("NoExecute"); + expect(rows[0].querySelector(".seconds").textContent).toBe("3600"); + + expect(rows[1].querySelector(".key").textContent).toBe("node.kubernetes.io/not-ready"); + expect(rows[1].querySelector(".operator").textContent).toBe("NoExist"); + expect(rows[1].querySelector(".effect").textContent).toBe("NoSchedule"); + expect(rows[1].querySelector(".seconds").textContent).toBe("7200"); + }); + + it("sorts table properly", () => { + const { container, getByText } = render(); + const headCell = getByText("Key"); + + fireEvent.click(headCell); + fireEvent.click(headCell); + + const rows = container.querySelectorAll(".TableRow"); + + expect(rows[0].querySelector(".key").textContent).toBe("node.kubernetes.io/not-ready"); + }); +}); diff --git a/src/renderer/components/+workloads-pods/pod-details-tolerations.scss b/src/renderer/components/+workloads-pods/pod-details-tolerations.scss index 0aa68fa1d6..1ac932cd9d 100644 --- a/src/renderer/components/+workloads-pods/pod-details-tolerations.scss +++ b/src/renderer/components/+workloads-pods/pod-details-tolerations.scss @@ -1,5 +1,23 @@ .PodDetailsTolerations { - .toleration { - margin-bottom: $margin; + grid-template-columns: auto; + + .PodTolerations { + margin-top: var(--margin); + } + + // Expanding value cell to cover 2 columns (whole Drawer width) + + > .name { + grid-row-start: 1; + grid-column-start: 1; + } + + > .value { + grid-row-start: 1; + grid-column-start: 1; + } + + .DrawerParamToggler > .params { + margin-left: var(--drawer-item-title-width); } } \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx b/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx index 8b67502e26..67bd5a07d0 100644 --- a/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-tolerations.tsx @@ -1,10 +1,11 @@ import "./pod-details-tolerations.scss"; import React from "react"; -import { Pod, Deployment, DaemonSet, StatefulSet, ReplicaSet, Job } from "../../api/endpoints"; import { DrawerParamToggler, DrawerItem } from "../drawer"; +import { WorkloadKubeObject } from "../../api/workload-kube-object"; +import { PodTolerations } from "./pod-tolerations"; interface Props { - workload: Pod | Deployment | DaemonSet | StatefulSet | ReplicaSet | Job; + workload: WorkloadKubeObject; } export class PodDetailsTolerations extends React.Component { @@ -17,20 +18,7 @@ export class PodDetailsTolerations extends React.Component { return ( - { - tolerations.map((toleration, index) => { - const { key, operator, effect, tolerationSeconds } = toleration; - - return ( -
- {key} - {operator && {operator}} - {effect && {effect}} - {!!tolerationSeconds && {tolerationSeconds}} -
- ); - }) - } +
); diff --git a/src/renderer/components/+workloads-pods/pod-tolerations.scss b/src/renderer/components/+workloads-pods/pod-tolerations.scss new file mode 100644 index 0000000000..b840697685 --- /dev/null +++ b/src/renderer/components/+workloads-pods/pod-tolerations.scss @@ -0,0 +1,14 @@ +.PodTolerations { + .TableHead { + background-color: var(--drawerSubtitleBackground); + } + + .TableCell { + white-space: normal; + word-break: normal; + + &.key { + flex-grow: 3; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-tolerations.tsx b/src/renderer/components/+workloads-pods/pod-tolerations.tsx new file mode 100644 index 0000000000..e8d3d7d099 --- /dev/null +++ b/src/renderer/components/+workloads-pods/pod-tolerations.tsx @@ -0,0 +1,63 @@ +import "./pod-tolerations.scss"; +import React from "react"; +import uniqueId from "lodash/uniqueId"; + +import { IToleration } from "../../api/workload-kube-object"; +import { Table, TableCell, TableHead, TableRow } from "../table"; + +interface Props { + tolerations: IToleration[]; +} + +enum sortBy { + Key = "key", + Operator = "operator", + Effect = "effect", + Seconds = "seconds", +} + +const sortingCallbacks = { + [sortBy.Key]: (toleration: IToleration) => toleration.key, + [sortBy.Operator]: (toleration: IToleration) => toleration.operator, + [sortBy.Effect]: (toleration: IToleration) => toleration.effect, + [sortBy.Seconds]: (toleration: IToleration) => toleration.tolerationSeconds, +}; + +const getTableRow = (toleration: IToleration) => { + const { key, operator, effect, tolerationSeconds } = toleration; + + return ( + + {key} + {operator} + {effect} + {tolerationSeconds} + + ); +}; + +export function PodTolerations({ tolerations }: Props) { + return ( + + + Key + Operator + Effect + Seconds + + { + tolerations.map(getTableRow) + } +
+ ); +} diff --git a/src/renderer/components/drawer/drawer-item.scss b/src/renderer/components/drawer/drawer-item.scss index f7727414a3..a9c54120df 100644 --- a/src/renderer/components/drawer/drawer-item.scss +++ b/src/renderer/components/drawer/drawer-item.scss @@ -1,6 +1,8 @@ .DrawerItem { + --drawer-item-title-width: 30%; + display: grid; - grid-template-columns: minmax(30%, min-content) auto; + grid-template-columns: minmax(var(--drawer-item-title-width), min-content) auto; border-bottom: 1px solid $borderFaintColor; padding: $padding 0; diff --git a/src/renderer/components/drawer/drawer-param-toggler.tsx b/src/renderer/components/drawer/drawer-param-toggler.tsx index 85772d0855..df97a4d1bd 100644 --- a/src/renderer/components/drawer/drawer-param-toggler.tsx +++ b/src/renderer/components/drawer/drawer-param-toggler.tsx @@ -25,7 +25,7 @@ export class DrawerParamToggler extends React.Component -
+
{label}
{link} diff --git a/src/renderer/components/drawer/drawer.scss b/src/renderer/components/drawer/drawer.scss index b34b3b5965..8b7fc27993 100644 --- a/src/renderer/components/drawer/drawer.scss +++ b/src/renderer/components/drawer/drawer.scss @@ -69,7 +69,6 @@ padding: var(--spacing); .Table .TableHead { - background-color: $contentColor; border-bottom: 1px solid $borderFaintColor; } } From f8c111ddd8031f568fa2f6b97790541ee568b9a5 Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 22 Jan 2021 13:18:46 +0200 Subject: [PATCH 019/219] Load k8s resources only for selected namespaces (#1918) * loading k8s resources into stores per selected namespaces -- part 1 Signed-off-by: Roman * loading k8s resources into stores per selected namespaces -- part 2 - fix: generating helm chart id Signed-off-by: Roman * loading k8s resources into stores per selected namespaces -- part 3 Signed-off-by: Roman * fixes Signed-off-by: Roman * fixes / responding to comments Signed-off-by: Roman * chore / small fixes Signed-off-by: Roman * fixes & refactoring Signed-off-by: Roman * make lint happy Signed-off-by: Roman * reset store on loading error Signed-off-by: Roman * added new cluster method: cluster.isAllowedResource Signed-off-by: Roman * fix: loading namespaces optimizations Signed-off-by: Roman * fixes & refactoring Signed-off-by: Roman --- src/common/rbac.ts | 53 ++-- src/common/user-store.ts | 9 + src/main/cluster.ts | 41 ++- src/renderer/api/kube-watch-api.ts | 34 ++- .../+apps-releases/release.store.ts | 27 +- .../components/+namespaces/namespace.store.ts | 140 ++++++---- .../role-bindings.store.ts | 18 +- .../+user-management-roles/roles.store.ts | 18 +- .../+workloads-overview/overview-statuses.tsx | 2 +- .../+workloads-overview/overview.tsx | 86 +++--- .../components/+workloads-pods/pods.tsx | 41 ++- .../item-object-list/item-list-layout.scss | 11 + .../item-object-list/item-list-layout.tsx | 250 +++++++++--------- .../item-object-list/page-filters.store.ts | 6 +- .../item-object-list/table-menu.scss | 4 - src/renderer/components/table/table-cell.tsx | 5 +- src/renderer/item.store.ts | 14 +- src/renderer/kube-object.store.ts | 66 +++-- 18 files changed, 465 insertions(+), 360 deletions(-) delete mode 100644 src/renderer/components/item-object-list/table-menu.scss diff --git a/src/common/rbac.ts b/src/common/rbac.ts index fbcf7c98d8..de242b114a 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -7,37 +7,38 @@ export type KubeResource = "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets"; export interface KubeApiResource { - resource: KubeResource; // valid resource name + kind: string; // resource type (e.g. "Namespace") + apiName: KubeResource; // valid api resource name (e.g. "namespaces") 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: "limitranges" }, - { resource: "namespaces" }, - { resource: "networkpolicies", group: "networking.k8s.io" }, - { resource: "nodes" }, - { resource: "persistentvolumes" }, - { resource: "persistentvolumeclaims" }, - { resource: "pods" }, - { resource: "poddisruptionbudgets" }, - { resource: "podsecuritypolicies" }, - { resource: "resourcequotas" }, - { resource: "replicasets", group: "apps" }, - { resource: "secrets" }, - { resource: "services" }, - { resource: "statefulsets", group: "apps" }, - { resource: "storageclasses", group: "storage.k8s.io" }, + { kind: "ConfigMap", apiName: "configmaps" }, + { kind: "CronJob", apiName: "cronjobs", group: "batch" }, + { kind: "CustomResourceDefinition", apiName: "customresourcedefinitions", group: "apiextensions.k8s.io" }, + { kind: "DaemonSet", apiName: "daemonsets", group: "apps" }, + { kind: "Deployment", apiName: "deployments", group: "apps" }, + { kind: "Endpoint", apiName: "endpoints" }, + { kind: "Event", apiName: "events" }, + { kind: "HorizontalPodAutoscaler", apiName: "horizontalpodautoscalers" }, + { kind: "Ingress", apiName: "ingresses", group: "networking.k8s.io" }, + { kind: "Job", apiName: "jobs", group: "batch" }, + { kind: "Namespace", apiName: "namespaces" }, + { kind: "LimitRange", apiName: "limitranges" }, + { kind: "NetworkPolicy", apiName: "networkpolicies", group: "networking.k8s.io" }, + { kind: "Node", apiName: "nodes" }, + { kind: "PersistentVolume", apiName: "persistentvolumes" }, + { kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" }, + { kind: "Pod", apiName: "pods" }, + { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets" }, + { kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" }, + { kind: "ResourceQuota", apiName: "resourcequotas" }, + { kind: "ReplicaSet", apiName: "replicasets", group: "apps" }, + { kind: "Secret", apiName: "secrets" }, + { kind: "Service", apiName: "services" }, + { kind: "StatefulSet", apiName: "statefulsets", group: "apps" }, + { kind: "StorageClass", apiName: "storageclasses", group: "storage.k8s.io" }, ]; export function isAllowedResource(resources: KubeResource | KubeResource[]) { diff --git a/src/common/user-store.ts b/src/common/user-store.ts index cf271a011d..b0294d9e5a 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -84,6 +84,15 @@ export class UserStore extends BaseStore { return semver.gt(getAppVersion(), this.lastSeenAppVersion); } + @action + setHiddenTableColumns(tableId: string, names: Set | string[]) { + this.preferences.hiddenTableColumns[tableId] = Array.from(names); + } + + getHiddenTableColumns(tableId: string): Set { + return new Set(this.preferences.hiddenTableColumns[tableId]); + } + @action resetKubeConfigPath() { this.kubeConfigPath = kubeConfigDefaultPath; diff --git a/src/main/cluster.ts b/src/main/cluster.ts index c6c14f6406..956164e10c 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -190,7 +190,7 @@ export class Cluster implements ClusterModel, ClusterState { */ @observable metadata: ClusterMetadata = {}; /** - * List of allowed namespaces + * List of allowed namespaces verified via K8S::SelfSubjectAccessReview api * * @observable */ @@ -203,7 +203,7 @@ export class Cluster implements ClusterModel, ClusterState { */ @observable allowedResources: string[] = []; /** - * List of accessible namespaces + * List of accessible namespaces provided by user in the Cluster Settings * * @observable */ @@ -224,7 +224,7 @@ export class Cluster implements ClusterModel, ClusterState { * @computed */ @computed get name() { - return this.preferences.clusterName || this.contextName; + return this.preferences.clusterName || this.contextName; } /** @@ -279,7 +279,8 @@ export class Cluster implements ClusterModel, ClusterState { * @param port port where internal auth proxy is listening * @internal */ - @action async init(port: number) { + @action + async init(port: number) { try { this.initializing = true; this.contextHandler = new ContextHandler(this); @@ -334,7 +335,8 @@ export class Cluster implements ClusterModel, ClusterState { * @param force force activation * @internal */ - @action async activate(force = false) { + @action + async activate(force = false) { if (this.activated && !force) { return this.pushState(); } @@ -373,7 +375,8 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - @action async reconnect() { + @action + async reconnect() { logger.info(`[CLUSTER]: reconnect`, this.getMeta()); this.contextHandler?.stopServer(); await this.contextHandler?.ensureServer(); @@ -400,7 +403,8 @@ export class Cluster implements ClusterModel, ClusterState { * @internal * @param opts refresh options */ - @action async refresh(opts: ClusterRefreshOptions = {}) { + @action + async refresh(opts: ClusterRefreshOptions = {}) { logger.info(`[CLUSTER]: refresh`, this.getMeta()); await this.whenInitialized; await this.refreshConnectionStatus(); @@ -420,7 +424,8 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - @action async refreshMetadata() { + @action + async refreshMetadata() { logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); const metadata = await detectorRegistry.detectForCluster(this); const existingMetadata = this.metadata; @@ -431,7 +436,8 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - @action async refreshConnectionStatus() { + @action + async refreshConnectionStatus() { const connectionStatus = await this.getConnectionStatus(); this.online = connectionStatus > ClusterStatus.Offline; @@ -441,7 +447,8 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - @action async refreshAllowedResources() { + @action + async refreshAllowedResources() { this.allowedNamespaces = await this.getAllowedNamespaces(); this.allowedResources = await this.getAllowedResources(); } @@ -668,7 +675,7 @@ export class Cluster implements ClusterModel, ClusterState { for (const namespace of this.allowedNamespaces.slice(0, 10)) { if (!this.resourceAccessStatuses.get(apiResource)) { const result = await this.canI({ - resource: apiResource.resource, + resource: apiResource.apiName, group: apiResource.group, verb: "list", namespace @@ -683,9 +690,19 @@ export class Cluster implements ClusterModel, ClusterState { return apiResources .filter((resource) => this.resourceAccessStatuses.get(resource)) - .map(apiResource => apiResource.resource); + .map(apiResource => apiResource.apiName); } catch (error) { return []; } } + + isAllowedResource(kind: string): boolean { + const apiResource = apiResources.find(resource => resource.kind === kind || resource.apiName === kind); + + if (apiResource) { + return this.allowedResources.includes(apiResource.apiName); + } + + return true; // allowed by default for other resources + } } diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 78ca25256e..fe35a04baa 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -11,7 +11,7 @@ import { apiPrefix, isDevelopment } from "../../common/vars"; import { getHostedCluster } from "../../common/cluster-store"; export interface IKubeWatchEvent { - type: "ADDED" | "MODIFIED" | "DELETED"; + type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR"; object?: T; } @@ -62,27 +62,41 @@ export class KubeWatchApi { }); } - protected getQuery(): Partial { - const { isAdmin, allowedNamespaces } = getHostedCluster(); + // FIXME: use POST to send apis for subscribing (list could be huge) + // TODO: try to use normal fetch res.body stream to consume watch-api updates + // https://github.com/lensapp/lens/issues/1898 + protected async getQuery() { + const { namespaceStore } = await import("../components/+namespaces/namespace.store"); + + await namespaceStore.whenReady; + const { isAdmin } = getHostedCluster(); return { api: this.activeApis.map(api => { - if (isAdmin) return api.getWatchUrl(); + if (isAdmin && !api.isNamespaced) { + return api.getWatchUrl(); + } - return allowedNamespaces.map(namespace => api.getWatchUrl(namespace)); + if (api.isNamespaced) { + return namespaceStore.getContextNamespaces().map(namespace => api.getWatchUrl(namespace)); + } + + return []; }).flat() }; } // todo: maybe switch to websocket to avoid often reconnects @autobind() - protected connect() { + protected async connect() { if (this.evtSource) this.disconnect(); // close previous connection - if (!this.activeApis.length) { + const query = await this.getQuery(); + + if (!this.activeApis.length || !query.api.length) { return; } - const query = this.getQuery(); + const apiUrl = `${apiPrefix}/watch?${stringify(query)}`; this.evtSource = new EventSource(apiUrl); @@ -158,6 +172,10 @@ export class KubeWatchApi { addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) { const listener = (evt: IKubeWatchEvent) => { + if (evt.type === "ERROR") { + return; // e.g. evt.object.message == "too old resource version" + } + const { namespace, resourceVersion } = evt.object.metadata; const api = apiManager.getApiByKind(evt.object.kind, evt.object.apiVersion); diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index b6d5c2fb5f..6f7ed39fed 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -5,7 +5,7 @@ import { HelmRelease, helmReleasesApi, IReleaseCreatePayload, IReleaseUpdatePayl import { ItemStore } from "../../item.store"; import { Secret } from "../../api/endpoints"; import { secretsStore } from "../+config-secrets/secrets.store"; -import { getHostedCluster } from "../../../common/cluster-store"; +import { namespaceStore } from "../+namespaces/namespace.store"; @autobind() export class ReleaseStore extends ItemStore { @@ -60,30 +60,23 @@ export class ReleaseStore extends ItemStore { @action async loadAll() { this.isLoading = true; - let items; try { - const { isAdmin, allowedNamespaces } = getHostedCluster(); + const items = await this.loadItems(namespaceStore.getContextNamespaces()); - items = await this.loadItems(!isAdmin ? allowedNamespaces : null); - } finally { - if (items) { - items = this.sortItems(items); - this.items.replace(items); - } + this.items.replace(this.sortItems(items)); this.isLoaded = true; + } catch (error) { + console.error(`Loading Helm Chart releases has failed: ${error}`); + } finally { this.isLoading = false; } } - async loadItems(namespaces?: string[]) { - if (!namespaces) { - return helmReleasesApi.list(); - } else { - return Promise - .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) - .then(items => items.flat()); - } + async loadItems(namespaces: string[]) { + return Promise + .all(namespaces.map(namespace => helmReleasesApi.list(namespace))) + .then(items => items.flat()); } async create(payload: IReleaseCreatePayload) { diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index ad02dd137c..50ec2c8038 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -1,53 +1,120 @@ -import { action, comparer, observable, reaction } from "mobx"; +import { action, comparer, IReactionDisposer, IReactionOptions, observable, reaction, toJS, when } from "mobx"; import { autobind, createStorage } from "../../utils"; -import { KubeObjectStore } from "../../kube-object.store"; -import { Namespace, namespacesApi } from "../../api/endpoints"; +import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; +import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api"; import { createPageParam } from "../../navigation"; import { apiManager } from "../../api/api-manager"; -import { isAllowedResource } from "../../../common/rbac"; -import { getHostedCluster } from "../../../common/cluster-store"; +import { clusterStore, getHostedCluster } from "../../../common/cluster-store"; -const storage = createStorage("context_namespaces", []); +const storage = createStorage("context_namespaces"); export const namespaceUrlParam = createPageParam({ name: "namespaces", isSystem: true, multiValues: true, get defaultValue() { - return storage.get(); // initial namespaces coming from URL or local-storage (default) + return storage.get() ?? []; // initial namespaces coming from URL or local-storage (default) } }); +export function getDummyNamespace(name: string) { + return new Namespace({ + kind: Namespace.kind, + apiVersion: "v1", + metadata: { + name, + uid: "", + resourceVersion: "", + selfLink: `/api/v1/namespaces/${name}` + } + }); +} + @autobind() export class NamespaceStore extends KubeObjectStore { api = namespacesApi; - contextNs = observable.array(); + + @observable contextNs = observable.array(); + @observable isReady = false; + + whenReady = when(() => this.isReady); constructor() { super(); this.init(); } - private init() { - this.setContext(this.initNamespaces); + private async init() { + await clusterStore.whenLoaded; + if (!getHostedCluster()) return; + await getHostedCluster().whenReady; // wait for cluster-state from main - return reaction(() => this.contextNs.toJS(), namespaces => { + this.setContext(this.initialNamespaces); + this.autoLoadAllowedNamespaces(); + this.autoUpdateUrlAndLocalStorage(); + + this.isReady = true; + } + + public onContextChange(callback: (contextNamespaces: string[]) => void, opts: IReactionOptions = {}): IReactionDisposer { + return reaction(() => this.contextNs.toJS(), callback, { + equals: comparer.shallow, + ...opts, + }); + } + + private autoUpdateUrlAndLocalStorage(): IReactionDisposer { + return this.onContextChange(namespaces => { storage.set(namespaces); // save to local-storage namespaceUrlParam.set(namespaces, { replaceHistory: true }); // update url }, { fireImmediately: true, - equals: comparer.identity, }); } - get initNamespaces() { - return namespaceUrlParam.get(); + private autoLoadAllowedNamespaces(): IReactionDisposer { + return reaction(() => this.allowedNamespaces, () => this.loadAll(), { + fireImmediately: true, + equals: comparer.shallow, + }); } - getContextParams() { - return { - namespaces: this.contextNs.toJS(), - }; + get allowedNamespaces(): string[] { + return toJS(getHostedCluster().allowedNamespaces); + } + + private get initialNamespaces(): string[] { + const allowed = new Set(this.allowedNamespaces); + const prevSelected = storage.get(); + + if (Array.isArray(prevSelected)) { + return prevSelected.filter(namespace => allowed.has(namespace)); + } + + // otherwise select "default" or first allowed namespace + if (allowed.has("default")) { + return ["default"]; + } else if (allowed.size) { + return [Array.from(allowed)[0]]; + } + + return []; + } + + getContextNamespaces(): string[] { + const namespaces = this.contextNs.toJS(); + + // show all namespaces when nothing selected + if (!namespaces.length) { + if (this.isLoaded) { + // return actual namespaces list since "allowedNamespaces" updating every 30s in cluster and thus might be stale + return this.items.map(namespace => namespace.getName()); + } + + return this.allowedNamespaces; + } + + return namespaces; } subscribe(apis = [this.api]) { @@ -61,31 +128,18 @@ export class NamespaceStore extends KubeObjectStore { return super.subscribe(apis); } - protected async loadItems(namespaces?: string[]) { - if (!isAllowedResource("namespaces")) { - if (namespaces) return namespaces.map(this.getDummyNamespace); + protected async loadItems(params: KubeObjectStoreLoadingParams) { + const { allowedNamespaces } = this; - return []; + let namespaces = await super.loadItems(params); + + namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName())); + + if (!namespaces.length && allowedNamespaces.length > 0) { + return allowedNamespaces.map(getDummyNamespace); } - if (namespaces) { - return Promise.all(namespaces.map(name => this.api.get({ name }))); - } else { - return super.loadItems(); - } - } - - protected getDummyNamespace(name: string) { - return new Namespace({ - kind: "Namespace", - apiVersion: "v1", - metadata: { - name, - uid: "", - resourceVersion: "", - selfLink: `/api/v1/namespaces/${name}` - } - }); + return namespaces; } @action @@ -105,12 +159,6 @@ export class NamespaceStore extends KubeObjectStore { else this.contextNs.push(namespace); } - @action - reset() { - super.reset(); - this.contextNs.clear(); - } - @action async remove(item: Namespace) { await super.remove(item); diff --git a/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts b/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts index f293dea6f0..71890acc44 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts +++ b/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts @@ -1,7 +1,7 @@ import difference from "lodash/difference"; import uniqBy from "lodash/uniqBy"; import { clusterRoleBindingApi, IRoleBindingSubject, RoleBinding, roleBindingApi } from "../../api/endpoints"; -import { KubeObjectStore } from "../../kube-object.store"; +import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; import { autobind } from "../../utils"; import { apiManager } from "../../api/api-manager"; @@ -26,15 +26,13 @@ export class RoleBindingsStore extends KubeObjectStore { return clusterRoleBindingApi.get(params); } - protected loadItems(namespaces?: string[]) { - if (namespaces) { - return Promise.all( - namespaces.map(namespace => roleBindingApi.list({ namespace })) - ).then(items => items.flat()); - } else { - return Promise.all([clusterRoleBindingApi.list(), roleBindingApi.list()]) - .then(items => items.flat()); - } + protected async loadItems(params: KubeObjectStoreLoadingParams): Promise { + const items = await Promise.all([ + super.loadItems({ ...params, api: clusterRoleBindingApi }), + super.loadItems({ ...params, api: roleBindingApi }), + ]); + + return items.flat(); } protected async createItem(params: { name: string; namespace?: string }, data?: Partial) { diff --git a/src/renderer/components/+user-management-roles/roles.store.ts b/src/renderer/components/+user-management-roles/roles.store.ts index 7b6c6c2397..7d2e90dd38 100644 --- a/src/renderer/components/+user-management-roles/roles.store.ts +++ b/src/renderer/components/+user-management-roles/roles.store.ts @@ -1,6 +1,6 @@ import { clusterRoleApi, Role, roleApi } from "../../api/endpoints"; import { autobind } from "../../utils"; -import { KubeObjectStore } from "../../kube-object.store"; +import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../kube-object.store"; import { apiManager } from "../../api/api-manager"; @autobind() @@ -24,15 +24,13 @@ export class RolesStore extends KubeObjectStore { return clusterRoleApi.get(params); } - protected loadItems(namespaces?: string[]): Promise { - if (namespaces) { - return Promise.all( - namespaces.map(namespace => roleApi.list({ namespace })) - ).then(items => items.flat()); - } else { - return Promise.all([clusterRoleApi.list(), roleApi.list()]) - .then(items => items.flat()); - } + protected async loadItems(params: KubeObjectStoreLoadingParams): Promise { + const items = await Promise.all([ + super.loadItems({ ...params, api: clusterRoleApi }), + super.loadItems({ ...params, api: roleApi }), + ]); + + return items.flat(); } protected async createItem(params: { name: string; namespace?: string }, data?: Partial) { diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx index 78adecb6df..33e5aa37c5 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -27,7 +27,7 @@ export class OverviewStatuses extends React.Component { @autobind() renderWorkload(resource: KubeResource): React.ReactElement { const store = workloadStores[resource]; - const items = store.getAllByNs(namespaceStore.contextNs); + const items = store.getAllByNs(namespaceStore.getContextNamespaces()); return (
diff --git a/src/renderer/components/+workloads-overview/overview.tsx b/src/renderer/components/+workloads-overview/overview.tsx index 318ad53f77..351b57462c 100644 --- a/src/renderer/components/+workloads-overview/overview.tsx +++ b/src/renderer/components/+workloads-overview/overview.tsx @@ -17,81 +17,65 @@ import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { Events } from "../+events"; import { KubeObjectStore } from "../../kube-object.store"; import { isAllowedResource } from "../../../common/rbac"; +import { namespaceStore } from "../+namespaces/namespace.store"; interface Props extends RouteComponentProps { } @observer export class WorkloadsOverview extends React.Component { + @observable isLoading = false; @observable isUnmounting = false; async componentDidMount() { - const stores: KubeObjectStore[] = []; + const stores: KubeObjectStore[] = [ + isAllowedResource("pods") && podsStore, + isAllowedResource("deployments") && deploymentStore, + isAllowedResource("daemonsets") && daemonSetStore, + isAllowedResource("statefulsets") && statefulSetStore, + isAllowedResource("replicasets") && replicaSetStore, + isAllowedResource("jobs") && jobStore, + isAllowedResource("cronjobs") && cronJobStore, + isAllowedResource("events") && eventStore, + ].filter(Boolean); - if (isAllowedResource("pods")) { - stores.push(podsStore); - } + const unsubscribeMap = new Map void>(); - if (isAllowedResource("deployments")) { - stores.push(deploymentStore); - } + const loadStores = async () => { + this.isLoading = true; - if (isAllowedResource("daemonsets")) { - stores.push(daemonSetStore); - } + for (const store of stores) { + if (this.isUnmounting) break; - if (isAllowedResource("statefulsets")) { - stores.push(statefulSetStore); - } + try { + await store.loadAll(); + unsubscribeMap.get(store)?.(); // unsubscribe previous watcher + unsubscribeMap.set(store, store.subscribe()); + } catch (error) { + console.error("loading store error", error); + } + } + this.isLoading = false; + }; - if (isAllowedResource("replicasets")) { - stores.push(replicaSetStore); - } + namespaceStore.onContextChange(loadStores, { + fireImmediately: true, + }); - if (isAllowedResource("jobs")) { - stores.push(jobStore); - } - - if (isAllowedResource("cronjobs")) { - stores.push(cronJobStore); - } - - if (isAllowedResource("events")) { - stores.push(eventStore); - } - - const unsubscribeList: Array<() => void> = []; - - for (const store of stores) { - await store.loadAll(); - unsubscribeList.push(store.subscribe()); - } - - await when(() => this.isUnmounting); - unsubscribeList.forEach(dispose => dispose()); + await when(() => this.isUnmounting && !this.isLoading); + unsubscribeMap.forEach(dispose => dispose()); + unsubscribeMap.clear(); } componentWillUnmount() { this.isUnmounting = true; } - get contents() { - return ( - <> - - { isAllowedResource("events") && } - - ); - } - render() { return (
- {this.contents} + + {isAllowedResource("events") && }
); } diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx index 2296b98317..a59c9d79d2 100644 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ b/src/renderer/components/+workloads-pods/pods.tsx @@ -19,8 +19,7 @@ import { lookupApiLink } from "../../api/kube-api"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { Badge } from "../badge"; - -enum sortBy { +enum columnId { name = "name", namespace = "namespace", containers = "containers", @@ -77,15 +76,15 @@ export class Pods extends React.Component { tableId = "workloads_pods" isConfigurable sortingCallbacks={{ - [sortBy.name]: (pod: Pod) => pod.getName(), - [sortBy.namespace]: (pod: Pod) => pod.getNs(), - [sortBy.containers]: (pod: Pod) => pod.getContainers().length, - [sortBy.restarts]: (pod: Pod) => pod.getRestartsCount(), - [sortBy.owners]: (pod: Pod) => pod.getOwnerRefs().map(ref => ref.kind), - [sortBy.qos]: (pod: Pod) => pod.getQosClass(), - [sortBy.node]: (pod: Pod) => pod.getNodeName(), - [sortBy.age]: (pod: Pod) => pod.metadata.creationTimestamp, - [sortBy.status]: (pod: Pod) => pod.getStatusMessage(), + [columnId.name]: (pod: Pod) => pod.getName(), + [columnId.namespace]: (pod: Pod) => pod.getNs(), + [columnId.containers]: (pod: Pod) => pod.getContainers().length, + [columnId.restarts]: (pod: Pod) => pod.getRestartsCount(), + [columnId.owners]: (pod: Pod) => pod.getOwnerRefs().map(ref => ref.kind), + [columnId.qos]: (pod: Pod) => pod.getQosClass(), + [columnId.node]: (pod: Pod) => pod.getNodeName(), + [columnId.age]: (pod: Pod) => pod.metadata.creationTimestamp, + [columnId.status]: (pod: Pod) => pod.getStatusMessage(), }} searchFilters={[ (pod: Pod) => pod.getSearchFields(), @@ -95,16 +94,16 @@ export class Pods extends React.Component { ]} renderHeaderTitle="Pods" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning", showWithColumn: "name" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Containers", className: "containers", sortBy: sortBy.containers }, - { title: "Restarts", className: "restarts", sortBy: sortBy.restarts }, - { title: "Controlled By", className: "owners", sortBy: sortBy.owners }, - { title: "Node", className: "node", sortBy: sortBy.node }, - { title: "QoS", className: "qos", sortBy: sortBy.qos }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Containers", className: "containers", sortBy: columnId.containers, id: columnId.containers }, + { title: "Restarts", className: "restarts", sortBy: columnId.restarts, id: columnId.restarts }, + { title: "Controlled By", className: "owners", sortBy: columnId.owners, id: columnId.owners }, + { title: "Node", className: "node", sortBy: columnId.node, id: columnId.node }, + { title: "QoS", className: "qos", sortBy: columnId.qos, id: columnId.qos }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(pod: Pod) => [ , diff --git a/src/renderer/components/item-object-list/item-list-layout.scss b/src/renderer/components/item-object-list/item-list-layout.scss index 9bdc2f943d..0008ffd527 100644 --- a/src/renderer/components/item-object-list/item-list-layout.scss +++ b/src/renderer/components/item-object-list/item-list-layout.scss @@ -36,3 +36,14 @@ } } +.ItemListLayoutVisibilityMenu { + .MenuItem { + padding: 0; + } + + .Checkbox { + width: 100%; + padding: var(--spacing); + cursor: pointer; + } +} 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 38e0e0218d..aaeb7438ea 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -1,12 +1,11 @@ import "./item-list-layout.scss"; -import "./table-menu.scss"; import groupBy from "lodash/groupBy"; import React, { ReactNode } from "react"; -import { computed, observable, reaction, toJS, when } from "mobx"; +import { computed, IReactionDisposer, observable, reaction, toJS } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog"; -import { TableSortCallback, Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps } from "../table"; +import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallback } from "../table"; import { autobind, createStorage, cssNames, IClassName, isReactNode, noop, prevDefault, stopPropagation } from "../../utils"; import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons"; import { NoItems } from "../no-items"; @@ -19,11 +18,10 @@ import { PageFiltersList } from "./page-filters-list"; import { PageFiltersSelect } from "./page-filters-select"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; import { themeStore } from "../../theme.store"; -import { MenuActions} from "../menu/menu-actions"; +import { MenuActions } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Checkbox } from "../checkbox"; import { userStore } from "../../../common/user-store"; -import logger from "../../../main/logger"; // todo: refactor, split to small re-usable components @@ -98,10 +96,11 @@ interface ItemListLayoutUserSettings { @observer export class ItemListLayout extends React.Component { static defaultProps = defaultProps as object; - @observable hiddenColumnNames = new Set(); + + private watchDisposers: IReactionDisposer[] = []; + @observable isUnmounting = false; - // default user settings (ui show-hide tweaks mostly) @observable userSettings: ItemListLayoutUserSettings = { showAppliedFilters: false, }; @@ -120,31 +119,54 @@ export class ItemListLayout extends React.Component { } async componentDidMount() { - const { store, dependentStores, isClusterScoped, tableId } = this.props; + const { isClusterScoped, isConfigurable, tableId } = this.props; - if (this.canBeConfigured) this.hiddenColumnNames = new Set(userStore.preferences?.hiddenTableColumns?.[tableId]); + if (isConfigurable && !tableId) { + throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified"); + } - const stores = [store, ...dependentStores]; + this.loadStores(); - if (!isClusterScoped) stores.push(namespaceStore); - - try { - stores.map(store => store.reset()); - await Promise.all(stores.map(store => store.loadAll())); - const subscriptions = stores.map(store => store.subscribe()); - - await when(() => this.isUnmounting); - subscriptions.forEach(dispose => dispose()); // unsubscribe all - } catch (error) { - console.log("catched", error); + if (!isClusterScoped) { + disposeOnUnmount(this, [ + namespaceStore.onContextChange(() => this.loadStores()) + ]); } } - componentWillUnmount() { + async componentWillUnmount() { this.isUnmounting = true; - const { store, isSelectable } = this.props; + this.unsubscribeStores(); + } - if (isSelectable) store.resetSelection(); + @computed get stores() { + const { store, dependentStores } = this.props; + + return new Set([store, ...dependentStores]); + } + + async loadStores() { + this.unsubscribeStores(); // reset first + + // load + for (const store of this.stores) { + if (this.isUnmounting) { + this.unsubscribeStores(); + break; + } + + try { + await store.loadAll(); + this.watchDisposers.push(store.subscribe()); + } catch (error) { + console.error("loading store error", error); + } + } + } + + unsubscribeStores() { + this.watchDisposers.forEach(dispose => dispose()); + this.watchDisposers.length = 0; } private filterCallbacks: { [type: string]: ItemsFilter } = { @@ -180,9 +202,7 @@ export class ItemListLayout extends React.Component { }; @computed get isReady() { - const { isReady, store } = this.props; - - return typeof isReady == "boolean" ? isReady : store.isLoaded; + return this.props.isReady ?? this.props.store.isLoaded; } @computed get filters() { @@ -228,42 +248,6 @@ export class ItemListLayout extends React.Component { return this.applyFilters(filterItems, allItems); } - updateColumnFilter(checkboxValue: boolean, columnName: string) { - if (checkboxValue){ - this.hiddenColumnNames.delete(columnName); - } else { - this.hiddenColumnNames.add(columnName); - } - - if (this.canBeConfigured) { - userStore.preferences.hiddenTableColumns[this.props.tableId] = Array.from(this.hiddenColumnNames); - } - } - - columnIsVisible(index: number): boolean { - const {renderTableHeader} = this.props; - - if (!this.canBeConfigured) return true; - - return !this.hiddenColumnNames.has(renderTableHeader[index].showWithColumn ?? renderTableHeader[index].className); - } - - get canBeConfigured(): boolean { - const { isConfigurable, tableId, renderTableHeader } = this.props; - - if (!isConfigurable || !tableId) { - return false; - } - - if (!renderTableHeader?.every(({ className }) => className)) { - logger.warning("[ItemObjectList]: cannot configure an object list without all headers being identifiable"); - - return false; - } - - return true; - } - @autobind() getRow(uid: string) { const { @@ -295,20 +279,18 @@ export class ItemListLayout extends React.Component { /> )} { - renderTableContents(item) - .map((content, index) => { - const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; + renderTableContents(item).map((content, index) => { + const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; + const headCell = renderTableHeader?.[index]; - if (copyClassNameFromHeadCells && renderTableHeader) { - const headCell = renderTableHeader[index]; + if (copyClassNameFromHeadCells && headCell) { + cellProps.className = cssNames(cellProps.className, headCell.className); + } - if (headCell) { - cellProps.className = cssNames(cellProps.className, headCell.className); - } - } - - return this.columnIsVisible(index) ? : null; - }) + if (!headCell || !this.isHiddenColumn(headCell)) { + return ; + } + }) } {renderItemMenu && ( @@ -347,16 +329,11 @@ export class ItemListLayout extends React.Component { return; } - return ; + return ; } renderNoItems() { - const { allItems, items, filters } = this; - const allItemsCount = allItems.length; - const itemsCount = items.length; - const isFiltered = filters.length > 0 && allItemsCount > itemsCount; - - if (isFiltered) { + if (this.filters.length > 0) { return ( No items found. @@ -369,7 +346,7 @@ export class ItemListLayout extends React.Component { ); } - return ; + return ; } renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { @@ -413,12 +390,12 @@ export class ItemListLayout extends React.Component { title:
{title}
, info: this.renderInfo(), filters: <> - {!isClusterScoped && } + {!isClusterScoped && } + }}/> , - search: , + search: , }; let header = this.renderHeaderContent(placeholders); @@ -442,10 +419,40 @@ export class ItemListLayout extends React.Component { ); } + renderTableHeader() { + const { renderTableHeader, isSelectable, isConfigurable, store } = this.props; + + if (!renderTableHeader) { + return; + } + + return ( + + {isSelectable && ( + store.toggleSelectionAll(this.items))} + /> + )} + {renderTableHeader.map((cellProps, index) => { + if (!this.isHiddenColumn(cellProps)) { + return ; + } + })} + {isConfigurable && ( + + {this.renderColumnVisibilityMenu()} + + )} + + ); + } + renderList() { const { - isSelectable, tableProps = {}, renderTableHeader, renderItemMenu, - store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, detailsItem + store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, detailsItem, + tableProps = {}, } = this.props; const { isReady, removeItemsDialog, items } = this; const { selectedItems } = store; @@ -454,7 +461,7 @@ export class ItemListLayout extends React.Component { return (
{!isReady && ( - + )} {isReady && ( { className: cssNames("box grow", tableProps.className, themeStore.activeTheme.type), })} > - {renderTableHeader && ( - - {isSelectable && ( - store.toggleSelectionAll(items))} - /> - )} - {renderTableHeader.map((cellProps, index) => this.columnIsVisible(index) ? : null)} - { renderItemMenu && - - {this.canBeConfigured && this.renderColumnMenu()} - - } - - )} + {this.renderTableHeader()} { !virtual && items.map(item => this.getRow(item.getId())) } @@ -502,24 +493,47 @@ export class ItemListLayout extends React.Component { ); } - renderColumnMenu() { - const { renderTableHeader} = this.props; + @computed get hiddenColumns() { + return userStore.getHiddenTableColumns(this.props.tableId); + } + + isHiddenColumn({ id: columnId, showWithColumn }: TableCellProps): boolean { + if (!this.props.isConfigurable) { + return false; + } + + return this.hiddenColumns.has(columnId) || ( + showWithColumn && this.hiddenColumns.has(showWithColumn) + ); + } + + updateColumnVisibility({ id: columnId }: TableCellProps, isVisible: boolean) { + const hiddenColumns = new Set(this.hiddenColumns); + + if (!isVisible) { + hiddenColumns.add(columnId); + } else { + hiddenColumns.delete(columnId); + } + + userStore.setHiddenTableColumns(this.props.tableId, hiddenColumns); + } + + renderColumnVisibilityMenu() { + const { renderTableHeader } = this.props; return ( - + {renderTableHeader.map((cellProps, index) => ( - !cellProps.showWithColumn && + !cellProps.showWithColumn && ( - `} - className = "MenuCheckbox" - value ={!this.hiddenColumnNames.has(cellProps.className)} - onChange = {(v) => this.updateColumnFilter(v, cellProps.className)} + `} + value={!this.isHiddenColumn(cellProps)} + onChange={isVisible => this.updateColumnVisibility(cellProps, isVisible)} /> + ) ))} ); diff --git a/src/renderer/components/item-object-list/page-filters.store.ts b/src/renderer/components/item-object-list/page-filters.store.ts index 9bff008aa6..d931cd2575 100644 --- a/src/renderer/components/item-object-list/page-filters.store.ts +++ b/src/renderer/components/item-object-list/page-filters.store.ts @@ -34,14 +34,14 @@ export class PageFiltersStore { namespaceStore.setContext(filteredNs); } }), - reaction(() => namespaceStore.contextNs.toJS(), contextNs => { + namespaceStore.onContextChange(namespaces => { const filteredNs = this.getValues(FilterType.NAMESPACE); - const isChanged = contextNs.length !== filteredNs.length; + const isChanged = namespaces.length !== filteredNs.length; if (isChanged) { this.filters.replace([ ...this.filters.filter(({ type }) => type !== FilterType.NAMESPACE), - ...contextNs.map(ns => ({ type: FilterType.NAMESPACE, value: ns })), + ...namespaces.map(ns => ({ type: FilterType.NAMESPACE, value: ns })), ]); } }, { diff --git a/src/renderer/components/item-object-list/table-menu.scss b/src/renderer/components/item-object-list/table-menu.scss deleted file mode 100644 index b7e41f54ca..0000000000 --- a/src/renderer/components/item-object-list/table-menu.scss +++ /dev/null @@ -1,4 +0,0 @@ -.MenuCheckbox { - width: 100%; - height: 100%; -} diff --git a/src/renderer/components/table/table-cell.tsx b/src/renderer/components/table/table-cell.tsx index 97335078f1..81e2f9f85f 100644 --- a/src/renderer/components/table/table-cell.tsx +++ b/src/renderer/components/table/table-cell.tsx @@ -9,13 +9,14 @@ import { Checkbox } from "../checkbox"; export type TableCellElem = React.ReactElement; export interface TableCellProps extends React.DOMAttributes { + id?: string; // used for configuration visibility of columns className?: string; title?: ReactNode; checkbox?: boolean; // render cell with a checkbox isChecked?: boolean; // mark checkbox as checked or not renderBoolean?: boolean; // show "true" or "false" for all of the children elements are "typeof boolean" sortBy?: TableSortBy; // column name, must be same as key in sortable object
- showWithColumn?: string // className of another column, if it is not empty the current column is not shown in the filter menu, visibility of this one is the same as a specified column, applicable to headers only + showWithColumn?: string // id of the column which follow same visibility rules _sorting?: Partial; //
sorting state, don't use this prop outside (!) _sort?(sortBy: TableSortBy): void; //
sort function, don't use this prop outside (!) _nowrap?: boolean; // indicator, might come from parent , don't use this prop outside (!) @@ -73,7 +74,7 @@ export class TableCell extends React.Component { const content = displayBooleans(displayBoolean, title || children); return ( -
+
{this.renderCheckbox()} {_nowrap ?
{content}
: content} {this.renderSortIcon()} diff --git a/src/renderer/item.store.ts b/src/renderer/item.store.ts index 2105954d32..eccd2b52df 100644 --- a/src/renderer/item.store.ts +++ b/src/renderer/item.store.ts @@ -9,7 +9,7 @@ export interface ItemObject { @autobind() export abstract class ItemStore { - abstract loadAll(): Promise; + abstract loadAll(...args: any[]): Promise; protected defaultSorting = (item: T) => item.getName(); @@ -40,8 +40,7 @@ export abstract class ItemStore { if (item) { return item; - } - else { + } else { const items = this.sortItems([...this.items, newItem]); this.items.replace(items); @@ -83,8 +82,7 @@ export abstract class ItemStore { const index = this.items.findIndex(item => item === existingItem); this.items.splice(index, 1, item); - } - else { + } else { let items = [...this.items, item]; if (sortItems) items = this.sortItems(items); @@ -130,8 +128,7 @@ export abstract class ItemStore { toggleSelection(item: T) { if (this.isSelected(item)) { this.unselect(item); - } - else { + } else { this.select(item); } } @@ -142,8 +139,7 @@ export abstract class ItemStore { if (allSelected) { visibleItems.forEach(this.unselect); - } - else { + } else { visibleItems.forEach(this.select); } } diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index bb2fffd819..956f5aa5f6 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -1,3 +1,4 @@ +import type { Cluster } from "../main/cluster"; import { action, observable, reaction } from "mobx"; import { autobind } from "./utils"; import { KubeObject } from "./api/kube-object"; @@ -6,7 +7,11 @@ import { ItemStore } from "./item.store"; import { apiManager } from "./api/api-manager"; import { IKubeApiQueryParams, KubeApi } from "./api/kube-api"; import { KubeJsonApiData } from "./api/kube-json-api"; -import { getHostedCluster } from "../common/cluster-store"; + +export interface KubeObjectStoreLoadingParams { + namespaces: string[]; + api?: KubeApi; +} @autobind() export abstract class KubeObjectStore extends ItemStore { @@ -71,14 +76,26 @@ export abstract class KubeObjectStore extends ItemSt } } - protected async loadItems(allowedNamespaces?: string[]): Promise { - if (!this.api.isNamespaced || !allowedNamespaces) { - return this.api.list({}, this.query); - } else { - return Promise - .all(allowedNamespaces.map(namespace => this.api.list({ namespace }))) - .then(items => items.flat()); + protected async resolveCluster(): Promise { + const { getHostedCluster } = await import("../common/cluster-store"); + + return getHostedCluster(); + } + + protected async loadItems({ namespaces, api }: KubeObjectStoreLoadingParams): Promise { + const cluster = await this.resolveCluster(); + + if (cluster.isAllowedResource(api.kind)) { + if (api.isNamespaced) { + return Promise + .all(namespaces.map(namespace => api.list({ namespace }))) + .then(items => items.flat()); + } + + return api.list({}, this.query); } + + return []; } protected filterItemsOnLoad(items: T[]) { @@ -86,30 +103,35 @@ export abstract class KubeObjectStore extends ItemSt } @action - async loadAll() { + async loadAll({ namespaces: contextNamespaces }: { namespaces?: string[] } = {}) { this.isLoading = true; - let items: T[]; try { - const { allowedNamespaces, accessibleNamespaces, isAdmin } = getHostedCluster(); + if (!contextNamespaces) { + const { namespaceStore } = await import("./components/+namespaces/namespace.store"); - if (isAdmin && accessibleNamespaces.length == 0) { - items = await this.loadItems(); - } else { - items = await this.loadItems(allowedNamespaces); + contextNamespaces = namespaceStore.getContextNamespaces(); } + let items = await this.loadItems({ namespaces: contextNamespaces, api: this.api }); + items = this.filterItemsOnLoad(items); - } finally { - if (items) { - items = this.sortItems(items); - this.items.replace(items); - } - this.isLoading = false; + items = this.sortItems(items); + + this.items.replace(items); this.isLoaded = true; + } catch (error) { + console.error("Loading store items failed", { error, store: this }); + this.resetOnError(error); + } finally { + this.isLoading = false; } } + protected resetOnError(error: any) { + if (error) this.reset(); + } + protected async loadItem(params: { name: string; namespace?: string }): Promise { return this.api.get(params); } @@ -194,7 +216,7 @@ export abstract class KubeObjectStore extends ItemSt // create latest non-observable copy of items to apply updates in one action (==single render) const items = this.items.toJS(); - for (const {type, object} of this.eventsBuffer.clear()) { + for (const { type, object } of this.eventsBuffer.clear()) { const index = items.findIndex(item => item.getId() === object.metadata?.uid); const item = items[index]; const api = apiManager.getApiByKind(object.kind, object.apiVersion); From 9da349ce42aedd714904e7a4eb17b093eb7fb72e Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 22 Jan 2021 16:51:42 +0200 Subject: [PATCH 020/219] Display CPU usage percentage with 2 decimal points (#2000) Signed-off-by: Alex Culliere --- src/renderer/components/+nodes/nodes.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/+nodes/nodes.tsx b/src/renderer/components/+nodes/nodes.tsx index edfa5c4026..1ca12343b5 100644 --- a/src/renderer/components/+nodes/nodes.tsx +++ b/src/renderer/components/+nodes/nodes.tsx @@ -51,6 +51,10 @@ export class Nodes extends React.Component { if (!metrics || !metrics[1]) return ; const usage = metrics[0]; const cores = metrics[1]; + const cpuUsagePercent = Math.ceil(usage * 100) / cores; + const cpuUsagePercentLabel: String = cpuUsagePercent % 1 === 0 + ? cpuUsagePercent.toString() + : cpuUsagePercent.toFixed(2); return ( { value={usage} tooltip={{ preferredPositions: TooltipPosition.BOTTOM, - children: `CPU: ${Math.ceil(usage * 100) / cores}\%, cores: ${cores}` + children: `CPU: ${cpuUsagePercentLabel}\%, cores: ${cores}` }} /> ); From 79db7bbbe44eb70d6e7f5c027b238fcd0d3b4344 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 25 Jan 2021 09:33:08 +0200 Subject: [PATCH 021/219] Fix azure pipeline integration test exit code (#1980) Signed-off-by: Jari Kolehmainen --- .azure-pipelines.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 6bb07489ec..d7ed058fa3 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -58,6 +58,7 @@ jobs: - script: make test-extensions displayName: Run In-tree Extension tests - bash: | + set -e rm -rf extensions/telemetry make integration-win git checkout extensions/telemetry @@ -102,6 +103,7 @@ jobs: - script: make test-extensions displayName: Run In-tree Extension tests - bash: | + set -e rm -rf extensions/telemetry make integration-mac git checkout extensions/telemetry @@ -159,6 +161,7 @@ jobs: sudo chown -R $USER $HOME/.kube $HOME/.minikube displayName: Install integration test dependencies - bash: | + set -e rm -rf extensions/telemetry xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux git checkout extensions/telemetry From 3a4da8793355ebc81120c08bf1f805a0c959cb8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Jan 2021 13:00:23 -0500 Subject: [PATCH 022/219] Bump marked from 1.1.0 to 1.2.7 (#1976) Bumps [marked](https://github.com/markedjs/marked) from 1.1.0 to 1.2.7. - [Release notes](https://github.com/markedjs/marked/releases) - [Changelog](https://github.com/markedjs/marked/blob/master/release.config.js) - [Commits](https://github.com/markedjs/marked/compare/v1.1.0...v1.2.7) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 57c7a8a657..a483f6b1c5 100644 --- a/package.json +++ b/package.json @@ -200,7 +200,7 @@ "jsonpath": "^1.0.2", "lodash": "^4.17.15", "mac-ca": "^1.0.4", - "marked": "^1.1.0", + "marked": "^1.2.7", "md5-file": "^5.0.0", "mobx": "^5.15.7", "mobx-observable-history": "^1.0.3", diff --git a/yarn.lock b/yarn.lock index 4cfa01fdf4..c61391d58d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8807,10 +8807,10 @@ marked@^0.8.0: resolved "https://registry.yarnpkg.com/marked/-/marked-0.8.2.tgz#4faad28d26ede351a7a1aaa5fec67915c869e355" integrity sha512-EGwzEeCcLniFX51DhTpmTom+dSA/MG/OBUDjnWtHbEnjAH180VzUeAw+oE4+Zv+CoYBWyRlYOTR0N8SO9R1PVw== -marked@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/marked/-/marked-1.1.0.tgz#62504ad4d11550c942935ccc5e39d64e5a4c4e50" - integrity sha512-EkE7RW6KcXfMHy2PA7Jg0YJE1l8UPEZE8k45tylzmZM30/r1M1MUXWQfJlrSbsTeh7m/XTwHbWUENvAJZpp1YA== +marked@^1.2.7: + version "1.2.7" + resolved "https://registry.yarnpkg.com/marked/-/marked-1.2.7.tgz#6e14b595581d2319cdcf033a24caaf41455a01fb" + integrity sha512-No11hFYcXr/zkBvL6qFmAp1z6BKY3zqLMHny/JN/ey+al7qwCM2+CMBL9BOgqMxZU36fz4cCWfn2poWIf7QRXA== matcher@^3.0.0: version "3.0.0" From 75f4d0df75a58f62eb6d6a4aa1f331a87287c581 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Jan 2021 13:36:20 -0500 Subject: [PATCH 023/219] Bump elliptic from 6.5.2 to 6.5.3 (#633) Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.2 to 6.5.3. - [Release notes](https://github.com/indutny/elliptic/releases) - [Commits](https://github.com/indutny/elliptic/compare/v6.5.2...v6.5.3) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index c61391d58d..6dcc19a63c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4811,9 +4811,9 @@ electron@^9.4.0: extract-zip "^1.0.3" elliptic@^6.0.0, elliptic@^6.5.2: - version "6.5.2" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.2.tgz#05c5678d7173c049d8ca433552224a495d0e3762" - integrity sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw== + version "6.5.3" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" + integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== dependencies: bn.js "^4.4.0" brorand "^1.0.1" From 09dc2790db366f74a6a96a5cf95558be580bb479 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Jan 2021 13:36:35 -0500 Subject: [PATCH 024/219] Bump make-plural from 6.2.1 to 6.2.2 (#1982) Bumps [make-plural](https://github.com/eemeli/make-plural/tree/HEAD/packages/plurals) from 6.2.1 to 6.2.2. - [Release notes](https://github.com/eemeli/make-plural/releases) - [Changelog](https://github.com/eemeli/make-plural/blob/master/packages/plurals/CHANGELOG.md) - [Commits](https://github.com/eemeli/make-plural/commits/make-plural@6.2.2/packages/plurals) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a483f6b1c5..1b3cc13c32 100644 --- a/package.json +++ b/package.json @@ -313,7 +313,7 @@ "jest-canvas-mock": "^2.3.0", "jest-fetch-mock": "^3.0.3", "jest-mock-extended": "^1.0.10", - "make-plural": "^6.2.1", + "make-plural": "^6.2.2", "mini-css-extract-plugin": "^0.9.0", "moment": "^2.26.0", "node-loader": "^0.6.0", diff --git a/yarn.lock b/yarn.lock index 6dcc19a63c..5e1cfa376d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8766,10 +8766,10 @@ make-fetch-happen@^5.0.0: socks-proxy-agent "^4.0.0" ssri "^6.0.0" -make-plural@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/make-plural/-/make-plural-6.2.1.tgz#2790af1d05fb2fc35a111ce759ffdb0aca1339a3" - integrity sha512-AmkruwJ9EjvyTv6AM8MBMK3TAeOJvhgTv5YQXzF0EP2qawhpvMjDpHvsdOIIT0Vn+BB0+IogmYZ1z+Ulm/m0Fg== +make-plural@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/make-plural/-/make-plural-6.2.2.tgz#beb5fd751355e72660eeb2218bb98eec92853c6c" + integrity sha512-8iTuFioatnTTmb/YJjywkVIHLjcwkFD9Ms0JpxjEm9Mo8eQYkh1z+55dwv4yc1jQ8ftVBxWQbihvZL1DfzGGWA== makeerror@1.0.x: version "1.0.11" From 5f1960612964793ca075618d4b0a78c8f336f6c7 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Tue, 26 Jan 2021 13:26:45 +0200 Subject: [PATCH 025/219] Fix Helm repositories and pod logs integration tests (#2015) * Fix Helm repository and pod logs integration tests Signed-off-by: Lauri Nevala * Return parsed object Signed-off-by: Lauri Nevala --- integration/__tests__/app.tests.ts | 42 +++++++++++++++++++++++------- integration/helpers/utils.ts | 23 ++++++++++++++++ 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index c986e4804e..ca30015fa1 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -6,10 +6,10 @@ */ import { Application } from "spectron"; import * as utils from "../helpers/utils"; -import { spawnSync, exec } from "child_process"; -import * as util from "util"; +import { spawnSync } from "child_process"; +import { listHelmRepositories } from "../helpers/utils"; +import { fail } from "assert"; -export const promiseExec = util.promisify(exec); jest.setTimeout(60000); @@ -96,8 +96,11 @@ describe("Lens integration tests", () => { }); it("ensures helm repos", async () => { - const { stdout: reposJson } = await promiseExec("helm repo list -o json"); - const repos = JSON.parse(reposJson); + const repos = await listHelmRepositories(); + + if (!repos[0]) { + fail("Lens failed to add Bitnami repository"); + } await app.client.waitUntilTextExists("div.repos #message-bitnami", repos[0].name); // wait for the helm-cli to fetch the repo(s) await app.client.click("#HelmRepoSelect"); // click the repo select to activate the drop-down @@ -505,19 +508,35 @@ describe("Lens integration tests", () => { await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); await app.client.click('a[href^="/pods"]'); + await app.client.click(".NamespaceSelect"); + await app.client.keys("kube-system"); + await app.client.keys("Enter");// "\uE007" await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver"); + let podMenuItemEnabled = false; + + // Wait until extensions are enabled on renderer + while (!podMenuItemEnabled) { + const logs = await app.client.getRenderProcessLogs(); + + podMenuItemEnabled = !!logs.find(entry => entry.message.includes("[EXTENSION]: enabled lens-pod-menu@")); + + if (!podMenuItemEnabled) { + await new Promise(r => setTimeout(r, 1000)); + } + } + await new Promise(r => setTimeout(r, 500)); // Give some extra time to prepare extensions // Open logs tab in dock await app.client.click(".list .TableRow:first-child"); await app.client.waitForVisible(".Drawer"); await app.client.click(".drawer-title .Menu li:nth-child(2)"); // Check if controls are available - await app.client.waitForVisible(".Logs .VirtualList"); + await app.client.waitForVisible(".LogList .VirtualList"); await app.client.waitForVisible(".LogResourceSelector"); - await app.client.waitForVisible(".LogResourceSelector .SearchInput"); - await app.client.waitForVisible(".LogResourceSelector .SearchInput input"); + //await app.client.waitForVisible(".LogSearch .SearchInput"); + await app.client.waitForVisible(".LogSearch .SearchInput input"); // Search for semicolon await app.client.keys(":"); - await app.client.waitForVisible(".Logs .list span.active"); + await app.client.waitForVisible(".LogList .list span.active"); // Click through controls await app.client.click(".LogControls .show-timestamps"); await app.client.click(".LogControls .show-previous"); @@ -556,7 +575,10 @@ describe("Lens integration tests", () => { await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); await app.client.click('a[href^="/pods"]'); - await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver"); + + await app.client.click(".NamespaceSelect"); + await app.client.keys(TEST_NAMESPACE); + await app.client.keys("Enter");// "\uE007" await app.client.click(".Icon.new-dock-tab"); await app.client.waitUntilTextExists("li.MenuItem.create-resource-tab", "Create resource"); await app.client.click("li.MenuItem.create-resource-tab"); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index f445a9ae48..a865280fed 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -1,4 +1,6 @@ import { Application } from "spectron"; +import * as util from "util"; +import { exec } from "child_process"; const AppPaths: Partial> = { "win32": "./dist/win-unpacked/Lens.exe", @@ -26,7 +28,12 @@ export function setup(): Application { }); } +type HelmRepository = { + name: string; + url: string; +}; type AsyncPidGetter = () => Promise; +export const promiseExec = util.promisify(exec); export async function tearDown(app: Application) { const pid = await (app.mainProcess.pid as any as AsyncPidGetter)(); @@ -39,3 +46,19 @@ export async function tearDown(app: Application) { console.error(e); } } + +export async function listHelmRepositories(retries = 0): Promise{ + if (retries < 5) { + try { + const { stdout: reposJson } = await promiseExec("helm repo list -o json"); + + return JSON.parse(reposJson); + } catch { + await new Promise(r => setTimeout(r, 2000)); // if no repositories, wait for Lens adding bitnami repository + + return await listHelmRepositories((retries + 1)); + } + } + + return []; +} From 9191d6bfd98caa39572365e51c8904f7375959fc Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Tue, 26 Jan 2021 16:30:26 +0300 Subject: [PATCH 026/219] Always shows .menu table column (#2024) Signed-off-by: Alex Andreev --- .../components/item-object-list/item-list-layout.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index aaeb7438ea..6b4ff4fd16 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -440,11 +440,9 @@ export class ItemListLayout extends React.Component { return ; } })} - {isConfigurable && ( - - {this.renderColumnVisibilityMenu()} - - )} + + {isConfigurable && this.renderColumnVisibilityMenu()} + ); } From 724c6c326587e76a46c4ce2d440976ef8bfe34dc Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 27 Jan 2021 16:15:13 +0200 Subject: [PATCH 027/219] Fix extension cluster submenu re-render (#1996) (#2027) * fix extension cluster pages re-render when they are registered as a sub-menu item Signed-off-by: Jari Kolehmainen * lint fix Signed-off-by: Jari Kolehmainen * refactor Signed-off-by: Jari Kolehmainen --- src/renderer/components/app.tsx | 46 +++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 495182b2a9..595506fcf9 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -42,7 +42,7 @@ import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../exte import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; import { eventStore } from "./+events/event.store"; -import { computed, reaction } from "mobx"; +import { reaction, computed, observable } from "mobx"; import { nodesStore } from "./+nodes/nodes.store"; import { podsStore } from "./+workloads-pods/pods.store"; import { sum } from "lodash"; @@ -75,6 +75,8 @@ export class App extends React.Component { whatInput.ask(); // Start to monitor user input device } + @observable extensionRoutes: Map = new Map(); + async componentDidMount() { const cluster = getHostedCluster(); const promises: Promise[] = []; @@ -101,6 +103,12 @@ export class App extends React.Component { reaction(() => this.warningsCount, (count) => { broadcastMessage(`cluster-warning-event-count:${cluster.id}`, count); }); + + reaction(() => clusterPageMenuRegistry.getRootItems(), (rootItems) => { + this.generateExtensionTabLayoutRoutes(rootItems); + }, { + fireImmediately: true + }); } @computed @@ -143,22 +151,38 @@ export class App extends React.Component { return routes; } - renderExtensionTabLayoutRoutes() { - return clusterPageMenuRegistry.getRootItems().map((menu, index) => { - const tabRoutes = this.getTabLayoutRoutes(menu); + generateExtensionTabLayoutRoutes(rootItems: ClusterPageMenuRegistration[]) { + rootItems.forEach((menu, index) => { + let route = this.extensionRoutes.get(menu); - if (tabRoutes.length > 0) { - const pageComponent = () => ; + if (!route) { + const tabRoutes = this.getTabLayoutRoutes(menu); - return tab.routePath)}/>; - } else { - const page = clusterPageRegistry.getByPageTarget(menu.target); + if (tabRoutes.length > 0) { + const pageComponent = () => ; - if (page) { - return ; + route = tab.routePath)} />; + this.extensionRoutes.set(menu, route); + } else { + const page = clusterPageRegistry.getByPageTarget(menu.target); + + if (page) { + route = ; + this.extensionRoutes.set(menu, route); + } } } }); + + Array.from(this.extensionRoutes.keys()).forEach((menu) => { + if (!rootItems.includes(menu)) { + this.extensionRoutes.delete(menu); + } + }); + } + + renderExtensionTabLayoutRoutes() { + return Array.from(this.extensionRoutes.values()); } renderExtensionRoutes() { From a157eb03e6c10376fc45c7062b290da7b5a442b5 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Wed, 27 Jan 2021 17:20:02 +0300 Subject: [PATCH 028/219] Generic logs view with Pod selector (#1984) * Adding LogTabStore Signed-off-by: Alex Andreev * Adding Pod selector in logs tab Signed-off-by: Alex Andreev * Refresh containers on pod change Signed-off-by: Alex Andreev * Adding tests Signed-off-by: Alex Andreev * Adding LogTabStore tests Signed-off-by: Alex Andreev * Clearn getPodsByOwnerId method Signed-off-by: Alex Andreev * Extracting dummy pods into mock file Signed-off-by: Alex Andreev * Eliminating containers and initContainers from store Signed-off-by: Alex Andreev * Refreshing tab pods if pod amount is changed in store Signed-off-by: Alex Andreev * A bit of cleaning up, fixing tests Signed-off-by: Alex Andreev * Fix lint newline errors Signed-off-by: Alex Andreev * Return getPodsByOwner() method Signed-off-by: Alex Andreev * Rename log tab when pod changes Signed-off-by: Alex Andreev --- extensions/pod-menu/package-lock.json | 656 +++++++++++++++++- extensions/pod-menu/src/logs-menu.tsx | 8 +- package.json | 1 + src/common/search-store.ts | 9 +- src/extensions/renderer-api/components.ts | 2 +- src/renderer/api/kube-json-api.ts | 2 +- .../+workloads-daemonsets/daemonsets.store.ts | 2 +- .../components/+workloads-jobs/job.store.ts | 2 +- .../components/+workloads-pods/pods.store.ts | 8 +- .../replicasets.store.ts | 2 +- .../statefulset.store.ts | 2 +- .../__test__/log-resource-selector.test.tsx | 103 +++ .../dock/__test__/log-tab.store.test.ts | 113 +++ .../components/dock/__test__/pod.mock.ts | 203 ++++++ src/renderer/components/dock/dock-tabs.tsx | 2 +- src/renderer/components/dock/dock.store.ts | 6 + src/renderer/components/dock/dock.tsx | 2 +- src/renderer/components/dock/log-controls.tsx | 13 +- src/renderer/components/dock/log-list.tsx | 7 +- .../components/dock/log-resource-selector.tsx | 65 +- src/renderer/components/dock/log-tab.store.ts | 123 ++++ src/renderer/components/dock/log.store.ts | 88 +-- src/renderer/components/dock/logs.tsx | 20 +- src/renderer/kube-object.store.ts | 4 + yarn.lock | 51 +- 25 files changed, 1359 insertions(+), 135 deletions(-) create mode 100644 src/renderer/components/dock/__test__/log-resource-selector.test.tsx create mode 100644 src/renderer/components/dock/__test__/log-tab.store.test.ts create mode 100644 src/renderer/components/dock/__test__/pod.mock.ts create mode 100644 src/renderer/components/dock/log-tab.store.ts diff --git a/extensions/pod-menu/package-lock.json b/extensions/pod-menu/package-lock.json index ea98213fb3..4409d2a89d 100644 --- a/extensions/pod-menu/package-lock.json +++ b/extensions/pod-menu/package-lock.json @@ -626,7 +626,644 @@ }, "@k8slens/extensions": { "version": "file:../../src/extensions/npm/extensions", - "dev": true + "dev": true, + "requires": { + "@material-ui/core": "*", + "@types/node": "*", + "@types/react-select": "*", + "conf": "^7.0.1" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", + "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "dev": true, + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "dev": true + }, + "@material-ui/core": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@material-ui/core/-/core-4.11.2.tgz", + "integrity": "sha512-/D1+AQQeYX/WhT/FUk78UCRj8ch/RCglsQLYujYTIqPSJlwZHKcvHidNeVhODXeApojeXjkl0tWdk5C9ofwOkQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/styles": "^4.11.2", + "@material-ui/system": "^4.11.2", + "@material-ui/types": "^5.1.0", + "@material-ui/utils": "^4.11.2", + "@types/react-transition-group": "^4.2.0", + "clsx": "^1.0.4", + "hoist-non-react-statics": "^3.3.2", + "popper.js": "1.16.1-lts", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0", + "react-transition-group": "^4.4.0" + } + }, + "@material-ui/styles": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.2.tgz", + "integrity": "sha512-xbItf8zkfD3FuGoD9f2vlcyPf9jTEtj9YTJoNNV+NMWaSAHXgrW6geqRoo/IwBuMjqpwqsZhct13e2nUyU9Ljw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.4", + "@emotion/hash": "^0.8.0", + "@material-ui/types": "^5.1.0", + "@material-ui/utils": "^4.11.2", + "clsx": "^1.0.4", + "csstype": "^2.5.2", + "hoist-non-react-statics": "^3.3.2", + "jss": "^10.0.3", + "jss-plugin-camel-case": "^10.0.3", + "jss-plugin-default-unit": "^10.0.3", + "jss-plugin-global": "^10.0.3", + "jss-plugin-nested": "^10.0.3", + "jss-plugin-props-sort": "^10.0.3", + "jss-plugin-rule-value-function": "^10.0.3", + "jss-plugin-vendor-prefixer": "^10.0.3", + "prop-types": "^15.7.2" + } + }, + "@material-ui/system": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@material-ui/system/-/system-4.11.2.tgz", + "integrity": "sha512-BELFJEel5E+5DMiZb6XXT3peWRn6UixRvBtKwSxqntmD0+zwbbfCij6jtGwwdJhN1qX/aXrKu10zX31GBaeR7A==", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "^4.11.2", + "csstype": "^2.5.2", + "prop-types": "^15.7.2" + } + }, + "@material-ui/types": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", + "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", + "dev": true + }, + "@material-ui/utils": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@material-ui/utils/-/utils-4.11.2.tgz", + "integrity": "sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.4.4", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0" + } + }, + "@types/node": { + "version": "14.14.21", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.21.tgz", + "integrity": "sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A==", + "dev": true + }, + "@types/prop-types": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", + "dev": true + }, + "@types/react": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.0.tgz", + "integrity": "sha512-aj/L7RIMsRlWML3YB6KZiXB3fV2t41+5RBGYF8z+tAKU43Px8C3cYUZsDvf1/+Bm4FK21QWBrDutu8ZJ/70qOw==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", + "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==", + "dev": true + } + } + }, + "@types/react-dom": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.0.tgz", + "integrity": "sha512-lUqY7OlkF/RbNtD5nIq7ot8NquXrdFrjSOR6+w9a9RFQevGi1oZO1dcJbXMeONAPKtZ2UrZOEJ5UOCVsxbLk/g==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-select": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@types/react-select/-/react-select-3.1.2.tgz", + "integrity": "sha512-ygvR/2FL87R2OLObEWFootYzkvm67LRA+URYEAcBuvKk7IXmdsnIwSGm60cVXGaqkJQHozb2Cy1t94tCYb6rJA==", + "dev": true, + "requires": { + "@types/react": "*", + "@types/react-dom": "*", + "@types/react-transition-group": "*" + } + }, + "@types/react-transition-group": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.0.tgz", + "integrity": "sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "atomically": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-1.7.0.tgz", + "integrity": "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==", + "dev": true + }, + "clsx": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.1.1.tgz", + "integrity": "sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==", + "dev": true + }, + "conf": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/conf/-/conf-7.1.2.tgz", + "integrity": "sha512-r8/HEoWPFn4CztjhMJaWNAe5n+gPUCSaJ0oufbqDLFKsA1V8JjAG7G+p0pgoDFAws9Bpk2VtVLLXqOBA7WxLeg==", + "dev": true, + "requires": { + "ajv": "^6.12.2", + "atomically": "^1.3.1", + "debounce-fn": "^4.0.0", + "dot-prop": "^5.2.0", + "env-paths": "^2.2.0", + "json-schema-typed": "^7.0.3", + "make-dir": "^3.1.0", + "onetime": "^5.1.0", + "pkg-up": "^3.1.0", + "semver": "^7.3.2" + } + }, + "css-vendor": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-2.0.8.tgz", + "integrity": "sha512-x9Aq0XTInxrkuFeHKbYC7zWY8ai7qJ04Kxd9MnvbC1uO5DagxoHQjm4JvG+vCdXOoFtCjbL2XSZfxmoYa9uQVQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.3", + "is-in-browser": "^1.0.2" + } + }, + "csstype": { + "version": "2.6.14", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.14.tgz", + "integrity": "sha512-2mSc+VEpGPblzAxyeR+vZhJKgYg0Og0nnRi7pmRXFYYxSfnOnW8A5wwQb4n4cE2nIOzqKOAzLCaEX6aBmNEv8A==", + "dev": true + }, + "debounce-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-4.0.0.tgz", + "integrity": "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==", + "dev": true, + "requires": { + "mimic-fn": "^3.0.0" + } + }, + "dom-helpers": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.0.tgz", + "integrity": "sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", + "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==", + "dev": true + } + } + }, + "dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "requires": { + "is-obj": "^2.0.0" + } + }, + "env-paths": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.0.tgz", + "integrity": "sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==", + "dev": true + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dev": true, + "requires": { + "react-is": "^16.7.0" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + } + } + }, + "hyphenate-style-name": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz", + "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ==", + "dev": true + }, + "indefinite-observable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/indefinite-observable/-/indefinite-observable-2.0.1.tgz", + "integrity": "sha512-G8vgmork+6H9S8lUAg1gtXEj2JxIQTo0g2PbFiYOdjkziSI0F7UYBiVwhZRuixhBCNGczAls34+5HJPyZysvxQ==", + "dev": true, + "requires": { + "symbol-observable": "1.2.0" + } + }, + "is-in-browser": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz", + "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU=", + "dev": true + }, + "is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "json-schema-typed": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-7.0.3.tgz", + "integrity": "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==", + "dev": true + }, + "jss": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/jss/-/jss-10.5.0.tgz", + "integrity": "sha512-B6151NvG+thUg3murLNHRPLxTLwQ13ep4SH5brj4d8qKtogOx/jupnpfkPGSHPqvcwKJaCLctpj2lEk+5yGwMw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "csstype": "^3.0.2", + "indefinite-observable": "^2.0.1", + "is-in-browser": "^1.1.3", + "tiny-warning": "^1.0.2" + }, + "dependencies": { + "csstype": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz", + "integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw==", + "dev": true + } + } + }, + "jss-plugin-camel-case": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/jss-plugin-camel-case/-/jss-plugin-camel-case-10.5.0.tgz", + "integrity": "sha512-GSjPL0adGAkuoqeYiXTgO7PlIrmjv5v8lA6TTBdfxbNYpxADOdGKJgIEkffhlyuIZHlPuuiFYTwUreLUmSn7rg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "hyphenate-style-name": "^1.0.3", + "jss": "10.5.0" + } + }, + "jss-plugin-default-unit": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/jss-plugin-default-unit/-/jss-plugin-default-unit-10.5.0.tgz", + "integrity": "sha512-rsbTtZGCMrbcb9beiDd+TwL991NGmsAgVYH0hATrYJtue9e+LH/Gn4yFD1ENwE+3JzF3A+rPnM2JuD9L/SIIWw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.5.0" + } + }, + "jss-plugin-global": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/jss-plugin-global/-/jss-plugin-global-10.5.0.tgz", + "integrity": "sha512-FZd9+JE/3D7HMefEG54fEC0XiQ9rhGtDHAT/ols24y8sKQ1D5KIw6OyXEmIdKFmACgxZV2ARQ5pAUypxkk2IFQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.5.0" + } + }, + "jss-plugin-nested": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/jss-plugin-nested/-/jss-plugin-nested-10.5.0.tgz", + "integrity": "sha512-ejPlCLNlEGgx8jmMiDk/zarsCZk+DV0YqXfddpgzbO9Toamo0HweCFuwJ3ZO40UFOfqKwfpKMVH/3HUXgxkTMg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.5.0", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-props-sort": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/jss-plugin-props-sort/-/jss-plugin-props-sort-10.5.0.tgz", + "integrity": "sha512-kTLRvrOetFKz5vM88FAhLNeJIxfjhCepnvq65G7xsAQ/Wgy7HwO1BS/2wE5mx8iLaAWC6Rj5h16mhMk9sKdZxg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.5.0" + } + }, + "jss-plugin-rule-value-function": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/jss-plugin-rule-value-function/-/jss-plugin-rule-value-function-10.5.0.tgz", + "integrity": "sha512-jXINGr8BSsB13JVuK274oEtk0LoooYSJqTBCGeBu2cG/VJ3+4FPs1gwLgsq24xTgKshtZ+WEQMVL34OprLidRA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "jss": "10.5.0", + "tiny-warning": "^1.0.2" + } + }, + "jss-plugin-vendor-prefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/jss-plugin-vendor-prefixer/-/jss-plugin-vendor-prefixer-10.5.0.tgz", + "integrity": "sha512-rux3gmfwDdOKCLDx0IQjTwTm03IfBa+Rm/hs747cOw5Q7O3RaTUIMPKjtVfc31Xr/XI9Abz2XEupk1/oMQ7zRA==", + "dev": true, + "requires": { + "@babel/runtime": "^7.3.1", + "css-vendor": "^2.0.8", + "jss": "10.5.0" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + } + } + }, + "mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + }, + "dependencies": { + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + } + } + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "pkg-up": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkg-up/-/pkg-up-3.1.0.tgz", + "integrity": "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "popper.js": { + "version": "1.16.1-lts", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1-lts.tgz", + "integrity": "sha512-Kjw8nKRl1m+VrSFCoVGPph93W/qrSO7ZkqPpTf7F4bk/sqcfWK019dWBUpE/fBOsOQY1dks/Bmcbfn1heM/IsA==", + "dev": true + }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "dev": true, + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true + } + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "react-is": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz", + "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==", + "dev": true + }, + "react-transition-group": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", + "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + } + }, + "regenerator-runtime": { + "version": "0.13.7", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", + "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==", + "dev": true + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "symbol-observable": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", + "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==", + "dev": true + }, + "tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "dev": true + }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } }, "@sinonjs/commons": { "version": "1.8.1", @@ -2796,7 +3433,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3226,6 +3864,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4382,6 +5021,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4396,6 +5036,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4405,6 +5046,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4414,6 +5056,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4422,7 +5065,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5438,7 +6082,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6315,7 +6960,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/pod-menu/src/logs-menu.tsx b/extensions/pod-menu/src/logs-menu.tsx index 706efcf128..1063207d0c 100644 --- a/extensions/pod-menu/src/logs-menu.tsx +++ b/extensions/pod-menu/src/logs-menu.tsx @@ -9,13 +9,9 @@ export class PodLogsMenu extends React.Component { Navigation.hideDetails(); const pod = this.props.object; - Component.createPodLogsTab({ - pod, - containers: pod.getContainers(), - initContainers: pod.getInitContainers(), + Component.logTabStore.createPodTab({ + selectedPod: pod, selectedContainer: container, - showTimestamps: false, - previous: false, }); } diff --git a/package.json b/package.json index 1b3cc13c32..3a06db621b 100644 --- a/package.json +++ b/package.json @@ -328,6 +328,7 @@ "react-refresh": "^0.9.0", "react-router-dom": "^5.2.0", "react-select": "^3.1.0", + "react-select-event": "^5.1.0", "react-window": "^1.8.5", "sass-loader": "^8.0.2", "sharp": "^0.26.1", diff --git a/src/common/search-store.ts b/src/common/search-store.ts index eb2517ca0f..86a6054af3 100644 --- a/src/common/search-store.ts +++ b/src/common/search-store.ts @@ -1,4 +1,5 @@ -import { action, computed, observable } from "mobx"; +import { action, computed, observable,reaction } from "mobx"; +import { dockStore } from "../renderer/components/dock/dock.store"; import { autobind } from "../renderer/utils"; export class SearchStore { @@ -6,6 +7,12 @@ export class SearchStore { @observable occurrences: number[] = []; // Array with line numbers, eg [0, 0, 10, 21, 21, 40...] @observable activeOverlayIndex = -1; // Index withing the occurences array. Showing where is activeOverlay currently located + constructor() { + reaction(() => dockStore.selectedTabId, () => { + searchStore.reset(); + }); + } + /** * Sets default activeOverlayIndex * @param text An array of any textual data (logs, for example) diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index a9a519498b..49c747da3a 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -38,4 +38,4 @@ export * from "../../renderer/components/+events/kube-event-details"; // specific exports export * from "../../renderer/components/status-brick"; export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store"; -export { createPodLogsTab } from "../../renderer/components/dock/log.store"; +export { logTabStore } from "../../renderer/components/dock/log-tab.store"; diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index 3026a9a956..362ee5438e 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -21,7 +21,7 @@ export interface KubeJsonApiData extends JsonApiData { resourceVersion: string; continue?: string; finalizers?: string[]; - selfLink: string; + selfLink?: string; labels?: { [label: string]: string; }; diff --git a/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts b/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts index ad9713e96b..b8c8dee573 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts +++ b/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts @@ -18,7 +18,7 @@ export class DaemonSetStore extends KubeObjectStore { } getChildPods(daemonSet: DaemonSet): Pod[] { - return podsStore.getPodsByOwner(daemonSet); + return podsStore.getPodsByOwnerId(daemonSet.getId()); } getStatuses(daemonSets?: DaemonSet[]) { diff --git a/src/renderer/components/+workloads-jobs/job.store.ts b/src/renderer/components/+workloads-jobs/job.store.ts index 569c9efb13..41d514df8d 100644 --- a/src/renderer/components/+workloads-jobs/job.store.ts +++ b/src/renderer/components/+workloads-jobs/job.store.ts @@ -10,7 +10,7 @@ export class JobStore extends KubeObjectStore { api = jobApi; getChildPods(job: Job): Pod[] { - return podsStore.getPodsByOwner(job); + return podsStore.getPodsByOwnerId(job.getId()); } getJobsByOwner(cronJob: CronJob) { diff --git a/src/renderer/components/+workloads-pods/pods.store.ts b/src/renderer/components/+workloads-pods/pods.store.ts index 9cd3c3b2f9..5a535cec66 100644 --- a/src/renderer/components/+workloads-pods/pods.store.ts +++ b/src/renderer/components/+workloads-pods/pods.store.ts @@ -3,8 +3,8 @@ import { action, observable } from "mobx"; import { KubeObjectStore } from "../../kube-object.store"; import { autobind, cpuUnitsToNumber, unitsToBytes } from "../../utils"; import { IPodMetrics, Pod, PodMetrics, podMetricsApi, podsApi } from "../../api/endpoints"; -import { WorkloadKubeObject } from "../../api/workload-kube-object"; import { apiManager } from "../../api/api-manager"; +import { WorkloadKubeObject } from "../../api/workload-kube-object"; @autobind() export class PodsStore extends KubeObjectStore { @@ -44,6 +44,12 @@ export class PodsStore extends KubeObjectStore { }); } + getPodsByOwnerId(workloadId: string): Pod[] { + return this.items.filter(pod => { + return pod.getOwnerRefs().find(owner => owner.uid === workloadId); + }); + } + getPodsByNode(node: string) { if (!this.isLoaded) return []; diff --git a/src/renderer/components/+workloads-replicasets/replicasets.store.ts b/src/renderer/components/+workloads-replicasets/replicasets.store.ts index 337f9c0ae1..ca58006930 100644 --- a/src/renderer/components/+workloads-replicasets/replicasets.store.ts +++ b/src/renderer/components/+workloads-replicasets/replicasets.store.ts @@ -18,7 +18,7 @@ export class ReplicaSetStore extends KubeObjectStore { } getChildPods(replicaSet: ReplicaSet) { - return podsStore.getPodsByOwner(replicaSet); + return podsStore.getPodsByOwnerId(replicaSet.getId()); } getStatuses(replicaSets: ReplicaSet[]) { diff --git a/src/renderer/components/+workloads-statefulsets/statefulset.store.ts b/src/renderer/components/+workloads-statefulsets/statefulset.store.ts index 12f1f663b9..6ee4bb5c28 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulset.store.ts +++ b/src/renderer/components/+workloads-statefulsets/statefulset.store.ts @@ -17,7 +17,7 @@ export class StatefulSetStore extends KubeObjectStore { } getChildPods(statefulSet: StatefulSet) { - return podsStore.getPodsByOwner(statefulSet); + return podsStore.getPodsByOwnerId(statefulSet.getId()); } getStatuses(statefulSets: StatefulSet[]) { diff --git a/src/renderer/components/dock/__test__/log-resource-selector.test.tsx b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx new file mode 100644 index 0000000000..22d97b7216 --- /dev/null +++ b/src/renderer/components/dock/__test__/log-resource-selector.test.tsx @@ -0,0 +1,103 @@ +/** + * @jest-environment jsdom + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render } from "@testing-library/react"; +import selectEvent from "react-select-event"; + +import { Pod } from "../../../api/endpoints"; +import { LogResourceSelector } from "../log-resource-selector"; +import { LogTabData } from "../log-tab.store"; +import { dockerPod, deploymentPod1 } from "./pod.mock"; + +const getComponent = (tabData: LogTabData) => { + return ( + + ); +}; + +const getOnePodTabData = (): LogTabData => { + const selectedPod = new Pod(dockerPod); + + return { + pods: [] as Pod[], + selectedPod, + selectedContainer: selectedPod.getContainers()[0], + }; +}; + +const getFewPodsTabData = (): LogTabData => { + const selectedPod = new Pod(deploymentPod1); + const anotherPod = new Pod(dockerPod); + + return { + pods: [anotherPod], + selectedPod, + selectedContainer: selectedPod.getContainers()[0], + }; +}; + +describe("", () => { + it("renders w/o errors", () => { + const tabData = getOnePodTabData(); + const { container } = render(getComponent(tabData)); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("renders proper namespace", () => { + const tabData = getOnePodTabData(); + const { getByTestId } = render(getComponent(tabData)); + const ns = getByTestId("namespace-badge"); + + expect(ns).toHaveTextContent("default"); + }); + + it("renders proper selected items within dropdowns", () => { + const tabData = getOnePodTabData(); + const { getByText } = render(getComponent(tabData)); + + expect(getByText("dockerExporter")).toBeInTheDocument(); + expect(getByText("docker-exporter")).toBeInTheDocument(); + }); + + it("renders sibling pods in dropdown", () => { + const tabData = getFewPodsTabData(); + const { container, getByText } = render(getComponent(tabData)); + const podSelector: HTMLElement = container.querySelector(".pod-selector"); + + selectEvent.openMenu(podSelector); + + expect(getByText("dockerExporter")).toBeInTheDocument(); + expect(getByText("deploymentPod1")).toBeInTheDocument(); + }); + + it("renders sibling containers in dropdown", () => { + const tabData = getFewPodsTabData(); + const { getByText, container } = render(getComponent(tabData)); + const containerSelector: HTMLElement = container.querySelector(".container-selector"); + + selectEvent.openMenu(containerSelector); + + expect(getByText("node-exporter-1")).toBeInTheDocument(); + expect(getByText("init-node-exporter")).toBeInTheDocument(); + expect(getByText("init-node-exporter-1")).toBeInTheDocument(); + }); + + it("renders pod owner as dropdown title", () => { + const tabData = getFewPodsTabData(); + const { getByText, container } = render(getComponent(tabData)); + const podSelector: HTMLElement = container.querySelector(".pod-selector"); + + selectEvent.openMenu(podSelector); + + expect(getByText("super-deployment")).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/dock/__test__/log-tab.store.test.ts b/src/renderer/components/dock/__test__/log-tab.store.test.ts new file mode 100644 index 0000000000..79b93af623 --- /dev/null +++ b/src/renderer/components/dock/__test__/log-tab.store.test.ts @@ -0,0 +1,113 @@ +/** + * @jest-environment jsdom + */ + +import { podsStore } from "../../+workloads-pods/pods.store"; +import { Pod } from "../../../api/endpoints"; +import { dockStore } from "../dock.store"; +import { logTabStore } from "../log-tab.store"; +import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; + + +podsStore.items.push(new Pod(dockerPod)); +podsStore.items.push(new Pod(deploymentPod1)); +podsStore.items.push(new Pod(deploymentPod2)); + +describe("log tab store", () => { + afterEach(() => { + logTabStore.reset(); + dockStore.reset(); + }); + + it("creates log tab without sibling pods", () => { + const selectedPod = new Pod(dockerPod); + const selectedContainer = selectedPod.getAllContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + pods: [selectedPod], + selectedPod, + selectedContainer, + showTimestamps: false, + previous: false + }); + }); + + it("creates log tab with sibling pods", () => { + const selectedPod = new Pod(deploymentPod1); + const siblingPod = new Pod(deploymentPod2); + const selectedContainer = selectedPod.getInitContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + pods: [selectedPod, siblingPod], + selectedPod, + selectedContainer, + showTimestamps: false, + previous: false + }); + }); + + it("removes item from pods list if pod deleted from store", () => { + const selectedPod = new Pod(deploymentPod1); + const selectedContainer = selectedPod.getInitContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + podsStore.items.pop(); + + expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + pods: [selectedPod], + selectedPod, + selectedContainer, + showTimestamps: false, + previous: false + }); + }); + + it("adds item into pods list if new sibling pod added to store", () => { + const selectedPod = new Pod(deploymentPod1); + const selectedContainer = selectedPod.getInitContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + podsStore.items.push(new Pod(deploymentPod3)); + + expect(logTabStore.getData(dockStore.selectedTabId)).toEqual({ + pods: [selectedPod, deploymentPod3], + selectedPod, + selectedContainer, + showTimestamps: false, + previous: false + }); + }); + + it("closes tab if no pods left in store", () => { + const selectedPod = new Pod(deploymentPod1); + const selectedContainer = selectedPod.getInitContainers()[0]; + + logTabStore.createPodTab({ + selectedPod, + selectedContainer + }); + + podsStore.items.clear(); + + expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined(); + expect(dockStore.getTabById(dockStore.selectedTabId)).toBeUndefined(); + }); +}); diff --git a/src/renderer/components/dock/__test__/pod.mock.ts b/src/renderer/components/dock/__test__/pod.mock.ts new file mode 100644 index 0000000000..acb4704395 --- /dev/null +++ b/src/renderer/components/dock/__test__/pod.mock.ts @@ -0,0 +1,203 @@ +export const dockerPod = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "dockerExporter", + name: "dockerExporter", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default" + }, + spec: { + initContainers: [] as any, + containers: [ + { + name: "docker-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + } +}; + +export const deploymentPod1 = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "deploymentPod1", + name: "deploymentPod1", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [{ + apiVersion: "v1", + kind: "Deployment", + name: "super-deployment", + uid: "uuid", + controller: true, + blockOwnerDeletion: true, + }] + }, + spec: { + initContainers: [ + { + name: "init-node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "init-node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + containers: [ + { + name: "node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + } +}; + +export const deploymentPod2 = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "deploymentPod2", + name: "deploymentPod2", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [{ + apiVersion: "v1", + kind: "Deployment", + name: "super-deployment", + uid: "uuid", + controller: true, + blockOwnerDeletion: true, + }] + }, + spec: { + initContainers: [ + { + name: "init-node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "init-node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + containers: [ + { + name: "node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + } +}; + +export const deploymentPod3 = { + apiVersion: "v1", + kind: "dummy", + metadata: { + uid: "deploymentPod3", + name: "deploymentPod3", + creationTimestamp: "dummy", + resourceVersion: "dummy", + namespace: "default", + ownerReferences: [{ + apiVersion: "v1", + kind: "Deployment", + name: "super-deployment", + uid: "uuid", + controller: true, + blockOwnerDeletion: true, + }] + }, + spec: { + containers: [ + { + name: "node-exporter", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + }, + { + name: "node-exporter-1", + image: "docker.io/prom/node-exporter:v1.0.0-rc.0", + imagePullPolicy: "pull" + } + ], + serviceAccountName: "dummy", + serviceAccount: "dummy", + }, + status: { + phase: "Running", + conditions: [{ + type: "Running", + status: "Running", + lastProbeTime: 1, + lastTransitionTime: "Some time", + }], + hostIP: "dummy", + podIP: "dummy", + startTime: "dummy", + } +}; diff --git a/src/renderer/components/dock/dock-tabs.tsx b/src/renderer/components/dock/dock-tabs.tsx index 554411024b..d0a3c3d125 100644 --- a/src/renderer/components/dock/dock-tabs.tsx +++ b/src/renderer/components/dock/dock-tabs.tsx @@ -7,7 +7,7 @@ import { DockTab } from "./dock-tab"; import { IDockTab } from "./dock.store"; import { isEditResourceTab } from "./edit-resource.store"; import { isInstallChartTab } from "./install-chart.store"; -import { isLogsTab } from "./log.store"; +import { isLogsTab } from "./log-tab.store"; import { TerminalTab } from "./terminal-tab"; import { isTerminalTab } from "./terminal.store"; import { isUpgradeChartTab } from "./upgrade-chart.store"; diff --git a/src/renderer/components/dock/dock.store.ts b/src/renderer/components/dock/dock.store.ts index 91d72d98d9..423367093e 100644 --- a/src/renderer/components/dock/dock.store.ts +++ b/src/renderer/components/dock/dock.store.ts @@ -208,6 +208,12 @@ export class DockStore { this.closeTabs(tabs); } + renameTab(tabId: TabId, title: string) { + const tab = this.getTabById(tabId); + + tab.title = title; + } + @action selectTab(tabId: TabId) { this.selectedTabId = this.getTabById(tabId)?.id ?? null; diff --git a/src/renderer/components/dock/dock.tsx b/src/renderer/components/dock/dock.tsx index c8adf82992..45e294b3c6 100644 --- a/src/renderer/components/dock/dock.tsx +++ b/src/renderer/components/dock/dock.tsx @@ -17,7 +17,7 @@ import { isEditResourceTab } from "./edit-resource.store"; import { InstallChart } from "./install-chart"; import { isInstallChartTab } from "./install-chart.store"; import { Logs } from "./logs"; -import { isLogsTab } from "./log.store"; +import { isLogsTab } from "./log-tab.store"; import { TerminalWindow } from "./terminal-window"; import { createTerminalTab, isTerminalTab } from "./terminal.store"; import { UpgradeChart } from "./upgrade-chart"; diff --git a/src/renderer/components/dock/log-controls.tsx b/src/renderer/components/dock/log-controls.tsx index 06bbf12863..8400aef584 100644 --- a/src/renderer/components/dock/log-controls.tsx +++ b/src/renderer/components/dock/log-controls.tsx @@ -5,22 +5,23 @@ import { observer } from "mobx-react"; import { Pod } from "../../api/endpoints"; import { cssNames, saveFileDialog } from "../../utils"; -import { IPodLogsData, podLogsStore } from "./log.store"; +import { logStore } from "./log.store"; import { Checkbox } from "../checkbox"; import { Icon } from "../icon"; +import { LogTabData } from "./log-tab.store"; interface Props { - tabData: IPodLogsData + tabData: LogTabData logs: string[] - save: (data: Partial) => void + save: (data: Partial) => void reload: () => void } export const LogControls = observer((props: Props) => { const { tabData, save, reload, logs } = props; const { showTimestamps, previous } = tabData; - const since = logs.length ? podLogsStore.getTimestamps(logs[0]) : null; - const pod = new Pod(tabData.pod); + const since = logs.length ? logStore.getTimestamps(logs[0]) : null; + const pod = new Pod(tabData.selectedPod); const toggleTimestamps = () => { save({ showTimestamps: !showTimestamps }); @@ -33,7 +34,7 @@ export const LogControls = observer((props: Props) => { const downloadLogs = () => { const fileName = pod.getName(); - const logsToDownload = showTimestamps ? logs : podLogsStore.logsWithoutTimestamps; + const logsToDownload = showTimestamps ? logs : logStore.logsWithoutTimestamps; saveFileDialog(`${fileName}.log`, logsToDownload.join("\n"), "text/plain"); }; diff --git a/src/renderer/components/dock/log-list.tsx b/src/renderer/components/dock/log-list.tsx index 74d64d2f58..3b66f42d86 100644 --- a/src/renderer/components/dock/log-list.tsx +++ b/src/renderer/components/dock/log-list.tsx @@ -14,7 +14,8 @@ import { Button } from "../button"; import { Icon } from "../icon"; import { Spinner } from "../spinner"; import { VirtualList } from "../virtual-list"; -import { podLogsStore } from "./log.store"; +import { logStore } from "./log.store"; +import { logTabStore } from "./log-tab.store"; interface Props { logs: string[] @@ -77,10 +78,10 @@ export class LogList extends React.Component { */ @computed get logs() { - const showTimestamps = podLogsStore.getData(this.props.id).showTimestamps; + const showTimestamps = logTabStore.getData(this.props.id).showTimestamps; if (!showTimestamps) { - return podLogsStore.logsWithoutTimestamps; + return logStore.logsWithoutTimestamps; } return this.props.logs; diff --git a/src/renderer/components/dock/log-resource-selector.tsx b/src/renderer/components/dock/log-resource-selector.tsx index a4b79bcfad..c6f1bee300 100644 --- a/src/renderer/components/dock/log-resource-selector.tsx +++ b/src/renderer/components/dock/log-resource-selector.tsx @@ -1,27 +1,30 @@ import "./log-resource-selector.scss"; -import React from "react"; +import React, { useEffect } from "react"; import { observer } from "mobx-react"; -import { IPodContainer, Pod } from "../../api/endpoints"; +import { Pod } from "../../api/endpoints"; import { Badge } from "../badge"; import { Select, SelectOption } from "../select"; -import { IPodLogsData } from "./log.store"; +import { LogTabData, logTabStore } from "./log-tab.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { TabId } from "./dock.store"; interface Props { - tabData: IPodLogsData - save: (data: Partial) => void + tabId: TabId + tabData: LogTabData + save: (data: Partial) => void reload: () => void } export const LogResourceSelector = observer((props: Props) => { - const { tabData, save, reload } = props; - const { selectedContainer, containers, initContainers } = tabData; - const pod = new Pod(tabData.pod); + const { tabData, save, reload, tabId } = props; + const { selectedPod, selectedContainer, pods } = tabData; + const pod = new Pod(selectedPod); + const containers = pod.getContainers(); + const initContainers = pod.getInitContainers(); const onContainerChange = (option: SelectOption) => { - const { containers, initContainers } = tabData; - save({ selectedContainer: containers .concat(initContainers) @@ -30,11 +33,18 @@ export const LogResourceSelector = observer((props: Props) => { reload(); }; - const getSelectOptions = (containers: IPodContainer[]) => { - return containers.map(container => { + const onPodChange = (option: SelectOption) => { + const selectedPod = podsStore.getByName(option.value, pod.getNs()); + + save({ selectedPod }); + logTabStore.renameTab(tabId); + }; + + const getSelectOptions = (items: string[]) => { + return items.map(item => { return { - value: container.name, - label: container.name + value: item, + label: item }; }); }; @@ -42,24 +52,43 @@ export const LogResourceSelector = observer((props: Props) => { const containerSelectOptions = [ { label: `Containers`, - options: getSelectOptions(containers) + options: getSelectOptions(containers.map(container => container.name)) }, { label: `Init Containers`, - options: getSelectOptions(initContainers), + options: getSelectOptions(initContainers.map(container => container.name)), } ]; + const podSelectOptions = [ + { + label: pod.getOwnerRefs()[0]?.name, + options: getSelectOptions(pods.map(pod => pod.metadata.name)) + } + ]; + + useEffect(() => { + reload(); + }, [selectedPod]); + return (
- Namespace - Pod + Namespace + Pod +
); diff --git a/src/renderer/components/dock/log-tab.store.ts b/src/renderer/components/dock/log-tab.store.ts new file mode 100644 index 0000000000..3eec7812be --- /dev/null +++ b/src/renderer/components/dock/log-tab.store.ts @@ -0,0 +1,123 @@ +import uniqueId from "lodash/uniqueId"; +import { reaction } from "mobx"; +import { podsStore } from "../+workloads-pods/pods.store"; + +import { IPodContainer, Pod } from "../../api/endpoints"; +import { WorkloadKubeObject } from "../../api/workload-kube-object"; +import { DockTabStore } from "./dock-tab.store"; +import { dockStore, IDockTab, TabKind } from "./dock.store"; + +export interface LogTabData { + pods: Pod[]; + selectedPod: Pod; + selectedContainer: IPodContainer + showTimestamps?: boolean + previous?: boolean +} + +interface PodLogsTabData { + selectedPod: Pod + selectedContainer: IPodContainer +} + +interface WorkloadLogsTabData { + workload: WorkloadKubeObject +} + +export class LogTabStore extends DockTabStore { + constructor() { + super({ + storageName: "pod_logs" + }); + + reaction(() => podsStore.items.length, () => { + this.updateTabsData(); + }); + } + + createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): void { + const podOwner = selectedPod.getOwnerRefs()[0]; + const pods = podsStore.getPodsByOwnerId(podOwner?.uid); + const title = `Pod ${selectedPod.getName()}`; + + this.createLogsTab(title, { + pods: pods.length ? pods : [selectedPod], + selectedPod, + selectedContainer + }); + } + + createWorkloadTab({ workload }: WorkloadLogsTabData): void { + const pods = podsStore.getPodsByOwnerId(workload.getId()); + + if (!pods.length) return; + + const selectedPod = pods[0]; + const selectedContainer = selectedPod.getAllContainers()[0]; + const title = `${workload.kind} ${selectedPod.getName()}`; + + this.createLogsTab(title, { + pods, + selectedPod, + selectedContainer + }); + } + + renameTab(tabId: string) { + const { selectedPod } = this.getData(tabId); + + dockStore.renameTab(tabId, `Pod ${selectedPod.metadata.name}`); + } + + private createDockTab(tabParams: Partial) { + dockStore.createTab({ + kind: TabKind.POD_LOGS, + ...tabParams + }, false); + } + + private createLogsTab(title: string, data: LogTabData) { + const id = uniqueId("log-tab-"); + + this.createDockTab({ id, title }); + this.setData(id, { + ...data, + showTimestamps: false, + previous: false + }); + } + + private updateTabsData() { + this.data.forEach((tabData, tabId) => { + const pod = new Pod(tabData.selectedPod); + const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid); + const isSelectedPodInList = pods.find(item => item.getId() == pod.getId()); + const selectedPod = isSelectedPodInList ? pod : pods[0]; + const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0]; + + if (pods.length) { + this.setData(tabId, { + ...tabData, + selectedPod, + selectedContainer, + pods + }); + + this.renameTab(tabId); + } else { + this.closeTab(tabId); + } + }); + } + + private closeTab(tabId: string) { + this.clearData(tabId); + dockStore.closeTab(tabId); + } +} + +export const logTabStore = new LogTabStore(); + +export function isLogsTab(tab: IDockTab) { + return tab && tab.kind === TabKind.POD_LOGS; +} diff --git a/src/renderer/components/dock/log.store.ts b/src/renderer/components/dock/log.store.ts index 4dcfccf981..14dc9efdd0 100644 --- a/src/renderer/components/dock/log.store.ts +++ b/src/renderer/components/dock/log.store.ts @@ -1,27 +1,16 @@ -import { autorun, computed, observable, reaction } from "mobx"; -import { Pod, IPodContainer, podsApi, IPodLogsQuery } from "../../api/endpoints"; +import { autorun, computed, observable } from "mobx"; + +import { IPodLogsQuery, Pod, podsApi } from "../../api/endpoints"; import { autobind, interval } from "../../utils"; -import { DockTabStore } from "./dock-tab.store"; -import { dockStore, IDockTab, TabKind } from "./dock.store"; -import { searchStore } from "../../../common/search-store"; +import { dockStore, TabId } from "./dock.store"; +import { isLogsTab, logTabStore } from "./log-tab.store"; -export interface IPodLogsData { - pod: Pod; - selectedContainer: IPodContainer - containers: IPodContainer[] - initContainers: IPodContainer[] - showTimestamps: boolean - previous: boolean -} - -type TabId = string; type PodLogLine = string; -// Number for log lines to load -export const logRange = 500; +const logLinesToLoad = 500; @autobind() -export class LogStore extends DockTabStore { +export class LogStore { private refresher = interval(10, () => { const id = dockStore.selectedTabId; @@ -30,12 +19,8 @@ export class LogStore extends DockTabStore { }); @observable podLogs = observable.map(); - @observable newLogSince = observable.map(); // Timestamp after which all logs are considered to be new constructor() { - super({ - storageName: "pod_logs" - }); autorun(() => { const { selectedTab, isOpen } = dockStore; @@ -45,15 +30,6 @@ export class LogStore extends DockTabStore { this.refresher.stop(); } }, { delay: 500 }); - - reaction(() => this.podLogs.get(dockStore.selectedTabId), () => { - this.setNewLogSince(dockStore.selectedTabId); - }); - - reaction(() => dockStore.selectedTabId, () => { - // Clear search query on tab change - searchStore.reset(); - }); } /** @@ -66,7 +42,7 @@ export class LogStore extends DockTabStore { load = async (tabId: TabId) => { try { const logs = await this.loadLogs(tabId, { - tailLines: this.lines + logRange + tailLines: this.lines + logLinesToLoad }); this.refresher.start(); @@ -107,9 +83,9 @@ export class LogStore extends DockTabStore { * @returns {Promise} A fetch request promise */ loadLogs = async (tabId: TabId, params: Partial) => { - const data = this.getData(tabId); + const data = logTabStore.getData(tabId); const { selectedContainer, previous } = data; - const pod = new Pod(data.pod); + const pod = new Pod(data.selectedPod); const namespace = pod.getNs(); const name = pod.getName(); @@ -127,17 +103,6 @@ export class LogStore extends DockTabStore { }); }; - /** - * Sets newLogSince separator timestamp to split old logs from new ones - * @param tabId - */ - setNewLogSince(tabId: TabId) { - if (!this.podLogs.has(tabId) || !this.podLogs.get(tabId).length || this.newLogSince.has(tabId)) return; - const timestamp = this.getLastSinceTime(tabId); - - this.newLogSince.set(tabId, timestamp.split(".")[0]); // Removing milliseconds from string - } - /** * Converts logs into a string array * @returns {number} Length of log lines @@ -196,37 +161,6 @@ export class LogStore extends DockTabStore { clearLogs(tabId: TabId) { this.podLogs.delete(tabId); } - - clearData(tabId: TabId) { - this.data.delete(tabId); - this.clearLogs(tabId); - } } -export const podLogsStore = new LogStore(); - -export function createPodLogsTab(data: IPodLogsData, tabParams: Partial = {}) { - const podId = data.pod.getId(); - let tab = dockStore.getTabById(podId); - - if (tab) { - dockStore.open(); - dockStore.selectTab(tab.id); - - return; - } - // If no existent tab found - tab = dockStore.createTab({ - id: podId, - kind: TabKind.POD_LOGS, - title: data.pod.getName(), - ...tabParams - }, false); - podLogsStore.setData(tab.id, data); - - return tab; -} - -export function isLogsTab(tab: IDockTab) { - return tab && tab.kind === TabKind.POD_LOGS; -} +export const logStore = new LogStore(); diff --git a/src/renderer/components/dock/logs.tsx b/src/renderer/components/dock/logs.tsx index c8ccd6d249..0aa31f95fb 100644 --- a/src/renderer/components/dock/logs.tsx +++ b/src/renderer/components/dock/logs.tsx @@ -8,9 +8,10 @@ import { IDockTab } from "./dock.store"; import { InfoPanel } from "./info-panel"; import { LogResourceSelector } from "./log-resource-selector"; import { LogList } from "./log-list"; -import { IPodLogsData, podLogsStore } from "./log.store"; +import { logStore } from "./log.store"; import { LogSearch } from "./log-search"; import { LogControls } from "./log-controls"; +import { LogTabData, logTabStore } from "./log-tab.store"; interface Props { className?: string @@ -30,7 +31,7 @@ export class Logs extends React.Component { } get tabData() { - return podLogsStore.getData(this.tabId); + return logTabStore.getData(this.tabId); } get tabId() { @@ -38,18 +39,18 @@ export class Logs extends React.Component { } @autobind() - save(data: Partial) { - podLogsStore.setData(this.tabId, { ...this.tabData, ...data }); + save(data: Partial) { + logTabStore.setData(this.tabId, { ...this.tabData, ...data }); } load = async () => { this.isLoading = true; - await podLogsStore.load(this.tabId); + await logStore.load(this.tabId); this.isLoading = false; }; reload = async () => { - podLogsStore.clearLogs(this.tabId); + logStore.clearLogs(this.tabId); await this.load(); }; @@ -82,11 +83,12 @@ export class Logs extends React.Component { } renderResourceSelector() { - const logs = podLogsStore.logs; - const searchLogs = this.tabData.showTimestamps ? logs : podLogsStore.logsWithoutTimestamps; + const logs = logStore.logs; + const searchLogs = this.tabData.showTimestamps ? logs : logStore.logsWithoutTimestamps; const controls = (
{ } render() { - const logs = podLogsStore.logs; + const logs = logStore.logs; return (
diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 956f5aa5f6..8a75bc7ae6 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -47,6 +47,10 @@ export abstract class KubeObjectStore extends ItemSt } } + getById(id: string) { + return this.items.find(item => item.getId() === id); + } + getByName(name: string, namespace?: string): T { return this.items.find(item => { return item.getName() === name && ( diff --git a/yarn.lock b/yarn.lock index 5e1cfa376d..dd9ec0c1c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -267,6 +267,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.12.5": + version "7.12.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e" + integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.10.1", "@babel/template@^7.3.3": version "7.10.1" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.1.tgz#e167154a94cb5f14b28dc58f5356d2162f539811" @@ -775,6 +782,17 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@jest/types@^26.6.2": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e" + integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ== + dependencies: + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^15.0.0" + chalk "^4.0.0" + "@kubernetes/client-node@^0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@kubernetes/client-node/-/client-node-0.12.0.tgz#79120311bced206ac8fa36435fb4cc2c1828fff2" @@ -955,6 +973,20 @@ dependencies: defer-to-connect "^1.0.1" +"@testing-library/dom@>=7": + version "7.29.4" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.29.4.tgz#1647c2b478789621ead7a50614ad81ab5ae5b86c" + integrity sha512-CtrJRiSYEfbtNGtEsd78mk1n1v2TUbeABlNIcOCJdDfkN5/JTOwQEbbQpoSRxGqzcWPgStMvJ4mNolSuBRv1NA== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^4.2.2" + chalk "^4.1.0" + dom-accessibility-api "^0.5.4" + lz-string "^1.4.4" + pretty-format "^26.6.2" + "@testing-library/dom@^7.26.0": version "7.26.3" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.26.3.tgz#5554ee985f712d621bd676104b879f85d9a7a0ef" @@ -4552,7 +4584,7 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.1: +dom-accessibility-api@^0.5.1, dom-accessibility-api@^0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz#b06d059cdd4a4ad9a79275f9d414a5c126241166" integrity sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ== @@ -10822,6 +10854,16 @@ pretty-format@^26.0.1: ansi-styles "^4.0.0" react-is "^16.12.0" +pretty-format@^26.6.2: + version "26.6.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93" + integrity sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg== + dependencies: + "@jest/types" "^26.6.2" + ansi-regex "^5.0.0" + ansi-styles "^4.0.0" + react-is "^17.0.1" + process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -11198,6 +11240,13 @@ react-router@5.2.0, react-router@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-select-event@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/react-select-event/-/react-select-event-5.1.0.tgz#d45ef68f2a9c872903e8c9725f3ae6e7576f7be0" + integrity sha512-D5DzJlYCdZsGbDVFMQFynrG0OLalJM3ZzDT7KQADNVWE604JCeQF9bIuvPZqVD7IzhnPsFzOUCsilzDA6w6WRQ== + dependencies: + "@testing-library/dom" ">=7" + react-select@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/react-select/-/react-select-3.1.0.tgz#ab098720b2e9fe275047c993f0d0caf5ded17c27" From 227a1497825afa362d543d5393d075efb7d11ae1 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 27 Jan 2021 20:04:50 +0200 Subject: [PATCH 029/219] Release v4.1.0-alpha.1 (#2026) Signed-off-by: Jari Kolehmainen --- package.json | 2 +- static/RELEASE_NOTES.md | 75 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3a06db621b..19689cc6a3 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.1.0-alpha.0", + "version": "4.1.0-alpha.1", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index b030c0d8b5..cdcf919304 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,7 +2,80 @@ Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! -## 4.0.2 (current version) +## 4.1.0-alpha.1 (current version) + +- Change: list views default to a namespace (insted of listing resources from all namespaces) +- Generic logs view with Pod selector +- Possibility to add custom Helm repository through Lens +- Possibility to change visibility of Pod list columns +- Suspend / resume buttons for CronJobs +- Dock tabs context menu +- Display node column in Pod list +- Unify age column output with kubectl +- Use dark colors in Dock regardless of active theme +- Improve Pod tolerations layout +- Lens metrics: scrape only lens-metrics namespace +- Lens metrics: Prometheus v2.19.3 +- Export PodDetailsList component to extension API +- Export Wizard components to extension API +- Export NamespaceSelect component to extension API + +## 4.0.8 + +- Fix: extension cluster sub-menu/page periodic re-render +- Fix: app hang on boot if started from command line & oh-my-zsh prompts for auto-update + +## 4.0.7 + +- Fix: typo in Prometheus Ingress metrics +- Fix: catch xterm.js fit error +- Fix: Windows tray icon click +- Fix: error on Kubernetes >= 1.20 on object edit +- Fix: multiline log wrapping +- Fix: prevent clusters from initializing multiple times +- Fix: show default workspace on first boot + +## 4.0.6 + +- Don't open Lens at OS login by default +- Disable GPU acceleration by setting an env variable +- Catch HTTP Errors in case pod metrics resources do not exist or access is forbidden +- Check is persistent volume claims resource to allowed for user +- Share react-router and react-router-dom libraries to extensions +- Fix: long list cropping in sidebar +- Fix: k0s distribution detection +- Fix: Preserve line breaks when copying logs +- Fix: error on api watch on complex api versions + +## 4.0.5 + +- Fix: add missing Kubernetes distro detectors +- Fix: improve how Workloads Overview is loaded +- Fix: race conditions on extension loader +- Fix: pod logs scrolling issues +- Fix: render node list before metrics are available +- Fix: kube-state-metrics v1.9.7 +- Fix: CRD sidebar expand/collapse +- Fix: disable oh-my-zsh auto-update prompt when resolving shell environment +- Add kubectl 1.20 support to Lens Smart Terminal +- Optimise performance during cluster connect + +## 4.0.4 + +- Fix errors on Kubernetes v1.20 +- Update bundled kubectl to v1.17.15 +- Fix: MacOS error on shutdown +- Fix: Kubernetes distribution detection +- Fix: error while displaying CRDs with column which type is an object + +## 4.0.3 + +- Fix: install in-tree extensions before others +- Fix: bundle all dependencies in in-tree extensions +- Fix: display error dialog if extensions couldn't be loaded +- Fix: ensure only one app instance + +## 4.0.2 We are aware some users are encountering issues and regressions from previous version. Many of these issues are something we have not seen as part of our automated or manual testing process. To make it worse, some of them are really difficult to reproduce. We want to ensure we are putting all our energy and effort trying to resolve these issues. We hope you are patient. Expect to see new patch releases still in the coming days! Fixes in this version: From e3db77f7ab319bd78993e3f4c6fa675683808c33 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 27 Jan 2021 20:05:12 +0200 Subject: [PATCH 030/219] Better extensionRoutes.keys() iteration (#2031) Signed-off-by: Jari Kolehmainen --- src/renderer/components/app.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 595506fcf9..958ab4b73d 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -174,11 +174,11 @@ export class App extends React.Component { } }); - Array.from(this.extensionRoutes.keys()).forEach((menu) => { + for (const menu of this.extensionRoutes.keys()) { if (!rootItems.includes(menu)) { this.extensionRoutes.delete(menu); } - }); + } } renderExtensionTabLayoutRoutes() { From a102ebad622a06f1070cc4db355989cf81b8eedb Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Wed, 27 Jan 2021 20:09:11 +0200 Subject: [PATCH 031/219] Bundle kubectl 1.18.15 (#2028) * bundle kubectl v1.18.15 Signed-off-by: Jari Kolehmainen * bump kubectl version map Signed-off-by: Jari Kolehmainen --- package.json | 2 +- src/main/kubectl.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 19689cc6a3..80d3c1d229 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts" }, "config": { - "bundledKubectlVersion": "1.17.15", + "bundledKubectlVersion": "1.18.15", "bundledHelmVersion": "3.4.2" }, "engines": { diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index ebfd2a6a98..7e0d6ed5c7 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -23,10 +23,10 @@ const kubectlMap: Map = new Map([ ["1.14", "1.14.10"], ["1.15", "1.15.11"], ["1.16", "1.16.15"], - ["1.17", bundledVersion], - ["1.18", "1.18.14"], - ["1.19", "1.19.5"], - ["1.20", "1.20.0"] + ["1.17", "1.17.17"], + ["1.18", bundledVersion], + ["1.19", "1.19.7"], + ["1.20", "1.20.2"] ]); const packageMirrors: Map = new Map([ ["default", "https://storage.googleapis.com/kubernetes-release/release"], From 3640f313b393cb7c4f5fda404713867cc80f94de Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Thu, 28 Jan 2021 12:18:45 +0300 Subject: [PATCH 032/219] Enabling configurable columns for all major tables (#2029) * Configurable columns in Deployments table Signed-off-by: Alex Andreev * Configurable columns in DaemonSets table Signed-off-by: Alex Andreev * Configurable columns in StatefulSets table Signed-off-by: Alex Andreev * Configurable columns in ReplicaSets table Signed-off-by: Alex Andreev * Configurable columns in Jobs table Signed-off-by: Alex Andreev * Configurable columns in CronJobs table Signed-off-by: Alex Andreev * Configurable columns in Nodes table Signed-off-by: Alex Andreev * Configurable columns in ConfigMaps table Signed-off-by: Alex Andreev * Configurable columns in Secrets table Signed-off-by: Alex Andreev * Configurable columns in ResourceQuota table Signed-off-by: Alex Andreev * Configurable columns in LimitRanges table Signed-off-by: Alex Andreev * Configurable columns in HPAs table Signed-off-by: Alex Andreev * Configurable columns in PodDistributionBudget table Signed-off-by: Alex Andreev * Configurable columns in Services table Signed-off-by: Alex Andreev * Configurable columns in Endpoints table Signed-off-by: Alex Andreev * Configurable columns in Ingresses table Signed-off-by: Alex Andreev * Configurable columns in NetworkPolicies table Signed-off-by: Alex Andreev * Configurable columns in Storage section Signed-off-by: Alex Andreev * Configurable columns in Namespaces table Signed-off-by: Alex Andreev * Configurable columns in Events table Signed-off-by: Alex Andreev * Configurable columns in Apps section Signed-off-by: Alex Andreev * Configurable columns in Access Control section Signed-off-by: Alex Andreev * Configurable columns in CRDs tables Signed-off-by: Alex Andreev --- .../+apps-helm-charts/helm-charts.tsx | 27 +++++++----- .../components/+apps-releases/releases.tsx | 34 ++++++++------- .../components/+config-autoscalers/hpa.tsx | 34 ++++++++------- .../+config-limit-ranges/limit-ranges.tsx | 18 ++++---- .../components/+config-maps/config-maps.tsx | 22 +++++----- .../pod-disruption-budgets.tsx | 34 ++++++++------- .../resource-quotas.tsx | 18 ++++---- .../components/+config-secrets/secrets.tsx | 30 ++++++------- .../components/+custom-resources/crd-list.tsx | 22 +++++----- .../+custom-resources/crd-resources.tsx | 19 +++++---- src/renderer/components/+events/events.tsx | 30 +++++++------ .../components/+namespaces/namespaces.tsx | 22 +++++----- .../+network-endpoints/endpoints.tsx | 21 ++++++---- .../+network-ingresses/ingresses.tsx | 24 ++++++----- .../+network-policies/network-policies.tsx | 21 ++++++---- .../components/+network-services/services.tsx | 41 +++++++++--------- src/renderer/components/+nodes/nodes.tsx | 42 ++++++++++--------- .../pod-security-policies.tsx | 22 +++++----- .../+storage-classes/storage-classes.tsx | 25 ++++++----- .../+storage-volume-claims/volume-claims.tsx | 34 ++++++++------- .../components/+storage-volumes/volumes.tsx | 29 +++++++------ .../role-bindings.tsx | 22 +++++----- .../+user-management-roles/roles.tsx | 18 ++++---- .../service-accounts.tsx | 18 ++++---- .../+workloads-cronjobs/cronjobs.tsx | 35 +++++++++------- .../+workloads-daemonsets/daemonsets.tsx | 25 ++++++----- .../+workloads-deployments/deployments.tsx | 29 +++++++------ .../components/+workloads-jobs/jobs.tsx | 25 ++++++----- .../+workloads-replicasets/replicasets.tsx | 30 ++++++------- .../+workloads-statefulsets/statefulsets.tsx | 25 ++++++----- 30 files changed, 439 insertions(+), 357 deletions(-) diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index 8bf5486a55..348ce00969 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -11,8 +11,11 @@ import { navigation } from "../../navigation"; import { ItemListLayout } from "../item-object-list/item-list-layout"; import { SearchInputUrl } from "../input"; -enum sortBy { +enum columnId { name = "name", + description = "description", + version = "version", + appVersion = "app-version", repo = "repo", } @@ -53,13 +56,15 @@ export class HelmCharts extends Component { return ( <> chart.getName(), - [sortBy.repo]: (chart: HelmChart) => chart.getRepository(), + [columnId.name]: (chart: HelmChart) => chart.getName(), + [columnId.repo]: (chart: HelmChart) => chart.getRepository(), }} searchFilters={[ (chart: HelmChart) => chart.getName(), @@ -74,13 +79,12 @@ export class HelmCharts extends Component { )} renderTableHeader={[ - { className: "icon" }, - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Description", className: "description" }, - { title: "Version", className: "version" }, - { title: "App Version", className: "app-version" }, - { title: "Repository", className: "repository", sortBy: sortBy.repo }, - + { className: "icon", showWithColumn: columnId.name }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Description", className: "description", id: columnId.description }, + { title: "Version", className: "version", id: columnId.version }, + { title: "App Version", className: "app-version", id: columnId.appVersion }, + { title: "Repository", className: "repository", sortBy: columnId.repo, id: columnId.repo }, ]} renderTableContents={(chart: HelmChart) => [
@@ -93,7 +97,8 @@ export class HelmCharts extends Component { chart.getDescription(), chart.getVersion(), chart.getAppVersion(), - { title: chart.getRepository(), className: chart.getRepository().toLowerCase() } + { title: chart.getRepository(), className: chart.getRepository().toLowerCase() }, + { className: "menu" } ]} detailsItem={this.selectedChart} onDetails={this.showDetails} diff --git a/src/renderer/components/+apps-releases/releases.tsx b/src/renderer/components/+apps-releases/releases.tsx index 709c6f9bbd..71cf3d954f 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -14,11 +14,13 @@ import { ItemListLayout } from "../item-object-list/item-list-layout"; import { HelmReleaseMenu } from "./release-menu"; import { secretsStore } from "../+config-secrets/secrets.store"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", revision = "revision", chart = "chart", + version = "version", + appVersion = "app-version", status = "status", updated = "update" } @@ -81,16 +83,18 @@ export class HelmReleases extends Component { return ( <> release.getName(), - [sortBy.namespace]: (release: HelmRelease) => release.getNs(), - [sortBy.revision]: (release: HelmRelease) => release.getRevision(), - [sortBy.chart]: (release: HelmRelease) => release.getChart(), - [sortBy.status]: (release: HelmRelease) => release.getStatus(), - [sortBy.updated]: (release: HelmRelease) => release.getUpdated(false, false), + [columnId.name]: (release: HelmRelease) => release.getName(), + [columnId.namespace]: (release: HelmRelease) => release.getNs(), + [columnId.revision]: (release: HelmRelease) => release.getRevision(), + [columnId.chart]: (release: HelmRelease) => release.getChart(), + [columnId.status]: (release: HelmRelease) => release.getStatus(), + [columnId.updated]: (release: HelmRelease) => release.getUpdated(false, false), }} searchFilters={[ (release: HelmRelease) => release.getName(), @@ -101,14 +105,14 @@ export class HelmReleases extends Component { ]} renderHeaderTitle="Releases" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Chart", className: "chart", sortBy: sortBy.chart }, - { title: "Revision", className: "revision", sortBy: sortBy.revision }, - { title: "Version", className: "version" }, - { title: "App Version", className: "app-version" }, - { title: "Status", className: "status", sortBy: sortBy.status }, - { title: "Updated", className: "updated", sortBy: sortBy.updated }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Chart", className: "chart", sortBy: columnId.chart, id: columnId.chart }, + { title: "Revision", className: "revision", sortBy: columnId.revision, id: columnId.revision }, + { title: "Version", className: "version", id: columnId.version }, + { title: "App Version", className: "app-version", id: columnId.appVersion }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, + { title: "Updated", className: "updated", sortBy: columnId.updated, id: columnId.updated }, ]} renderTableContents={(release: HelmRelease) => { const version = release.getVersion(); diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx index 023a28f156..2e2a78fc82 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -11,13 +11,15 @@ import { Badge } from "../badge"; import { cssNames } from "../../utils"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + metrics = "metrics", minPods = "min-pods", maxPods = "max-pods", replicas = "replicas", age = "age", + status = "status" } interface Props extends RouteComponentProps { @@ -37,28 +39,30 @@ export class HorizontalPodAutoscalers extends React.Component { render() { return ( item.getName(), - [sortBy.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(), - [sortBy.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(), - [sortBy.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(), - [sortBy.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas() + [columnId.name]: (item: HorizontalPodAutoscaler) => item.getName(), + [columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(), + [columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(), + [columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(), + [columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas() }} searchFilters={[ (item: HorizontalPodAutoscaler) => item.getSearchFields() ]} renderHeaderTitle="Horizontal Pod Autoscalers" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Metrics", className: "metrics" }, - { title: "Min Pods", className: "min-pods", sortBy: sortBy.minPods }, - { title: "Max Pods", className: "max-pods", sortBy: sortBy.maxPods }, - { title: "Replicas", className: "replicas", sortBy: sortBy.replicas }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status" }, + { title: "Name", className: "name", sortBy: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Metrics", className: "metrics", id: columnId.metrics }, + { title: "Min Pods", className: "min-pods", sortBy: columnId.minPods, id: columnId.minPods }, + { title: "Max Pods", className: "max-pods", sortBy: columnId.maxPods, id: columnId.maxPods }, + { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", id: columnId.status }, ]} renderTableContents={(hpa: HorizontalPodAutoscaler) => [ hpa.getName(), diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx index 8bb498c1c0..a3b111929a 100644 --- a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx @@ -9,7 +9,7 @@ import React from "react"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { LimitRange } from "../../api/endpoints/limit-range.api"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", age = "age", @@ -23,12 +23,14 @@ export class LimitRanges extends React.Component { render() { return ( item.getName(), - [sortBy.namespace]: (item: LimitRange) => item.getNs(), - [sortBy.age]: (item: LimitRange) => item.metadata.creationTimestamp, + [columnId.name]: (item: LimitRange) => item.getName(), + [columnId.namespace]: (item: LimitRange) => item.getNs(), + [columnId.age]: (item: LimitRange) => item.metadata.creationTimestamp, }} searchFilters={[ (item: LimitRange) => item.getName(), @@ -36,10 +38,10 @@ export class LimitRanges extends React.Component { ]} renderHeaderTitle={"Limit Ranges"} renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(limitRange: LimitRange) => [ limitRange.getName(), diff --git a/src/renderer/components/+config-maps/config-maps.tsx b/src/renderer/components/+config-maps/config-maps.tsx index 128c583fc8..532532bf53 100644 --- a/src/renderer/components/+config-maps/config-maps.tsx +++ b/src/renderer/components/+config-maps/config-maps.tsx @@ -9,7 +9,7 @@ import { KubeObjectListLayout } from "../kube-object"; import { IConfigMapsRouteParams } from "./config-maps.route"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", keys = "keys", @@ -24,12 +24,14 @@ export class ConfigMaps extends React.Component { render() { return ( item.getName(), - [sortBy.namespace]: (item: ConfigMap) => item.getNs(), - [sortBy.keys]: (item: ConfigMap) => item.getKeys(), - [sortBy.age]: (item: ConfigMap) => item.metadata.creationTimestamp, + [columnId.name]: (item: ConfigMap) => item.getName(), + [columnId.namespace]: (item: ConfigMap) => item.getNs(), + [columnId.keys]: (item: ConfigMap) => item.getKeys(), + [columnId.age]: (item: ConfigMap) => item.metadata.creationTimestamp, }} searchFilters={[ (item: ConfigMap) => item.getSearchFields(), @@ -37,11 +39,11 @@ export class ConfigMaps extends React.Component { ]} renderHeaderTitle="Config Maps" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Keys", className: "keys", sortBy: sortBy.keys }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Keys", className: "keys", sortBy: columnId.keys, id: columnId.keys }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(configMap: ConfigMap) => [ configMap.getName(), diff --git a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx b/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx index f0754e0be8..8136225f11 100644 --- a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx +++ b/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx @@ -7,7 +7,7 @@ import { PodDisruptionBudget } from "../../api/endpoints/poddisruptionbudget.api import { KubeObjectDetailsProps, KubeObjectListLayout } from "../kube-object"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", minAvailable = "min-available", @@ -25,30 +25,32 @@ export class PodDisruptionBudgets extends React.Component { render() { return ( pdb.getName(), - [sortBy.namespace]: (pdb: PodDisruptionBudget) => pdb.getNs(), - [sortBy.minAvailable]: (pdb: PodDisruptionBudget) => pdb.getMinAvailable(), - [sortBy.maxUnavailable]: (pdb: PodDisruptionBudget) => pdb.getMaxUnavailable(), - [sortBy.currentHealthy]: (pdb: PodDisruptionBudget) => pdb.getCurrentHealthy(), - [sortBy.desiredHealthy]: (pdb: PodDisruptionBudget) => pdb.getDesiredHealthy(), - [sortBy.age]: (pdb: PodDisruptionBudget) => pdb.getAge(), + [columnId.name]: (pdb: PodDisruptionBudget) => pdb.getName(), + [columnId.namespace]: (pdb: PodDisruptionBudget) => pdb.getNs(), + [columnId.minAvailable]: (pdb: PodDisruptionBudget) => pdb.getMinAvailable(), + [columnId.maxUnavailable]: (pdb: PodDisruptionBudget) => pdb.getMaxUnavailable(), + [columnId.currentHealthy]: (pdb: PodDisruptionBudget) => pdb.getCurrentHealthy(), + [columnId.desiredHealthy]: (pdb: PodDisruptionBudget) => pdb.getDesiredHealthy(), + [columnId.age]: (pdb: PodDisruptionBudget) => pdb.getAge(), }} searchFilters={[ (pdb: PodDisruptionBudget) => pdb.getSearchFields(), ]} renderHeaderTitle="Pod Disruption Budgets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Min Available", className: "min-available", sortBy: sortBy.minAvailable }, - { title: "Max Unavailable", className: "max-unavailable", sortBy: sortBy.maxUnavailable }, - { title: "Current Healthy", className: "current-healthy", sortBy: sortBy.currentHealthy }, - { title: "Desired Healthy", className: "desired-healthy", sortBy: sortBy.desiredHealthy }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Min Available", className: "min-available", sortBy: columnId.minAvailable, id: columnId.minAvailable }, + { title: "Max Unavailable", className: "max-unavailable", sortBy: columnId.maxUnavailable, id: columnId.maxUnavailable }, + { title: "Current Healthy", className: "current-healthy", sortBy: columnId.currentHealthy, id: columnId.currentHealthy }, + { title: "Desired Healthy", className: "desired-healthy", sortBy: columnId.desiredHealthy, id: columnId.desiredHealthy }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(pdb: PodDisruptionBudget) => { return [ diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx index 1aed2c9d24..5adfef2edc 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx +++ b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx @@ -10,7 +10,7 @@ import { resourceQuotaStore } from "./resource-quotas.store"; import { IResourceQuotaRouteParams } from "./resource-quotas.route"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", age = "age" @@ -25,11 +25,13 @@ export class ResourceQuotas extends React.Component { return ( <> item.getName(), - [sortBy.namespace]: (item: ResourceQuota) => item.getNs(), - [sortBy.age]: (item: ResourceQuota) => item.metadata.creationTimestamp, + [columnId.name]: (item: ResourceQuota) => item.getName(), + [columnId.namespace]: (item: ResourceQuota) => item.getNs(), + [columnId.age]: (item: ResourceQuota) => item.metadata.creationTimestamp, }} searchFilters={[ (item: ResourceQuota) => item.getSearchFields(), @@ -37,10 +39,10 @@ export class ResourceQuotas extends React.Component { ]} renderHeaderTitle="Resource Quotas" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(resourceQuota: ResourceQuota) => [ resourceQuota.getName(), diff --git a/src/renderer/components/+config-secrets/secrets.tsx b/src/renderer/components/+config-secrets/secrets.tsx index f2c88fda58..60158cfb55 100644 --- a/src/renderer/components/+config-secrets/secrets.tsx +++ b/src/renderer/components/+config-secrets/secrets.tsx @@ -11,7 +11,7 @@ import { Badge } from "../badge"; import { secretsStore } from "./secrets.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", labels = "labels", @@ -29,14 +29,16 @@ export class Secrets extends React.Component { return ( <> item.getName(), - [sortBy.namespace]: (item: Secret) => item.getNs(), - [sortBy.labels]: (item: Secret) => item.getLabels(), - [sortBy.keys]: (item: Secret) => item.getKeys(), - [sortBy.type]: (item: Secret) => item.type, - [sortBy.age]: (item: Secret) => item.metadata.creationTimestamp, + [columnId.name]: (item: Secret) => item.getName(), + [columnId.namespace]: (item: Secret) => item.getNs(), + [columnId.labels]: (item: Secret) => item.getLabels(), + [columnId.keys]: (item: Secret) => item.getKeys(), + [columnId.type]: (item: Secret) => item.type, + [columnId.age]: (item: Secret) => item.metadata.creationTimestamp, }} searchFilters={[ (item: Secret) => item.getSearchFields(), @@ -44,13 +46,13 @@ export class Secrets extends React.Component { ]} renderHeaderTitle="Secrets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Labels", className: "labels", sortBy: sortBy.labels }, - { title: "Keys", className: "keys", sortBy: sortBy.keys }, - { title: "Type", className: "type", sortBy: sortBy.type }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Labels", className: "labels", sortBy: columnId.labels, id: columnId.labels }, + { title: "Keys", className: "keys", sortBy: columnId.keys, id: columnId.keys }, + { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(secret: Secret) => [ secret.getName(), diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index 8868231235..f8b77c09a9 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -19,7 +19,7 @@ export const crdGroupsUrlParam = createPageParam({ defaultValue: [], }); -enum sortBy { +enum columnId { kind = "kind", group = "group", version = "version", @@ -47,14 +47,16 @@ export class CrdList extends React.Component { render() { const selectedGroups = this.groups; const sortingCallbacks = { - [sortBy.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(), - [sortBy.group]: (crd: CustomResourceDefinition) => crd.getGroup(), - [sortBy.version]: (crd: CustomResourceDefinition) => crd.getVersion(), - [sortBy.scope]: (crd: CustomResourceDefinition) => crd.getScope(), + [columnId.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(), + [columnId.group]: (crd: CustomResourceDefinition) => crd.getGroup(), + [columnId.version]: (crd: CustomResourceDefinition) => crd.getVersion(), + [columnId.scope]: (crd: CustomResourceDefinition) => crd.getScope(), }; return ( [ diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index e6a7f2aac6..b9008b410d 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -16,7 +16,7 @@ import { parseJsonPath } from "../../utils/jsonPath"; interface Props extends RouteComponentProps { } -enum sortBy { +enum columnId { name = "name", namespace = "namespace", age = "age", @@ -55,9 +55,9 @@ export class CrdResources extends React.Component { const isNamespaced = crd.isNamespaced(); const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details const sortingCallbacks: { [sortBy: string]: TableSortCallback } = { - [sortBy.name]: (item: KubeObject) => item.getName(), - [sortBy.namespace]: (item: KubeObject) => item.getNs(), - [sortBy.age]: (item: KubeObject) => item.metadata.creationTimestamp, + [columnId.name]: (item: KubeObject) => item.getName(), + [columnId.namespace]: (item: KubeObject) => item.getNs(), + [columnId.age]: (item: KubeObject) => item.metadata.creationTimestamp, }; extraColumns.forEach(column => { @@ -66,6 +66,8 @@ export class CrdResources extends React.Component { return ( { ]} renderHeaderTitle={crd.getResourceTitle()} renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - isNamespaced && { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, ...extraColumns.map(column => { const { name } = column; return { title: name, className: name.toLowerCase(), - sortBy: name + sortBy: name, + id: name }; }), - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(crdInstance: KubeObject) => [ crdInstance.getName(), diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index c4e6920bc8..8e2af5d9a8 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -12,11 +12,13 @@ import { cssNames, IClassName, stopPropagation } from "../../utils"; import { Icon } from "../icon"; import { lookupApiLink } from "../../api/kube-api"; -enum sortBy { +enum columnId { + message = "message", namespace = "namespace", object = "object", type = "type", count = "count", + source = "source", age = "age", } @@ -39,15 +41,17 @@ export class Events extends React.Component { const events = ( event.getNs(), - [sortBy.type]: (event: KubeEvent) => event.involvedObject.kind, - [sortBy.object]: (event: KubeEvent) => event.involvedObject.name, - [sortBy.count]: (event: KubeEvent) => event.count, - [sortBy.age]: (event: KubeEvent) => event.metadata.creationTimestamp, + [columnId.namespace]: (event: KubeEvent) => event.getNs(), + [columnId.type]: (event: KubeEvent) => event.involvedObject.kind, + [columnId.object]: (event: KubeEvent) => event.involvedObject.name, + [columnId.count]: (event: KubeEvent) => event.count, + [columnId.age]: (event: KubeEvent) => event.metadata.creationTimestamp, }} searchFilters={[ (event: KubeEvent) => event.getSearchFields(), @@ -72,13 +76,13 @@ export class Events extends React.Component { }) )} renderTableHeader={[ - { title: "Message", className: "message" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Type", className: "type", sortBy: sortBy.type }, - { title: "Involved Object", className: "object", sortBy: sortBy.object }, - { title: "Source", className: "source" }, - { title: "Count", className: "count", sortBy: sortBy.count }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Message", className: "message", id: columnId.message }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, + { title: "Involved Object", className: "object", sortBy: columnId.object, id: columnId.object }, + { title: "Source", className: "source", id: columnId.source }, + { title: "Count", className: "count", sortBy: columnId.count, id: columnId.count }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(event: KubeEvent) => { const { involvedObject, type, message } = event; diff --git a/src/renderer/components/+namespaces/namespaces.tsx b/src/renderer/components/+namespaces/namespaces.tsx index f097657493..3972f3d180 100644 --- a/src/renderer/components/+namespaces/namespaces.tsx +++ b/src/renderer/components/+namespaces/namespaces.tsx @@ -11,7 +11,7 @@ import { INamespacesRouteParams } from "./namespaces.route"; import { namespaceStore } from "./namespace.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", labels = "labels", age = "age", @@ -27,12 +27,14 @@ export class Namespaces extends React.Component { ns.getName(), - [sortBy.labels]: (ns: Namespace) => ns.getLabels(), - [sortBy.age]: (ns: Namespace) => ns.metadata.creationTimestamp, - [sortBy.status]: (ns: Namespace) => ns.getStatus(), + [columnId.name]: (ns: Namespace) => ns.getName(), + [columnId.labels]: (ns: Namespace) => ns.getLabels(), + [columnId.age]: (ns: Namespace) => ns.metadata.creationTimestamp, + [columnId.status]: (ns: Namespace) => ns.getStatus(), }} searchFilters={[ (item: Namespace) => item.getSearchFields(), @@ -40,11 +42,11 @@ export class Namespaces extends React.Component { ]} renderHeaderTitle="Namespaces" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Labels", className: "labels", sortBy: sortBy.labels }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Labels", className: "labels", sortBy: columnId.labels, id: columnId.labels }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(item: Namespace) => [ item.getName(), diff --git a/src/renderer/components/+network-endpoints/endpoints.tsx b/src/renderer/components/+network-endpoints/endpoints.tsx index 3b859c46f3..ce87c14a4a 100644 --- a/src/renderer/components/+network-endpoints/endpoints.tsx +++ b/src/renderer/components/+network-endpoints/endpoints.tsx @@ -9,9 +9,10 @@ import { endpointStore } from "./endpoints.store"; import { KubeObjectListLayout } from "../kube-object"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + endpoints = "endpoints", age = "age", } @@ -23,22 +24,24 @@ export class Endpoints extends React.Component { render() { return ( endpoint.getName(), - [sortBy.namespace]: (endpoint: Endpoint) => endpoint.getNs(), - [sortBy.age]: (endpoint: Endpoint) => endpoint.metadata.creationTimestamp, + [columnId.name]: (endpoint: Endpoint) => endpoint.getName(), + [columnId.namespace]: (endpoint: Endpoint) => endpoint.getNs(), + [columnId.age]: (endpoint: Endpoint) => endpoint.metadata.creationTimestamp, }} searchFilters={[ (endpoint: Endpoint) => endpoint.getSearchFields() ]} renderHeaderTitle="Endpoints" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Endpoints", className: "endpoints" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Endpoints", className: "endpoints", id: columnId.endpoints }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(endpoint: Endpoint) => [ endpoint.getName(), diff --git a/src/renderer/components/+network-ingresses/ingresses.tsx b/src/renderer/components/+network-ingresses/ingresses.tsx index adb6c84528..945f2b8f0a 100644 --- a/src/renderer/components/+network-ingresses/ingresses.tsx +++ b/src/renderer/components/+network-ingresses/ingresses.tsx @@ -9,9 +9,11 @@ import { ingressStore } from "./ingress.store"; import { KubeObjectListLayout } from "../kube-object"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + loadBalancers ="load-balancers", + rules = "rules", age = "age", } @@ -23,11 +25,13 @@ export class Ingresses extends React.Component { render() { return ( ingress.getName(), - [sortBy.namespace]: (ingress: Ingress) => ingress.getNs(), - [sortBy.age]: (ingress: Ingress) => ingress.metadata.creationTimestamp, + [columnId.name]: (ingress: Ingress) => ingress.getName(), + [columnId.namespace]: (ingress: Ingress) => ingress.getNs(), + [columnId.age]: (ingress: Ingress) => ingress.metadata.creationTimestamp, }} searchFilters={[ (ingress: Ingress) => ingress.getSearchFields(), @@ -35,12 +39,12 @@ export class Ingresses extends React.Component { ]} renderHeaderTitle="Ingresses" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "LoadBalancers", className: "loadbalancers" }, - { title: "Rules", className: "rules" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "LoadBalancers", className: "loadbalancers", id: columnId.loadBalancers }, + { title: "Rules", className: "rules", id: columnId.rules }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(ingress: Ingress) => [ ingress.getName(), diff --git a/src/renderer/components/+network-policies/network-policies.tsx b/src/renderer/components/+network-policies/network-policies.tsx index d4dc0e2fa9..6899c14558 100644 --- a/src/renderer/components/+network-policies/network-policies.tsx +++ b/src/renderer/components/+network-policies/network-policies.tsx @@ -9,9 +9,10 @@ import { INetworkPoliciesRouteParams } from "./network-policies.route"; import { networkPolicyStore } from "./network-policy.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + types = "types", age = "age", } @@ -23,22 +24,24 @@ export class NetworkPolicies extends React.Component { render() { return ( item.getName(), - [sortBy.namespace]: (item: NetworkPolicy) => item.getNs(), - [sortBy.age]: (item: NetworkPolicy) => item.metadata.creationTimestamp, + [columnId.name]: (item: NetworkPolicy) => item.getName(), + [columnId.namespace]: (item: NetworkPolicy) => item.getNs(), + [columnId.age]: (item: NetworkPolicy) => item.metadata.creationTimestamp, }} searchFilters={[ (item: NetworkPolicy) => item.getSearchFields(), ]} renderHeaderTitle="Network Policies" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Policy Types", className: "type" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Policy Types", className: "type", id: columnId.types }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(item: NetworkPolicy) => [ item.getName(), diff --git a/src/renderer/components/+network-services/services.tsx b/src/renderer/components/+network-services/services.tsx index 3452c10a68..740e0bfdf1 100644 --- a/src/renderer/components/+network-services/services.tsx +++ b/src/renderer/components/+network-services/services.tsx @@ -10,12 +10,13 @@ import { Badge } from "../badge"; import { serviceStore } from "./services.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", selector = "selector", ports = "port", clusterIp = "cluster-ip", + externalIp = "external-ip", age = "age", type = "type", status = "status", @@ -29,16 +30,18 @@ export class Services extends React.Component { render() { return ( service.getName(), - [sortBy.namespace]: (service: Service) => service.getNs(), - [sortBy.selector]: (service: Service) => service.getSelector(), - [sortBy.ports]: (service: Service) => (service.spec.ports || []).map(({ port }) => port)[0], - [sortBy.clusterIp]: (service: Service) => service.getClusterIp(), - [sortBy.type]: (service: Service) => service.getType(), - [sortBy.age]: (service: Service) => service.metadata.creationTimestamp, - [sortBy.status]: (service: Service) => service.getStatus(), + [columnId.name]: (service: Service) => service.getName(), + [columnId.namespace]: (service: Service) => service.getNs(), + [columnId.selector]: (service: Service) => service.getSelector(), + [columnId.ports]: (service: Service) => (service.spec.ports || []).map(({ port }) => port)[0], + [columnId.clusterIp]: (service: Service) => service.getClusterIp(), + [columnId.type]: (service: Service) => service.getType(), + [columnId.age]: (service: Service) => service.metadata.creationTimestamp, + [columnId.status]: (service: Service) => service.getStatus(), }} searchFilters={[ (service: Service) => service.getSearchFields(), @@ -47,16 +50,16 @@ export class Services extends React.Component { ]} renderHeaderTitle="Services" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Type", className: "type", sortBy: sortBy.type }, - { title: "Cluster IP", className: "clusterIp", sortBy: sortBy.clusterIp, }, - { title: "Ports", className: "ports", sortBy: sortBy.ports }, - { title: "External IP", className: "externalIp" }, - { title: "Selector", className: "selector", sortBy: sortBy.selector }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, + { title: "Cluster IP", className: "clusterIp", sortBy: columnId.clusterIp, id: columnId.clusterIp }, + { title: "Ports", className: "ports", sortBy: columnId.ports, id: columnId.ports }, + { title: "External IP", className: "externalIp", id: columnId.externalIp }, + { title: "Selector", className: "selector", sortBy: columnId.selector, id: columnId.selector }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(service: Service) => [ service.getName(), diff --git a/src/renderer/components/+nodes/nodes.tsx b/src/renderer/components/+nodes/nodes.tsx index 1ca12343b5..32da6a13db 100644 --- a/src/renderer/components/+nodes/nodes.tsx +++ b/src/renderer/components/+nodes/nodes.tsx @@ -17,7 +17,7 @@ import upperFirst from "lodash/upperFirst"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { Badge } from "../badge/badge"; -enum sortBy { +enum columnId { name = "name", cpu = "cpu", memory = "memory", @@ -135,21 +135,23 @@ export class Nodes extends React.Component { return ( node.getName(), - [sortBy.cpu]: (node: Node) => nodesStore.getLastMetricValues(node, ["cpuUsage"]), - [sortBy.memory]: (node: Node) => nodesStore.getLastMetricValues(node, ["memoryUsage"]), - [sortBy.disk]: (node: Node) => nodesStore.getLastMetricValues(node, ["fsUsage"]), - [sortBy.conditions]: (node: Node) => node.getNodeConditionText(), - [sortBy.taints]: (node: Node) => node.getTaints().length, - [sortBy.roles]: (node: Node) => node.getRoleLabels(), - [sortBy.age]: (node: Node) => node.metadata.creationTimestamp, - [sortBy.version]: (node: Node) => node.getKubeletVersion(), + [columnId.name]: (node: Node) => node.getName(), + [columnId.cpu]: (node: Node) => nodesStore.getLastMetricValues(node, ["cpuUsage"]), + [columnId.memory]: (node: Node) => nodesStore.getLastMetricValues(node, ["memoryUsage"]), + [columnId.disk]: (node: Node) => nodesStore.getLastMetricValues(node, ["fsUsage"]), + [columnId.conditions]: (node: Node) => node.getNodeConditionText(), + [columnId.taints]: (node: Node) => node.getTaints().length, + [columnId.roles]: (node: Node) => node.getRoleLabels(), + [columnId.age]: (node: Node) => node.metadata.creationTimestamp, + [columnId.version]: (node: Node) => node.getKubeletVersion(), }} searchFilters={[ (node: Node) => node.getSearchFields(), @@ -159,16 +161,16 @@ export class Nodes extends React.Component { ]} renderHeaderTitle="Nodes" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "CPU", className: "cpu", sortBy: sortBy.cpu }, - { title: "Memory", className: "memory", sortBy: sortBy.memory }, - { title: "Disk", className: "disk", sortBy: sortBy.disk }, - { title: "Taints", className: "taints", sortBy: sortBy.taints }, - { title: "Roles", className: "roles", sortBy: sortBy.roles }, - { title: "Version", className: "version", sortBy: sortBy.version }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Conditions", className: "conditions", sortBy: sortBy.conditions }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "CPU", className: "cpu", sortBy: columnId.cpu, id: columnId.cpu }, + { title: "Memory", className: "memory", sortBy: columnId.memory, id: columnId.memory }, + { title: "Disk", className: "disk", sortBy: columnId.disk, id: columnId.disk }, + { title: "Taints", className: "taints", sortBy: columnId.taints, id: columnId.taints }, + { title: "Roles", className: "roles", sortBy: columnId.roles, id: columnId.roles }, + { title: "Version", className: "version", sortBy: columnId.version, id: columnId.version }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Conditions", className: "conditions", sortBy: columnId.conditions, id: columnId.conditions }, ]} renderTableContents={(node: Node) => { const tooltipId = `node-taints-${node.getId()}`; diff --git a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx index 30ec1d6304..a91e0114d6 100644 --- a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx +++ b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx @@ -7,7 +7,7 @@ import { podSecurityPoliciesStore } from "./pod-security-policies.store"; import { PodSecurityPolicy } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", volumes = "volumes", privileged = "privileged", @@ -19,14 +19,16 @@ export class PodSecurityPolicies extends React.Component { render() { return ( item.getName(), - [sortBy.volumes]: (item: PodSecurityPolicy) => item.getVolumes(), - [sortBy.privileged]: (item: PodSecurityPolicy) => +item.isPrivileged(), - [sortBy.age]: (item: PodSecurityPolicy) => item.metadata.creationTimestamp, + [columnId.name]: (item: PodSecurityPolicy) => item.getName(), + [columnId.volumes]: (item: PodSecurityPolicy) => item.getVolumes(), + [columnId.privileged]: (item: PodSecurityPolicy) => +item.isPrivileged(), + [columnId.age]: (item: PodSecurityPolicy) => item.metadata.creationTimestamp, }} searchFilters={[ (item: PodSecurityPolicy) => item.getSearchFields(), @@ -35,11 +37,11 @@ export class PodSecurityPolicies extends React.Component { ]} renderHeaderTitle="Pod Security Policies" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Privileged", className: "privileged", sortBy: sortBy.privileged }, - { title: "Volumes", className: "volumes", sortBy: sortBy.volumes }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Privileged", className: "privileged", sortBy: columnId.privileged, id: columnId.privileged }, + { title: "Volumes", className: "volumes", sortBy: columnId.volumes, id: columnId.volumes }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(item: PodSecurityPolicy) => { return [ diff --git a/src/renderer/components/+storage-classes/storage-classes.tsx b/src/renderer/components/+storage-classes/storage-classes.tsx index ec7e1c8e05..1a8ed346fd 100644 --- a/src/renderer/components/+storage-classes/storage-classes.tsx +++ b/src/renderer/components/+storage-classes/storage-classes.tsx @@ -9,10 +9,11 @@ import { IStorageClassesRouteParams } from "./storage-classes.route"; import { storageClassStore } from "./storage-class.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", age = "age", provisioner = "provision", + default = "default", reclaimPolicy = "reclaim", } @@ -24,13 +25,15 @@ export class StorageClasses extends React.Component { render() { return ( item.getName(), - [sortBy.age]: (item: StorageClass) => item.metadata.creationTimestamp, - [sortBy.provisioner]: (item: StorageClass) => item.provisioner, - [sortBy.reclaimPolicy]: (item: StorageClass) => item.reclaimPolicy, + [columnId.name]: (item: StorageClass) => item.getName(), + [columnId.age]: (item: StorageClass) => item.metadata.creationTimestamp, + [columnId.provisioner]: (item: StorageClass) => item.provisioner, + [columnId.reclaimPolicy]: (item: StorageClass) => item.reclaimPolicy, }} searchFilters={[ (item: StorageClass) => item.getSearchFields(), @@ -38,12 +41,12 @@ export class StorageClasses extends React.Component { ]} renderHeaderTitle="Storage Classes" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Provisioner", className: "provisioner", sortBy: sortBy.provisioner }, - { title: "Reclaim Policy", className: "reclaim-policy", sortBy: sortBy.reclaimPolicy }, - { title: "Default", className: "is-default" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Provisioner", className: "provisioner", sortBy: columnId.provisioner, id: columnId.provisioner }, + { title: "Reclaim Policy", className: "reclaim-policy", sortBy: columnId.reclaimPolicy, id: columnId.reclaimPolicy }, + { title: "Default", className: "is-default", id: columnId.default }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(storageClass: StorageClass) => [ storageClass.getName(), diff --git a/src/renderer/components/+storage-volume-claims/volume-claims.tsx b/src/renderer/components/+storage-volume-claims/volume-claims.tsx index bb9a4a05a7..e93529b8d2 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claims.tsx +++ b/src/renderer/components/+storage-volume-claims/volume-claims.tsx @@ -13,7 +13,7 @@ import { stopPropagation } from "../../utils"; import { storageClassApi } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", pods = "pods", @@ -31,17 +31,19 @@ export class PersistentVolumeClaims extends React.Component { render() { return ( pvc.getName(), - [sortBy.namespace]: (pvc: PersistentVolumeClaim) => pvc.getNs(), - [sortBy.pods]: (pvc: PersistentVolumeClaim) => pvc.getPods(podsStore.items).map(pod => pod.getName()), - [sortBy.status]: (pvc: PersistentVolumeClaim) => pvc.getStatus(), - [sortBy.size]: (pvc: PersistentVolumeClaim) => unitsToBytes(pvc.getStorage()), - [sortBy.storageClass]: (pvc: PersistentVolumeClaim) => pvc.spec.storageClassName, - [sortBy.age]: (pvc: PersistentVolumeClaim) => pvc.metadata.creationTimestamp, + [columnId.name]: (pvc: PersistentVolumeClaim) => pvc.getName(), + [columnId.namespace]: (pvc: PersistentVolumeClaim) => pvc.getNs(), + [columnId.pods]: (pvc: PersistentVolumeClaim) => pvc.getPods(podsStore.items).map(pod => pod.getName()), + [columnId.status]: (pvc: PersistentVolumeClaim) => pvc.getStatus(), + [columnId.size]: (pvc: PersistentVolumeClaim) => unitsToBytes(pvc.getStorage()), + [columnId.storageClass]: (pvc: PersistentVolumeClaim) => pvc.spec.storageClassName, + [columnId.age]: (pvc: PersistentVolumeClaim) => pvc.metadata.creationTimestamp, }} searchFilters={[ (item: PersistentVolumeClaim) => item.getSearchFields(), @@ -49,14 +51,14 @@ export class PersistentVolumeClaims extends React.Component { ]} renderHeaderTitle="Persistent Volume Claims" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Storage class", className: "storageClass", sortBy: sortBy.storageClass }, - { title: "Size", className: "size", sortBy: sortBy.size }, - { title: "Pods", className: "pods", sortBy: sortBy.pods }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Storage class", className: "storageClass", sortBy: columnId.storageClass, id: columnId.storageClass }, + { title: "Size", className: "size", sortBy: columnId.size, id: columnId.size }, + { title: "Pods", className: "pods", sortBy: columnId.pods, id: columnId.pods }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(pvc: PersistentVolumeClaim) => { const pods = pvc.getPods(podsStore.items); diff --git a/src/renderer/components/+storage-volumes/volumes.tsx b/src/renderer/components/+storage-volumes/volumes.tsx index 412093a7ad..6822e3d4f7 100644 --- a/src/renderer/components/+storage-volumes/volumes.tsx +++ b/src/renderer/components/+storage-volumes/volumes.tsx @@ -11,10 +11,11 @@ import { volumesStore } from "./volumes.store"; import { pvcApi, storageClassApi } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", storageClass = "storage-class", capacity = "capacity", + claim = "claim", status = "status", age = "age", } @@ -27,14 +28,16 @@ export class PersistentVolumes extends React.Component { render() { return ( item.getName(), - [sortBy.storageClass]: (item: PersistentVolume) => item.spec.storageClassName, - [sortBy.capacity]: (item: PersistentVolume) => item.getCapacity(true), - [sortBy.status]: (item: PersistentVolume) => item.getStatus(), - [sortBy.age]: (item: PersistentVolume) => item.metadata.creationTimestamp, + [columnId.name]: (item: PersistentVolume) => item.getName(), + [columnId.storageClass]: (item: PersistentVolume) => item.spec.storageClassName, + [columnId.capacity]: (item: PersistentVolume) => item.getCapacity(true), + [columnId.status]: (item: PersistentVolume) => item.getStatus(), + [columnId.age]: (item: PersistentVolume) => item.metadata.creationTimestamp, }} searchFilters={[ (item: PersistentVolume) => item.getSearchFields(), @@ -42,13 +45,13 @@ export class PersistentVolumes extends React.Component { ]} renderHeaderTitle="Persistent Volumes" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Storage Class", className: "storageClass", sortBy: sortBy.storageClass }, - { title: "Capacity", className: "capacity", sortBy: sortBy.capacity }, - { title: "Claim", className: "claim" }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Status", className: "status", sortBy: sortBy.status }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Storage Class", className: "storageClass", sortBy: columnId.storageClass, id: columnId.storageClass }, + { title: "Capacity", className: "capacity", sortBy: columnId.capacity, id: columnId.capacity }, + { title: "Claim", className: "claim", id: columnId.claim }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={(volume: PersistentVolume) => { const { claimRef, storageClassName } = volume.spec; 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 3d64562047..f55e781e0e 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx +++ b/src/renderer/components/+user-management-roles-bindings/role-bindings.tsx @@ -10,7 +10,7 @@ import { KubeObjectListLayout } from "../kube-object"; import { AddRoleBindingDialog } from "./add-role-binding-dialog"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", bindings = "bindings", @@ -25,13 +25,15 @@ export class RoleBindings extends React.Component { render() { return ( binding.getName(), - [sortBy.namespace]: (binding: RoleBinding) => binding.getNs(), - [sortBy.bindings]: (binding: RoleBinding) => binding.getSubjectNames(), - [sortBy.age]: (binding: RoleBinding) => binding.metadata.creationTimestamp, + [columnId.name]: (binding: RoleBinding) => binding.getName(), + [columnId.namespace]: (binding: RoleBinding) => binding.getNs(), + [columnId.bindings]: (binding: RoleBinding) => binding.getSubjectNames(), + [columnId.age]: (binding: RoleBinding) => binding.metadata.creationTimestamp, }} searchFilters={[ (binding: RoleBinding) => binding.getSearchFields(), @@ -39,11 +41,11 @@ export class RoleBindings extends React.Component { ]} renderHeaderTitle="Role Bindings" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Bindings", className: "bindings", sortBy: sortBy.bindings }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Bindings", className: "bindings", sortBy: columnId.bindings, id: columnId.bindings }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(binding: RoleBinding) => [ binding.getName(), diff --git a/src/renderer/components/+user-management-roles/roles.tsx b/src/renderer/components/+user-management-roles/roles.tsx index 21ad3bdf8a..a990cbfa7e 100644 --- a/src/renderer/components/+user-management-roles/roles.tsx +++ b/src/renderer/components/+user-management-roles/roles.tsx @@ -10,7 +10,7 @@ import { KubeObjectListLayout } from "../kube-object"; import { AddRoleDialog } from "./add-role-dialog"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", age = "age", @@ -25,22 +25,24 @@ export class Roles extends React.Component { return ( <> role.getName(), - [sortBy.namespace]: (role: Role) => role.getNs(), - [sortBy.age]: (role: Role) => role.metadata.creationTimestamp, + [columnId.name]: (role: Role) => role.getName(), + [columnId.namespace]: (role: Role) => role.getNs(), + [columnId.age]: (role: Role) => role.metadata.creationTimestamp, }} searchFilters={[ (role: Role) => role.getSearchFields(), ]} renderHeaderTitle="Roles" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(role: Role) => [ role.getName(), diff --git a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx b/src/renderer/components/+user-management-service-accounts/service-accounts.tsx index 37bed40ba9..4ea78904c0 100644 --- a/src/renderer/components/+user-management-service-accounts/service-accounts.tsx +++ b/src/renderer/components/+user-management-service-accounts/service-accounts.tsx @@ -15,7 +15,7 @@ import { CreateServiceAccountDialog } from "./create-service-account-dialog"; import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", age = "age", @@ -30,21 +30,23 @@ export class ServiceAccounts extends React.Component { return ( <> account.getName(), - [sortBy.namespace]: (account: ServiceAccount) => account.getNs(), - [sortBy.age]: (account: ServiceAccount) => account.metadata.creationTimestamp, + [columnId.name]: (account: ServiceAccount) => account.getName(), + [columnId.namespace]: (account: ServiceAccount) => account.getNs(), + [columnId.age]: (account: ServiceAccount) => account.metadata.creationTimestamp, }} searchFilters={[ (account: ServiceAccount) => account.getSearchFields(), ]} renderHeaderTitle="Service Accounts" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(account: ServiceAccount) => [ account.getName(), diff --git a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx index 19d35e4b3a..08b84c0671 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx +++ b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx @@ -18,12 +18,13 @@ import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { ConfirmDialog } from "../confirm-dialog/confirm-dialog"; import { Notifications } from "../notifications/notifications"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + schedule = "schedule", suspend = "suspend", active = "active", - lastSchedule = "schedule", + lastSchedule = "last-schedule", age = "age", } @@ -35,15 +36,17 @@ export class CronJobs extends React.Component { render() { return ( cronJob.getName(), - [sortBy.namespace]: (cronJob: CronJob) => cronJob.getNs(), - [sortBy.suspend]: (cronJob: CronJob) => cronJob.getSuspendFlag(), - [sortBy.active]: (cronJob: CronJob) => cronJobStore.getActiveJobsNum(cronJob), - [sortBy.lastSchedule]: (cronJob: CronJob) => cronJob.getLastScheduleTime(), - [sortBy.age]: (cronJob: CronJob) => cronJob.metadata.creationTimestamp, + [columnId.name]: (cronJob: CronJob) => cronJob.getName(), + [columnId.namespace]: (cronJob: CronJob) => cronJob.getNs(), + [columnId.suspend]: (cronJob: CronJob) => cronJob.getSuspendFlag(), + [columnId.active]: (cronJob: CronJob) => cronJobStore.getActiveJobsNum(cronJob), + [columnId.lastSchedule]: (cronJob: CronJob) => cronJob.getLastScheduleTime(), + [columnId.age]: (cronJob: CronJob) => cronJob.metadata.creationTimestamp, }} searchFilters={[ (cronJob: CronJob) => cronJob.getSearchFields(), @@ -51,14 +54,14 @@ export class CronJobs extends React.Component { ]} renderHeaderTitle="Cron Jobs" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Schedule", className: "schedule" }, - { title: "Suspend", className: "suspend", sortBy: sortBy.suspend }, - { title: "Active", className: "active", sortBy: sortBy.active }, - { title: "Last schedule", className: "last-schedule", sortBy: sortBy.lastSchedule }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Schedule", className: "schedule", id: columnId.schedule }, + { title: "Suspend", className: "suspend", sortBy: columnId.suspend, id: columnId.suspend }, + { title: "Active", className: "active", sortBy: columnId.active, id: columnId.active }, + { title: "Last schedule", className: "last-schedule", sortBy: columnId.lastSchedule, id: columnId.lastSchedule }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(cronJob: CronJob) => [ cronJob.getName(), diff --git a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx b/src/renderer/components/+workloads-daemonsets/daemonsets.tsx index ff061f7877..e2d1e30e17 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx +++ b/src/renderer/components/+workloads-daemonsets/daemonsets.tsx @@ -13,10 +13,11 @@ import { IDaemonSetsRouteParams } from "../+workloads"; import { Badge } from "../badge"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", pods = "pods", + labels = "labels", age = "age", } @@ -38,13 +39,15 @@ export class DaemonSets extends React.Component { render() { return ( daemonSet.getName(), - [sortBy.namespace]: (daemonSet: DaemonSet) => daemonSet.getNs(), - [sortBy.pods]: (daemonSet: DaemonSet) => this.getPodsLength(daemonSet), - [sortBy.age]: (daemonSet: DaemonSet) => daemonSet.metadata.creationTimestamp, + [columnId.name]: (daemonSet: DaemonSet) => daemonSet.getName(), + [columnId.namespace]: (daemonSet: DaemonSet) => daemonSet.getNs(), + [columnId.pods]: (daemonSet: DaemonSet) => this.getPodsLength(daemonSet), + [columnId.age]: (daemonSet: DaemonSet) => daemonSet.metadata.creationTimestamp, }} searchFilters={[ (daemonSet: DaemonSet) => daemonSet.getSearchFields(), @@ -52,12 +55,12 @@ export class DaemonSets extends React.Component { ]} renderHeaderTitle="Daemon Sets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Pods", className: "pods", sortBy: sortBy.pods }, - { className: "warning" }, - { title: "Node Selector", className: "labels" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Pods", className: "pods", sortBy: columnId.pods, id: columnId.pods }, + { className: "warning", showWithColumn: columnId.pods }, + { title: "Node Selector", className: "labels", id: columnId.labels }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(daemonSet: DaemonSet) => [ daemonSet.getName(), diff --git a/src/renderer/components/+workloads-deployments/deployments.tsx b/src/renderer/components/+workloads-deployments/deployments.tsx index b84cd7b340..0147c238b9 100644 --- a/src/renderer/components/+workloads-deployments/deployments.tsx +++ b/src/renderer/components/+workloads-deployments/deployments.tsx @@ -23,9 +23,10 @@ import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-obje import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { Notifications } from "../notifications"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + pods = "pods", replicas = "replicas", age = "age", condition = "condition", @@ -55,14 +56,16 @@ export class Deployments extends React.Component { render() { return ( deployment.getName(), - [sortBy.namespace]: (deployment: Deployment) => deployment.getNs(), - [sortBy.replicas]: (deployment: Deployment) => deployment.getReplicas(), - [sortBy.age]: (deployment: Deployment) => deployment.metadata.creationTimestamp, - [sortBy.condition]: (deployment: Deployment) => deployment.getConditionsText(), + [columnId.name]: (deployment: Deployment) => deployment.getName(), + [columnId.namespace]: (deployment: Deployment) => deployment.getNs(), + [columnId.replicas]: (deployment: Deployment) => deployment.getReplicas(), + [columnId.age]: (deployment: Deployment) => deployment.metadata.creationTimestamp, + [columnId.condition]: (deployment: Deployment) => deployment.getConditionsText(), }} searchFilters={[ (deployment: Deployment) => deployment.getSearchFields(), @@ -70,13 +73,13 @@ export class Deployments extends React.Component { ]} renderHeaderTitle="Deployments" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Pods", className: "pods" }, - { title: "Replicas", className: "replicas", sortBy: sortBy.replicas }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Conditions", className: "conditions", sortBy: sortBy.condition }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Pods", className: "pods", id: columnId.pods }, + { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Conditions", className: "conditions", sortBy: columnId.condition, id: columnId.condition }, ]} renderTableContents={(deployment: Deployment) => [ deployment.getName(), diff --git a/src/renderer/components/+workloads-jobs/jobs.tsx b/src/renderer/components/+workloads-jobs/jobs.tsx index 00c1ee0db5..6301c287d0 100644 --- a/src/renderer/components/+workloads-jobs/jobs.tsx +++ b/src/renderer/components/+workloads-jobs/jobs.tsx @@ -12,9 +12,10 @@ import { IJobsRouteParams } from "../+workloads"; import kebabCase from "lodash/kebabCase"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + completions = "completions", conditions = "conditions", age = "age", } @@ -27,25 +28,27 @@ export class Jobs extends React.Component { render() { return ( job.getName(), - [sortBy.namespace]: (job: Job) => job.getNs(), - [sortBy.conditions]: (job: Job) => job.getCondition() != null ? job.getCondition().type : "", - [sortBy.age]: (job: Job) => job.metadata.creationTimestamp, + [columnId.name]: (job: Job) => job.getName(), + [columnId.namespace]: (job: Job) => job.getNs(), + [columnId.conditions]: (job: Job) => job.getCondition() != null ? job.getCondition().type : "", + [columnId.age]: (job: Job) => job.metadata.creationTimestamp, }} searchFilters={[ (job: Job) => job.getSearchFields(), ]} renderHeaderTitle="Jobs" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Completions", className: "completions" }, - { className: "warning" }, - { title: "Age", className: "age", sortBy: sortBy.age }, - { title: "Conditions", className: "conditions", sortBy: sortBy.conditions }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Completions", className: "completions", id: columnId.completions }, + { className: "warning", showWithColumn: columnId.completions }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, + { title: "Conditions", className: "conditions", sortBy: columnId.conditions, id: columnId.conditions }, ]} renderTableContents={(job: Job) => { const condition = job.getCondition(); diff --git a/src/renderer/components/+workloads-replicasets/replicasets.tsx b/src/renderer/components/+workloads-replicasets/replicasets.tsx index 55f607e3c3..fa6ee5cef4 100644 --- a/src/renderer/components/+workloads-replicasets/replicasets.tsx +++ b/src/renderer/components/+workloads-replicasets/replicasets.tsx @@ -14,7 +14,7 @@ import { Icon } from "../icon/icon"; import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; import { ReplicaSetScaleDialog } from "./replicaset-scale-dialog"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", desired = "desired", @@ -31,27 +31,29 @@ export class ReplicaSets extends React.Component { render() { return ( replicaSet.getName(), - [sortBy.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(), - [sortBy.desired]: (replicaSet: ReplicaSet) => replicaSet.getDesired(), - [sortBy.current]: (replicaSet: ReplicaSet) => replicaSet.getCurrent(), - [sortBy.ready]: (replicaSet: ReplicaSet) => replicaSet.getReady(), - [sortBy.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp, + [columnId.name]: (replicaSet: ReplicaSet) => replicaSet.getName(), + [columnId.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(), + [columnId.desired]: (replicaSet: ReplicaSet) => replicaSet.getDesired(), + [columnId.current]: (replicaSet: ReplicaSet) => replicaSet.getCurrent(), + [columnId.ready]: (replicaSet: ReplicaSet) => replicaSet.getReady(), + [columnId.age]: (replicaSet: ReplicaSet) => replicaSet.metadata.creationTimestamp, }} searchFilters={[ (replicaSet: ReplicaSet) => replicaSet.getSearchFields(), ]} renderHeaderTitle="Replica Sets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { className: "warning" }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Desired", className: "desired", sortBy: sortBy.desired }, - { title: "Current", className: "current", sortBy: sortBy.current }, - { title: "Ready", className: "ready", sortBy: sortBy.ready }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { className: "warning", showWithColumn: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Desired", className: "desired", sortBy: columnId.desired, id: columnId.desired }, + { title: "Current", className: "current", sortBy: columnId.current, id: columnId.current }, + { title: "Ready", className: "ready", sortBy: columnId.ready, id: columnId.ready }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(replicaSet: ReplicaSet) => [ replicaSet.getName(), diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx index 9e6011e156..7c91c9905c 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx @@ -17,9 +17,10 @@ import { MenuItem } from "../menu/menu"; import { Icon } from "../icon/icon"; import { kubeObjectMenuRegistry } from "../../../extensions/registries/kube-object-menu-registry"; -enum sortBy { +enum columnId { name = "name", namespace = "namespace", + pods = "pods", age = "age", replicas = "replicas", } @@ -38,25 +39,27 @@ export class StatefulSets extends React.Component { render() { return ( statefulSet.getName(), - [sortBy.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(), - [sortBy.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp, - [sortBy.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(), + [columnId.name]: (statefulSet: StatefulSet) => statefulSet.getName(), + [columnId.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(), + [columnId.age]: (statefulSet: StatefulSet) => statefulSet.metadata.creationTimestamp, + [columnId.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(), }} searchFilters={[ (statefulSet: StatefulSet) => statefulSet.getSearchFields(), ]} renderHeaderTitle="Stateful Sets" renderTableHeader={[ - { title: "Name", className: "name", sortBy: sortBy.name }, - { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, - { title: "Pods", className: "pods" }, - { title: "Replicas", className: "replicas", sortBy: sortBy.replicas }, - { className: "warning" }, - { title: "Age", className: "age", sortBy: sortBy.age }, + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Pods", className: "pods", id: columnId.pods }, + { title: "Replicas", className: "replicas", sortBy: columnId.replicas, id: columnId.replicas }, + { className: "warning", showWithColumn: columnId.replicas }, + { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} renderTableContents={(statefulSet: StatefulSet) => [ statefulSet.getName(), From 27439907b48891e91f2c23952a3dbd6f8e94d41a Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Thu, 28 Jan 2021 19:03:33 +0200 Subject: [PATCH 033/219] makefile: regenerate node_modules if yarn.lock changes (#2041) Signed-off-by: Jari Kolehmainen --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 362ef3b830..000682e039 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ endif binaries/client: yarn download-bins -node_modules: +node_modules: yarn.lock yarn install --frozen-lockfile yarn check --verify-tree --integrity From 79234dcbf945ba102f04bfd3c0adb86e9d1bb815 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Fri, 29 Jan 2021 09:18:25 +0300 Subject: [PATCH 034/219] Fix jest window.matchMedia() error warnings (#2037) Signed-off-by: Alex Andreev --- .../dock/__test__/dock-tabs.test.tsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/dock/__test__/dock-tabs.test.tsx b/src/renderer/components/dock/__test__/dock-tabs.test.tsx index bcf6b94a2b..f893e06540 100644 --- a/src/renderer/components/dock/__test__/dock-tabs.test.tsx +++ b/src/renderer/components/dock/__test__/dock-tabs.test.tsx @@ -4,8 +4,6 @@ import "@testing-library/jest-dom/extend-expect"; import { DockTabs } from "../dock-tabs"; import { dockStore, IDockTab, TabKind } from "../dock.store"; -import { createResourceTab } from "../create-resource.store"; -import { createTerminalTab } from "../terminal.store"; import { observable } from "mobx"; const onChangeTab = jest.fn(); @@ -25,11 +23,19 @@ const getTabKinds = () => dockStore.tabs.map(tab => tab.kind); describe("", () => { beforeEach(() => { - createTerminalTab(); - createResourceTab(); - createTerminalTab(); - createResourceTab(); - createTerminalTab(); + const terminalTab: IDockTab = { id: "terminal1", kind: TabKind.TERMINAL, title: "Terminal" }; + const createResourceTab: IDockTab = { id: "create", kind: TabKind.CREATE_RESOURCE, title: "Create resource" }; + const editResourceTab: IDockTab = { id: "edit", kind: TabKind.EDIT_RESOURCE, title: "Edit resource" }; + const installChartTab: IDockTab = { id: "install", kind: TabKind.INSTALL_CHART, title: "Install chart" }; + const logsTab: IDockTab = { id: "logs", kind: TabKind.POD_LOGS, title: "Logs" }; + + dockStore.tabs.push( + terminalTab, + createResourceTab, + editResourceTab, + installChartTab, + logsTab + ); }); afterEach(() => { @@ -72,9 +78,9 @@ describe("", () => { expect(getTabKinds()).toEqual([ TabKind.TERMINAL, TabKind.CREATE_RESOURCE, - TabKind.TERMINAL, - TabKind.CREATE_RESOURCE, - TabKind.TERMINAL + TabKind.EDIT_RESOURCE, + TabKind.INSTALL_CHART, + TabKind.POD_LOGS ]); }); @@ -90,7 +96,7 @@ describe("", () => { const tabs = container.querySelectorAll(".Tab"); expect(tabs.length).toBe(1); - expect(getTabKinds()).toEqual([TabKind.TERMINAL]); + expect(getTabKinds()).toEqual([TabKind.EDIT_RESOURCE]); }); it("closes all tabs", () => { @@ -123,7 +129,7 @@ describe("", () => { TabKind.TERMINAL, TabKind.TERMINAL, TabKind.CREATE_RESOURCE, - TabKind.TERMINAL + TabKind.EDIT_RESOURCE ]); }); From 7490b15aad8c930a0caf35e3d3f45b8cdbf839d7 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 1 Feb 2021 08:40:58 -0500 Subject: [PATCH 035/219] update extensions' package-lock files (#2043) Signed-off-by: Sebastian Malton --- extensions/example-extension/package-lock.json | 17 +++++++++++++---- extensions/license-menu-item/package-lock.json | 17 +++++++++++++---- .../metrics-cluster-feature/package-lock.json | 12 +++++++++--- extensions/node-menu/package-lock.json | 17 +++++++++++++---- extensions/telemetry/package-lock.json | 17 +++++++++++++---- 5 files changed, 61 insertions(+), 19 deletions(-) diff --git a/extensions/example-extension/package-lock.json b/extensions/example-extension/package-lock.json index 954ba2e41f..16febd433c 100644 --- a/extensions/example-extension/package-lock.json +++ b/extensions/example-extension/package-lock.json @@ -2796,7 +2796,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3226,6 +3227,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4367,6 +4369,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4381,6 +4384,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4390,6 +4394,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4399,6 +4404,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4407,7 +4413,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5398,7 +5405,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6275,7 +6283,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/license-menu-item/package-lock.json b/extensions/license-menu-item/package-lock.json index 071d2f62a6..5d6de53633 100644 --- a/extensions/license-menu-item/package-lock.json +++ b/extensions/license-menu-item/package-lock.json @@ -2868,7 +2868,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3298,6 +3299,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4460,6 +4462,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4474,6 +4477,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4483,6 +4487,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4492,6 +4497,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4500,7 +4506,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5516,7 +5523,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6406,7 +6414,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/metrics-cluster-feature/package-lock.json b/extensions/metrics-cluster-feature/package-lock.json index 334135b4eb..ea68169ca9 100644 --- a/extensions/metrics-cluster-feature/package-lock.json +++ b/extensions/metrics-cluster-feature/package-lock.json @@ -2816,7 +2816,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3246,6 +3247,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4394,6 +4396,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4408,6 +4411,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -5434,7 +5438,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6311,7 +6316,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/node-menu/package-lock.json b/extensions/node-menu/package-lock.json index 5e1eec009a..2595e825f7 100644 --- a/extensions/node-menu/package-lock.json +++ b/extensions/node-menu/package-lock.json @@ -2796,7 +2796,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3226,6 +3227,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4382,6 +4384,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4396,6 +4399,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4405,6 +4409,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4414,6 +4419,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4422,7 +4428,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5438,7 +5445,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", @@ -6315,7 +6323,8 @@ "version": "8.3.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.1.tgz", "integrity": "sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==", - "dev": true + "dev": true, + "optional": true }, "v8-to-istanbul": { "version": "7.0.0", diff --git a/extensions/telemetry/package-lock.json b/extensions/telemetry/package-lock.json index 89e30dc2f4..9829eebb6b 100644 --- a/extensions/telemetry/package-lock.json +++ b/extensions/telemetry/package-lock.json @@ -2901,7 +2901,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha1-8QdIy+dq+WS3yWyTxrzCivEgwIE=", - "dev": true + "dev": true, + "optional": true }, "har-schema": { "version": "2.0.0", @@ -3337,6 +3338,7 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, + "optional": true, "requires": { "is-docker": "^2.0.0" } @@ -4533,6 +4535,7 @@ "resolved": "https://registry.npmjs.org/node-notifier/-/node-notifier-8.0.1.tgz", "integrity": "sha512-BvEXF+UmsnAfYfoapKM9nGxnP+Wn7P91YfXmrKnfcYCx6VBeoN5Ez5Ogck6I8Bi5k4RlpqRYaw75pAwzX9OphA==", "dev": true, + "optional": true, "requires": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -4547,6 +4550,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, + "optional": true, "requires": { "yallist": "^4.0.0" } @@ -4556,6 +4560,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", "dev": true, + "optional": true, "requires": { "lru-cache": "^6.0.0" } @@ -4564,13 +4569,15 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true + "dev": true, + "optional": true }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -4579,7 +4586,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "dev": true, + "optional": true } } }, @@ -5595,7 +5603,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", - "dev": true + "dev": true, + "optional": true }, "signal-exit": { "version": "3.0.3", From 078f952b363df6492f37ff833185cd1c620e0902 Mon Sep 17 00:00:00 2001 From: Roman Date: Mon, 1 Feb 2021 15:49:32 +0200 Subject: [PATCH 036/219] Watch-api streaming reworks (#1990) * loading k8s resources into stores per selected namespaces -- part 1 Signed-off-by: Roman * loading k8s resources into stores per selected namespaces -- part 2 - fix: generating helm chart id Signed-off-by: Roman * loading k8s resources into stores per selected namespaces -- part 3 Signed-off-by: Roman * fixes Signed-off-by: Roman * fixes / responding to comments Signed-off-by: Roman * chore / small fixes Signed-off-by: Roman * Watch api does not work for non-admins with lots of namespaces #1898 -- part 1 Signed-off-by: Roman * fixes & refactoring Signed-off-by: Roman * make lint happy Signed-off-by: Roman * reset store on loading error Signed-off-by: Roman * added new cluster method: cluster.isAllowedResource Signed-off-by: Roman * fix: loading namespaces optimizations Signed-off-by: Roman * fixes & refactoring Signed-off-by: Roman * fix: parse multiple kube-events from stream's chunk Signed-off-by: Roman * fix: mobx issue with accessing empty observable array by index (removes warning), use common logger Signed-off-by: Roman * fine-tuning Signed-off-by: Roman * fix: parse json stream chunks at client-side (might be partial, depends on network speed) Signed-off-by: Roman * store subscribing refactoring -- part 1 Signed-off-by: Roman * store subscribing refactoring -- part 2 Signed-off-by: Roman * store subscribing refactoring -- part 3 Signed-off-by: Roman * store subscribing refactoring -- part 4 Signed-off-by: Roman * auto-reconnect on online/offline status change, interval connection check Signed-off-by: Roman * check connection every 5m Signed-off-by: Roman * split concurrent watch-api requests by 10 at a time + 150ms delay before next call Signed-off-by: Roman * refactoring / clean up Signed-off-by: Roman * use `plimit` + delay for k8s watch requests Signed-off-by: Roman * lint fix Signed-off-by: Roman * added explicit `preload: true` when subscribing stores Signed-off-by: Roman * kubeWatchApi refactoring / fine-tuning Signed-off-by: Roman * clean up Signed-off-by: Roman --- src/common/utils/delay.ts | 6 + src/common/utils/index.ts | 1 + src/main/router.ts | 2 +- src/main/routes/watch-route.ts | 97 +++- src/renderer/api/api-manager.ts | 4 +- src/renderer/api/kube-api.ts | 14 +- src/renderer/api/kube-watch-api.ts | 431 ++++++++++++------ .../components/+cluster/cluster-overview.tsx | 44 +- .../components/+events/event.store.ts | 4 + .../+namespaces/namespace-select.tsx | 27 +- .../components/+namespaces/namespace.store.ts | 6 +- .../+network-services/service-details.tsx | 16 +- src/renderer/components/+nodes/nodes.store.ts | 5 + .../role-bindings.store.ts | 4 +- .../+user-management-roles/roles.store.ts | 4 +- .../+workloads-overview/overview.tsx | 60 +-- src/renderer/components/app.tsx | 86 ++-- .../item-object-list/item-list-layout.tsx | 58 +-- .../kube-object/kube-object-list-layout.tsx | 16 +- src/renderer/kube-object.store.ts | 29 +- 20 files changed, 519 insertions(+), 395 deletions(-) create mode 100644 src/common/utils/delay.ts diff --git a/src/common/utils/delay.ts b/src/common/utils/delay.ts new file mode 100644 index 0000000000..208e042759 --- /dev/null +++ b/src/common/utils/delay.ts @@ -0,0 +1,6 @@ +// Create async delay for provided timeout in milliseconds + +export async function delay(timeoutMs = 1000) { + if (!timeoutMs) return; + await new Promise(resolve => setTimeout(resolve, timeoutMs)); +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 582135d7f0..942c675f0a 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -7,6 +7,7 @@ export * from "./autobind"; export * from "./base64"; export * from "./camelCase"; export * from "./cloneJson"; +export * from "./delay"; export * from "./debouncePromise"; export * from "./defineGlobal"; export * from "./getRandId"; diff --git a/src/main/router.ts b/src/main/router.ts index 896893a592..6e98d0ce0c 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -146,7 +146,7 @@ export class Router { this.router.add({ method: "get", path: `${apiPrefix}/kubeconfig/service-account/{namespace}/{account}` }, kubeconfigRoute.routeServiceAccountRoute.bind(kubeconfigRoute)); // Watch API - this.router.add({ method: "get", path: `${apiPrefix}/watch` }, watchRoute.routeWatch.bind(watchRoute)); + this.router.add({ method: "post", path: `${apiPrefix}/watch` }, watchRoute.routeWatch.bind(watchRoute)); // Metrics API this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, metricsRoute.routeMetrics.bind(metricsRoute)); diff --git a/src/main/routes/watch-route.ts b/src/main/routes/watch-route.ts index eb9f007eae..2c86314908 100644 --- a/src/main/routes/watch-route.ts +++ b/src/main/routes/watch-route.ts @@ -1,10 +1,29 @@ +import type { KubeJsonApiData, KubeJsonApiError } from "../../renderer/api/kube-json-api"; + +import plimit from "p-limit"; +import { delay } from "../../common/utils"; import { LensApiRequest } from "../router"; import { LensApi } from "../lens-api"; -import { Watch, KubeConfig } from "@kubernetes/client-node"; +import { KubeConfig, Watch } from "@kubernetes/client-node"; import { ServerResponse } from "http"; import { Request } from "request"; import logger from "../logger"; +export interface IKubeWatchEvent { + type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR" | "STREAM_END"; + object?: T; +} + +export interface IKubeWatchEventStreamEnd extends IKubeWatchEvent { + type: "STREAM_END"; + url: string; + status: number; +} + +export interface IWatchRoutePayload { + apis: string[]; // kube-api url list for subscribing to watch events +} + class ApiWatcher { private apiUrl: string; private response: ServerResponse; @@ -24,6 +43,7 @@ class ApiWatcher { clearInterval(this.processor); } this.processor = setInterval(() => { + if (this.response.finished) return; const events = this.eventBuffer.splice(0); events.map(event => this.sendEvent(event)); @@ -33,7 +53,9 @@ class ApiWatcher { } public stop() { - if (!this.watchRequest) { return; } + if (!this.watchRequest) { + return; + } if (this.processor) { clearInterval(this.processor); @@ -42,11 +64,14 @@ class ApiWatcher { try { this.watchRequest.abort(); - this.sendEvent({ + + const event: IKubeWatchEventStreamEnd = { type: "STREAM_END", url: this.apiUrl, status: 410, - }); + }; + + this.sendEvent(event); logger.debug("watch aborted"); } catch (error) { logger.error(`Watch abort errored:${error}`); @@ -65,50 +90,72 @@ class ApiWatcher { this.watchRequest.abort(); } - private sendEvent(evt: any) { - // convert to "text/event-stream" format - this.response.write(`data: ${JSON.stringify(evt)}\n\n`); + private sendEvent(evt: IKubeWatchEvent) { + this.response.write(`${JSON.stringify(evt)}\n`); } } class WatchRoute extends LensApi { + private response: ServerResponse; - public async routeWatch(request: LensApiRequest) { - const { response, cluster} = request; - const apis: string[] = request.query.getAll("api"); - const watchers: ApiWatcher[] = []; + private setResponse(response: ServerResponse) { + // clean up previous connection and stop all corresponding watch-api requests + // otherwise it happens only by request timeout or something else.. + this.response?.destroy(); + this.response = response; + } - if (!apis.length) { + public async routeWatch(request: LensApiRequest) { + const { response, cluster, payload: { apis } = {} } = request; + + if (!apis?.length) { this.respondJson(response, { - message: "Empty request. Query params 'api' are not provided.", - example: "?api=/api/v1/pods&api=/api/v1/nodes", + message: "watch apis list is empty" }, 400); return; } - response.setHeader("Content-Type", "text/event-stream"); + this.setResponse(response); + response.setHeader("Content-Type", "application/json"); response.setHeader("Cache-Control", "no-cache"); response.setHeader("Connection", "keep-alive"); logger.debug(`watch using kubeconfig:${JSON.stringify(cluster.getProxyKubeconfig(), null, 2)}`); + // limit concurrent k8s requests to avoid possible ECONNRESET-error + const requests = plimit(5); + const watchers = new Map(); + let isWatchRequestEnded = false; + apis.forEach(apiUrl => { const watcher = new ApiWatcher(apiUrl, cluster.getProxyKubeconfig(), response); - watcher.start(); - watchers.push(watcher); + watchers.set(apiUrl, watcher); + + requests(async () => { + if (isWatchRequestEnded) return; + await watcher.start(); + await delay(100); + }); + }); + + function onRequestEnd() { + if (isWatchRequestEnded) return; + isWatchRequestEnded = true; + requests.clearQueue(); + watchers.forEach(watcher => watcher.stop()); + watchers.clear(); + } + + request.raw.req.on("end", () => { + logger.info("Watch request end"); + onRequestEnd(); }); request.raw.req.on("close", () => { - logger.debug("Watch request closed"); - watchers.map(watcher => watcher.stop()); + logger.info("Watch request close"); + onRequestEnd(); }); - - request.raw.req.on("end", () => { - logger.debug("Watch request ended"); - watchers.map(watcher => watcher.stop()); - }); - } } diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index 629a0f29c2..47500adf79 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -2,7 +2,7 @@ import type { KubeObjectStore } from "../kube-object.store"; import { action, observable } from "mobx"; import { autobind } from "../utils"; -import { KubeApi } from "./kube-api"; +import { KubeApi, parseKubeApi } from "./kube-api"; @autobind() export class ApiManager { @@ -11,7 +11,7 @@ export class ApiManager { getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { if (typeof pathOrCallback === "string") { - return this.apis.get(pathOrCallback) || this.apis.get(KubeApi.parseApi(pathOrCallback).apiBase); + return this.apis.get(pathOrCallback) || this.apis.get(parseKubeApi(pathOrCallback).apiBase); } return Array.from(this.apis.values()).find(pathOrCallback ?? (() => true)); diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 8a3a2517c2..e62603b14f 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -92,14 +92,6 @@ export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { } export class KubeApi { - static parseApi = parseKubeApi; - - static watchAll(...apis: KubeApi[]) { - const disposers = apis.map(api => api.watch()); - - return () => disposers.forEach(unwatch => unwatch()); - } - readonly kind: string; readonly apiBase: string; readonly apiPrefix: string; @@ -124,7 +116,7 @@ export class KubeApi { if (!options.apiBase) { options.apiBase = objectConstructor.apiBase; } - const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = KubeApi.parseApi(options.apiBase); + const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase); this.kind = kind; this.isNamespaced = isNamespaced; @@ -157,7 +149,7 @@ export class KubeApi { for (const apiUrl of apiBases) { // Split e.g. "/apis/extensions/v1beta1/ingresses" to parts - const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = KubeApi.parseApi(apiUrl); + const { apiPrefix, apiGroup, apiVersionWithGroup, resource } = parseKubeApi(apiUrl); // Request available resources try { @@ -366,7 +358,7 @@ export class KubeApi { } watch(): () => void { - return kubeWatchApi.subscribe(this); + return kubeWatchApi.subscribeApi(this); } } diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index fe35a04baa..8adf58676f 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -1,202 +1,349 @@ -// Kubernetes watch-api consumer +// Kubernetes watch-api client +// API: https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Using_readable_streams -import { computed, observable, reaction } from "mobx"; -import { stringify } from "querystring"; -import { autobind, EventEmitter } from "../utils"; -import { KubeJsonApiData } from "./kube-json-api"; +import type { Cluster } from "../../main/cluster"; +import type { IKubeWatchEvent, IKubeWatchEventStreamEnd, IWatchRoutePayload } from "../../main/routes/watch-route"; +import type { KubeObject } from "./kube-object"; import type { KubeObjectStore } from "../kube-object.store"; -import { ensureObjectSelfLink, KubeApi } from "./kube-api"; +import type { NamespaceStore } from "../components/+namespaces/namespace.store"; + +import plimit from "p-limit"; +import debounce from "lodash/debounce"; +import { comparer, computed, observable, reaction } from "mobx"; +import { autobind, EventEmitter } from "../utils"; +import { ensureObjectSelfLink, KubeApi, parseKubeApi } from "./kube-api"; +import { KubeJsonApiData, KubeJsonApiError } from "./kube-json-api"; +import { apiPrefix, isDebugging, isProduction } from "../../common/vars"; import { apiManager } from "./api-manager"; -import { apiPrefix, isDevelopment } from "../../common/vars"; -import { getHostedCluster } from "../../common/cluster-store"; -export interface IKubeWatchEvent { - type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR"; - object?: T; +export { IKubeWatchEvent, IKubeWatchEventStreamEnd }; + +export interface IKubeWatchMessage { + data?: IKubeWatchEvent + error?: IKubeWatchEvent; + api?: KubeApi; + store?: KubeObjectStore; } -export interface IKubeWatchRouteEvent { - type: "STREAM_END"; - url: string; - status: number; +export interface IKubeWatchSubscribeStoreOptions { + preload?: boolean; // preload store items, default: true + waitUntilLoaded?: boolean; // subscribe only after loading all stores, default: true + cacheLoading?: boolean; // when enabled loading store will be skipped, default: false } -export interface IKubeWatchRouteQuery { - api: string | string[]; +export interface IKubeWatchReconnectOptions { + reconnectAttempts: number; + timeout: number; +} + +export interface IKubeWatchLog { + message: string | Error; + meta?: object; } @autobind() export class KubeWatchApi { - protected evtSource: EventSource; - protected onData = new EventEmitter<[IKubeWatchEvent]>(); - protected subscribers = observable.map(); - protected reconnectTimeoutMs = 5000; - protected maxReconnectsOnError = 10; - protected reconnectAttempts = this.maxReconnectsOnError; + private cluster: Cluster; + private namespaceStore: NamespaceStore; - constructor() { - reaction(() => this.activeApis, () => this.connect(), { - fireImmediately: true, - delay: 500, - }); + private requestId = 0; + private isConnected = false; + private reader: ReadableStreamReader; + private subscribers = observable.map(); + + // events + public onMessage = new EventEmitter<[IKubeWatchMessage]>(); + + @computed get isActive(): boolean { + return this.apis.length > 0; } - @computed get activeApis() { - return Array.from(this.subscribers.keys()); + @computed get apis(): string[] { + const { cluster, namespaceStore } = this; + const activeApis = Array.from(this.subscribers.keys()); + + return activeApis.map(api => { + if (!cluster.isAllowedResource(api.kind)) { + return []; + } + + if (api.isNamespaced) { + return namespaceStore.getContextNamespaces().map(namespace => api.getWatchUrl(namespace)); + } else { + return api.getWatchUrl(); + } + }).flat(); + } + + constructor() { + this.init(); + } + + private async init() { + const { getHostedCluster } = await import("../../common/cluster-store"); + const { namespaceStore } = await import("../components/+namespaces/namespace.store"); + + await namespaceStore.whenReady; + + this.cluster = getHostedCluster(); + this.namespaceStore = namespaceStore; + this.bindAutoConnect(); + } + + private bindAutoConnect() { + const connect = debounce(() => this.connect(), 1000); + + reaction(() => this.apis, connect, { + fireImmediately: true, + equals: comparer.structural, + }); + + window.addEventListener("online", () => this.connect()); + window.addEventListener("offline", () => this.disconnect()); + setInterval(() => this.connectionCheck(), 60000 * 5); // every 5m } getSubscribersCount(api: KubeApi) { return this.subscribers.get(api) || 0; } - subscribe(...apis: KubeApi[]) { + isAllowedApi(api: KubeApi): boolean { + return !!this?.cluster.isAllowedResource(api.kind); + } + + subscribeApi(api: KubeApi | KubeApi[]): () => void { + const apis: KubeApi[] = [api].flat(); + apis.forEach(api => { + if (!this.isAllowedApi(api)) return; // skip this.subscribers.set(api, this.getSubscribersCount(api) + 1); }); - return () => apis.forEach(api => { - const count = this.getSubscribersCount(api) - 1; + return () => { + apis.forEach(api => { + const count = this.getSubscribersCount(api) - 1; - if (count <= 0) this.subscribers.delete(api); - else this.subscribers.set(api, count); - }); - } - - // FIXME: use POST to send apis for subscribing (list could be huge) - // TODO: try to use normal fetch res.body stream to consume watch-api updates - // https://github.com/lensapp/lens/issues/1898 - protected async getQuery() { - const { namespaceStore } = await import("../components/+namespaces/namespace.store"); - - await namespaceStore.whenReady; - const { isAdmin } = getHostedCluster(); - - return { - api: this.activeApis.map(api => { - if (isAdmin && !api.isNamespaced) { - return api.getWatchUrl(); - } - - if (api.isNamespaced) { - return namespaceStore.getContextNamespaces().map(namespace => api.getWatchUrl(namespace)); - } - - return []; - }).flat() + if (count <= 0) this.subscribers.delete(api); + else this.subscribers.set(api, count); + }); }; } - // todo: maybe switch to websocket to avoid often reconnects - @autobind() - protected async connect() { - if (this.evtSource) this.disconnect(); // close previous connection + subscribeStores(stores: KubeObjectStore[], options: IKubeWatchSubscribeStoreOptions = {}): () => void { + const { preload = true, waitUntilLoaded = true, cacheLoading = false } = options; + const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages + const preloading: Promise[] = []; + const apis = new Set(stores.map(store => store.getSubscribeApis()).flat()); + const unsubscribeList: (() => void)[] = []; + let isUnsubscribed = false; - const query = await this.getQuery(); + const subscribe = () => { + if (isUnsubscribed) return; + apis.forEach(api => unsubscribeList.push(this.subscribeApi(api))); + }; + + if (preload) { + for (const store of stores) { + preloading.push(limitRequests(async () => { + if (cacheLoading && store.isLoaded) return; // skip + + return store.loadAll(); + })); + } + } + + if (waitUntilLoaded) { + Promise.all(preloading).then(subscribe, error => { + this.log({ + message: new Error("Loading stores has failed"), + meta: { stores, error, options }, + }); + }); + } else { + subscribe(); + } + + // unsubscribe + return () => { + if (isUnsubscribed) return; + isUnsubscribed = true; + limitRequests.clearQueue(); + unsubscribeList.forEach(unsubscribe => unsubscribe()); + }; + } + + protected async connectionCheck() { + if (!this.isConnected) { + this.log({ message: "Offline: reconnecting.." }); + await this.connect(); + } + + this.log({ + message: `Connection check: ${this.isConnected ? "online" : "offline"}`, + meta: { connected: this.isConnected }, + }); + } + + protected async connect(apis = this.apis) { + this.disconnect(); // close active connections first + + if (!navigator.onLine || !apis.length) { + this.isConnected = false; - if (!this.activeApis.length || !query.api.length) { return; } - const apiUrl = `${apiPrefix}/watch?${stringify(query)}`; + this.log({ + message: "Connecting", + meta: { apis } + }); - this.evtSource = new EventSource(apiUrl); - this.evtSource.onmessage = this.onMessage; - this.evtSource.onerror = this.onError; - this.writeLog("CONNECTING", query.api); - } + try { + const requestId = ++this.requestId; + const abortController = new AbortController(); - reconnect() { - if (!this.evtSource || this.evtSource.readyState !== EventSource.OPEN) { - this.reconnectAttempts = this.maxReconnectsOnError; - this.connect(); + const request = await fetch(`${apiPrefix}/watch`, { + method: "POST", + body: JSON.stringify({ apis } as IWatchRoutePayload), + signal: abortController.signal, + headers: { + "content-type": "application/json" + } + }); + + // request above is stale since new request-id has been issued + if (this.requestId !== requestId) { + abortController.abort(); + + return; + } + + let jsonBuffer = ""; + const stream = request.body.pipeThrough(new TextDecoderStream()); + const reader = stream.getReader(); + + this.isConnected = true; + this.reader = reader; + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; // exit + + const events = (jsonBuffer + value).split("\n"); + + jsonBuffer = this.processBuffer(events); + } + } catch (error) { + this.log({ message: error }); + } finally { + this.isConnected = false; } } protected disconnect() { - if (!this.evtSource) return; - this.evtSource.close(); - this.evtSource.onmessage = null; - this.evtSource = null; + this.reader?.cancel(); + this.reader = null; + this.isConnected = false; } - protected onMessage(evt: MessageEvent) { - if (!evt.data) return; - const data = JSON.parse(evt.data); + // process received stream events, returns unprocessed buffer chunk if any + protected processBuffer(events: string[]): string { + for (const json of events) { + try { + const kubeEvent: IKubeWatchEvent = JSON.parse(json); + const message = this.getMessage(kubeEvent); - if ((data as IKubeWatchEvent).object) { - this.onData.emit(data); - } else { - this.onRouteEvent(data); + this.onMessage.emit(message); + } catch (error) { + return json; + } } + + return ""; } - protected async onRouteEvent(event: IKubeWatchRouteEvent) { - if (event.type === "STREAM_END") { - this.disconnect(); - const { apiBase, namespace } = KubeApi.parseApi(event.url); - const api = apiManager.getApi(apiBase); + protected getMessage(event: IKubeWatchEvent): IKubeWatchMessage { + const message: IKubeWatchMessage = {}; - if (api) { - try { - await api.refreshResourceVersion({ namespace }); - this.reconnect(); - } catch (error) { - console.error("failed to refresh resource version", error); + switch (event.type) { + case "ADDED": + case "DELETED": - if (this.subscribers.size > 0) { - setTimeout(() => { - this.onRouteEvent(event); - }, 1000); - } + case "MODIFIED": { + const data = event as IKubeWatchEvent; + const api = apiManager.getApiByKind(data.object.kind, data.object.apiVersion); + + message.data = data; + + if (api) { + ensureObjectSelfLink(api, data.object); + + const { namespace, resourceVersion } = data.object.metadata; + + api.setResourceVersion(namespace, resourceVersion); + api.setResourceVersion("", resourceVersion); + + message.api = api; + message.store = apiManager.getStore(api); } + break; + } + + case "ERROR": + message.error = event as IKubeWatchEvent; + break; + + case "STREAM_END": { + this.onServerStreamEnd(event as IKubeWatchEventStreamEnd, { + reconnectAttempts: 5, + timeout: 1000, + }); + break; + } + } + + return message; + } + + protected async onServerStreamEnd(event: IKubeWatchEventStreamEnd, opts?: IKubeWatchReconnectOptions) { + const { apiBase, namespace } = parseKubeApi(event.url); + const api = apiManager.getApi(apiBase); + + if (!api) return; + + try { + await api.refreshResourceVersion({ namespace }); + this.connect(); + } catch (error) { + this.log({ + message: new Error(`Failed to connect on single stream end: ${error}`), + meta: { event, error }, + }); + + if (this.isActive && opts?.reconnectAttempts > 0) { + opts.reconnectAttempts--; + setTimeout(() => this.onServerStreamEnd(event, opts), opts.timeout); // repeat event } } } - protected onError(evt: MessageEvent) { - const { reconnectAttempts: attemptsRemain, reconnectTimeoutMs } = this; - - if (evt.eventPhase === EventSource.CLOSED) { - if (attemptsRemain > 0) { - this.reconnectAttempts--; - setTimeout(() => this.connect(), reconnectTimeoutMs); - } + protected log({ message, meta = {} }: IKubeWatchLog) { + if (isProduction && !isDebugging) { + return; } - } - protected writeLog(...data: any[]) { - if (isDevelopment) { - console.log("%cKUBE-WATCH-API:", `font-weight: bold`, ...data); + const logMessage = `%c[KUBE-WATCH-API]: ${String(message).toUpperCase()}`; + const isError = message instanceof Error; + const textStyle = `font-weight: bold;`; + const time = new Date().toLocaleString(); + + if (isError) { + console.error(logMessage, textStyle, { time, ...meta }); + } else { + console.info(logMessage, textStyle, { time, ...meta }); } } - - addListener(store: KubeObjectStore, callback: (evt: IKubeWatchEvent) => void) { - const listener = (evt: IKubeWatchEvent) => { - if (evt.type === "ERROR") { - return; // e.g. evt.object.message == "too old resource version" - } - - const { namespace, resourceVersion } = evt.object.metadata; - const api = apiManager.getApiByKind(evt.object.kind, evt.object.apiVersion); - - api.setResourceVersion(namespace, resourceVersion); - api.setResourceVersion("", resourceVersion); - - ensureObjectSelfLink(api, evt.object); - - if (store == apiManager.getStore(api)) { - callback(evt); - } - }; - - this.onData.addListener(listener); - - return () => this.onData.removeListener(listener); - } - - reset() { - this.subscribers.clear(); - } } export const kubeWatchApi = new KubeWatchApi(); diff --git a/src/renderer/components/+cluster/cluster-overview.tsx b/src/renderer/components/+cluster/cluster-overview.tsx index 104c6fd022..1f7a7f6b78 100644 --- a/src/renderer/components/+cluster/cluster-overview.tsx +++ b/src/renderer/components/+cluster/cluster-overview.tsx @@ -3,13 +3,9 @@ import "./cluster-overview.scss"; import React from "react"; import { reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; - -import { eventStore } from "../+events/event.store"; import { nodesStore } from "../+nodes/nodes.store"; import { podsStore } from "../+workloads-pods/pods.store"; import { getHostedCluster } from "../../../common/cluster-store"; -import { isAllowedResource } from "../../../common/rbac"; -import { KubeObjectStore } from "../../kube-object.store"; import { interval } from "../../utils"; import { TabLayout } from "../layout/tab-layout"; import { Spinner } from "../spinner"; @@ -17,45 +13,33 @@ import { ClusterIssues } from "./cluster-issues"; import { ClusterMetrics } from "./cluster-metrics"; import { clusterOverviewStore } from "./cluster-overview.store"; import { ClusterPieCharts } from "./cluster-pie-charts"; +import { eventStore } from "../+events/event.store"; +import { kubeWatchApi } from "../../api/kube-watch-api"; @observer export class ClusterOverview extends React.Component { - private stores: KubeObjectStore[] = []; - private subscribers: Array<() => void> = []; - private metricPoller = interval(60, this.loadMetrics); - - @disposeOnUnmount - fetchMetrics = reaction( - () => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher - () => this.metricPoller.restart(true) - ); + private metricPoller = interval(60, () => this.loadMetrics()); loadMetrics() { getHostedCluster().available && clusterOverviewStore.loadMetrics(); } - async componentDidMount() { - if (isAllowedResource("nodes")) { - this.stores.push(nodesStore); - } + componentDidMount() { + this.metricPoller.start(true); - if (isAllowedResource("pods")) { - this.stores.push(podsStore); - } + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([nodesStore, podsStore, eventStore], { + preload: true, + }), - if (isAllowedResource("events")) { - this.stores.push(eventStore); - } - - await Promise.all(this.stores.map(store => store.loadAll())); - this.loadMetrics(); - - this.subscribers = this.stores.map(store => store.subscribe()); - this.metricPoller.start(); + reaction( + () => clusterOverviewStore.metricNodeRole, // Toggle Master/Worker node switcher + () => this.metricPoller.restart(true) + ), + ]); } componentWillUnmount() { - this.subscribers.forEach(dispose => dispose()); // unsubscribe all this.metricPoller.stop(); } diff --git a/src/renderer/components/+events/event.store.ts b/src/renderer/components/+events/event.store.ts index 3651ce1549..d6090be947 100644 --- a/src/renderer/components/+events/event.store.ts +++ b/src/renderer/components/+events/event.store.ts @@ -52,6 +52,10 @@ export class EventStore extends KubeObjectStore { return compact(eventsWithError); } + + getWarningsCount() { + return this.getWarnings().length; + } } export const eventStore = new EventStore(); diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 079a9cd0b6..6ee7ea2d57 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -2,13 +2,14 @@ import "./namespace-select.scss"; import React from "react"; import { computed } from "mobx"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { Select, SelectOption, SelectProps } from "../select"; -import { cssNames, noop } from "../../utils"; +import { cssNames } from "../../utils"; import { Icon } from "../icon"; import { namespaceStore } from "./namespace.store"; import { FilterIcon } from "../item-object-list/filter-icon"; import { FilterType } from "../item-object-list/page-filters.store"; +import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends SelectProps { showIcons?: boolean; @@ -28,17 +29,13 @@ const defaultProps: Partial = { @observer export class NamespaceSelect extends React.Component { static defaultProps = defaultProps as object; - private unsubscribe = noop; - async componentDidMount() { - if (!namespaceStore.isLoaded) { - await namespaceStore.loadAll(); - } - this.unsubscribe = namespaceStore.subscribe(); - } - - componentWillUnmount() { - this.unsubscribe(); + componentDidMount() { + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([namespaceStore], { + preload: true, + }) + ]); } @computed get options(): SelectOption[] { @@ -60,7 +57,7 @@ export class NamespaceSelect extends React.Component { return label || ( <> - {showIcons && } + {showIcons && } {value} ); @@ -103,9 +100,9 @@ export class NamespaceSelectFilter extends React.Component { return (
- + {namespace} - {isSelected && } + {isSelected && }
); }} diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 50ec2c8038..63bb7525de 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -117,15 +117,15 @@ export class NamespaceStore extends KubeObjectStore { return namespaces; } - subscribe(apis = [this.api]) { + getSubscribeApis() { const { accessibleNamespaces } = getHostedCluster(); // if user has given static list of namespaces let's not start watches because watch adds stuff that's not wanted if (accessibleNamespaces.length > 0) { - return Function; // no-op + return []; } - return super.subscribe(apis); + return super.getSubscribeApis(); } protected async loadItems(params: KubeObjectStoreLoadingParams) { diff --git a/src/renderer/components/+network-services/service-details.tsx b/src/renderer/components/+network-services/service-details.tsx index 58cbe0a86e..80c9bd4dc7 100644 --- a/src/renderer/components/+network-services/service-details.tsx +++ b/src/renderer/components/+network-services/service-details.tsx @@ -1,17 +1,18 @@ import "./service-details.scss"; import React from "react"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { DrawerItem, DrawerTitle } from "../drawer"; import { Badge } from "../badge"; import { KubeEventDetails } from "../+events/kube-event-details"; import { KubeObjectDetailsProps } from "../kube-object"; -import { Service, endpointApi } from "../../api/endpoints"; +import { Service } from "../../api/endpoints"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { ServicePortComponent } from "./service-port-component"; import { endpointStore } from "../+network-endpoints/endpoints.store"; import { ServiceDetailsEndpoint } from "./service-details-endpoint"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends KubeObjectDetailsProps { } @@ -19,10 +20,11 @@ interface Props extends KubeObjectDetailsProps { @observer export class ServiceDetails extends React.Component { componentDidMount() { - if (!endpointStore.isLoaded) { - endpointStore.loadAll(); - } - endpointApi.watch(); + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([endpointStore], { + preload: true, + }), + ]); } render() { @@ -77,7 +79,7 @@ export class ServiceDetails extends React.Component { )} - +
); } diff --git a/src/renderer/components/+nodes/nodes.store.ts b/src/renderer/components/+nodes/nodes.store.ts index c0385b078b..b301015747 100644 --- a/src/renderer/components/+nodes/nodes.store.ts +++ b/src/renderer/components/+nodes/nodes.store.ts @@ -1,3 +1,4 @@ +import { sum } from "lodash"; import { action, computed, observable } from "mobx"; import { clusterApi, IClusterMetrics, INodeMetrics, Node, nodesApi } from "../../api/endpoints"; import { autobind } from "../../utils"; @@ -62,6 +63,10 @@ export class NodesStore extends KubeObjectStore { }); } + getWarningsCount(): number { + return sum(this.items.map((node: Node) => node.getWarningConditions().length)); + } + reset() { super.reset(); this.metrics = {}; diff --git a/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts b/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts index 71890acc44..620fbd86ac 100644 --- a/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts +++ b/src/renderer/components/+user-management-roles-bindings/role-bindings.store.ts @@ -9,8 +9,8 @@ import { apiManager } from "../../api/api-manager"; export class RoleBindingsStore extends KubeObjectStore { api = clusterRoleBindingApi; - subscribe() { - return super.subscribe([clusterRoleBindingApi, roleBindingApi]); + getSubscribeApis() { + return [clusterRoleBindingApi, roleBindingApi]; } protected sortItems(items: RoleBinding[]) { diff --git a/src/renderer/components/+user-management-roles/roles.store.ts b/src/renderer/components/+user-management-roles/roles.store.ts index 7d2e90dd38..82b0e66612 100644 --- a/src/renderer/components/+user-management-roles/roles.store.ts +++ b/src/renderer/components/+user-management-roles/roles.store.ts @@ -7,8 +7,8 @@ import { apiManager } from "../../api/api-manager"; export class RolesStore extends KubeObjectStore { api = clusterRoleApi; - subscribe() { - return super.subscribe([roleApi, clusterRoleApi]); + getSubscribeApis() { + return [roleApi, clusterRoleApi]; } protected sortItems(items: Role[]) { diff --git a/src/renderer/components/+workloads-overview/overview.tsx b/src/renderer/components/+workloads-overview/overview.tsx index 351b57462c..50a25ef87c 100644 --- a/src/renderer/components/+workloads-overview/overview.tsx +++ b/src/renderer/components/+workloads-overview/overview.tsx @@ -1,8 +1,7 @@ import "./overview.scss"; import React from "react"; -import { observable, when } from "mobx"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { OverviewStatuses } from "./overview-statuses"; import { RouteComponentProps } from "react-router"; import { IWorkloadsOverviewRouteParams } from "../+workloads"; @@ -15,60 +14,23 @@ import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; import { jobStore } from "../+workloads-jobs/job.store"; import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import { Events } from "../+events"; -import { KubeObjectStore } from "../../kube-object.store"; import { isAllowedResource } from "../../../common/rbac"; -import { namespaceStore } from "../+namespaces/namespace.store"; +import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends RouteComponentProps { } @observer export class WorkloadsOverview extends React.Component { - @observable isLoading = false; - @observable isUnmounting = false; - - async componentDidMount() { - const stores: KubeObjectStore[] = [ - isAllowedResource("pods") && podsStore, - isAllowedResource("deployments") && deploymentStore, - isAllowedResource("daemonsets") && daemonSetStore, - isAllowedResource("statefulsets") && statefulSetStore, - isAllowedResource("replicasets") && replicaSetStore, - isAllowedResource("jobs") && jobStore, - isAllowedResource("cronjobs") && cronJobStore, - isAllowedResource("events") && eventStore, - ].filter(Boolean); - - const unsubscribeMap = new Map void>(); - - const loadStores = async () => { - this.isLoading = true; - - for (const store of stores) { - if (this.isUnmounting) break; - - try { - await store.loadAll(); - unsubscribeMap.get(store)?.(); // unsubscribe previous watcher - unsubscribeMap.set(store, store.subscribe()); - } catch (error) { - console.error("loading store error", error); - } - } - this.isLoading = false; - }; - - namespaceStore.onContextChange(loadStores, { - fireImmediately: true, - }); - - await when(() => this.isUnmounting && !this.isLoading); - unsubscribeMap.forEach(dispose => dispose()); - unsubscribeMap.clear(); - } - - componentWillUnmount() { - this.isUnmounting = true; + componentDidMount() { + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([ + podsStore, deploymentStore, daemonSetStore, statefulSetStore, replicaSetStore, + jobStore, cronJobStore, eventStore, + ], { + preload: true, + }), + ]); } render() { diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 958ab4b73d..767a905e4a 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { Redirect, Route, Router, Switch } from "react-router"; import { history } from "../navigation"; import { Notifications } from "./notifications"; @@ -42,10 +42,10 @@ import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../exte import { TabLayout, TabLayoutRoute } from "./layout/tab-layout"; import { StatefulSetScaleDialog } from "./+workloads-statefulsets/statefulset-scale-dialog"; import { eventStore } from "./+events/event.store"; -import { reaction, computed, observable } from "mobx"; +import { computed, reaction, observable } from "mobx"; import { nodesStore } from "./+nodes/nodes.store"; import { podsStore } from "./+workloads-pods/pods.store"; -import { sum } from "lodash"; +import { kubeWatchApi } from "../api/kube-watch-api"; import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog"; @observer @@ -75,50 +75,26 @@ export class App extends React.Component { whatInput.ask(); // Start to monitor user input device } - @observable extensionRoutes: Map = new Map(); + componentDidMount() { + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([podsStore, nodesStore, eventStore], { + preload: true, + }), - async componentDidMount() { - const cluster = getHostedCluster(); - const promises: Promise[] = []; + reaction(() => this.warningsTotal, (count: number) => { + broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count); + }), - if (isAllowedResource("events") && isAllowedResource("pods")) { - promises.push(eventStore.loadAll()); - promises.push(podsStore.loadAll()); - } - - if (isAllowedResource("nodes")) { - promises.push(nodesStore.loadAll()); - } - await Promise.all(promises); - - if (eventStore.isLoaded && podsStore.isLoaded) { - eventStore.subscribe(); - podsStore.subscribe(); - } - - if (nodesStore.isLoaded) { - nodesStore.subscribe(); - } - - reaction(() => this.warningsCount, (count) => { - broadcastMessage(`cluster-warning-event-count:${cluster.id}`, count); - }); - - reaction(() => clusterPageMenuRegistry.getRootItems(), (rootItems) => { - this.generateExtensionTabLayoutRoutes(rootItems); - }, { - fireImmediately: true - }); + reaction(() => clusterPageMenuRegistry.getRootItems(), (rootItems) => { + this.generateExtensionTabLayoutRoutes(rootItems); + }, { + fireImmediately: true + }) + ]); } - @computed - get warningsCount() { - let warnings = sum(nodesStore.items - .map(node => node.getWarningConditions().length)); - - warnings = warnings + eventStore.getWarnings().length; - - return warnings; + @computed get warningsTotal(): number { + return nodesStore.getWarningsCount() + eventStore.getWarningsCount(); } get startURL() { @@ -151,6 +127,26 @@ export class App extends React.Component { return routes; } + renderExtensionTabLayoutRoutes() { + return clusterPageMenuRegistry.getRootItems().map((menu, index) => { + const tabRoutes = this.getTabLayoutRoutes(menu); + + if (tabRoutes.length > 0) { + const pageComponent = () => ; + + return tab.routePath)}/>; + } else { + const page = clusterPageRegistry.getByPageTarget(menu.target); + + if (page) { + return ; + } + } + }); + } + + @observable extensionRoutes: Map = new Map(); + generateExtensionTabLayoutRoutes(rootItems: ClusterPageMenuRegistration[]) { rootItems.forEach((menu, index) => { let route = this.extensionRoutes.get(menu); @@ -181,10 +177,6 @@ export class App extends React.Component { } } - renderExtensionTabLayoutRoutes() { - return Array.from(this.extensionRoutes.values()); - } - renderExtensionRoutes() { return clusterPageRegistry.getItems().map((page, index) => { const menu = clusterPageMenuRegistry.getByPage(page); 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 6b4ff4fd16..b13d496064 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -2,7 +2,7 @@ import "./item-list-layout.scss"; import groupBy from "lodash/groupBy"; import React, { ReactNode } from "react"; -import { computed, IReactionDisposer, observable, reaction, toJS } from "mobx"; +import { computed, observable, reaction, toJS } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog"; import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallback } from "../table"; @@ -12,7 +12,6 @@ import { NoItems } from "../no-items"; import { Spinner } from "../spinner"; import { ItemObject, ItemStore } from "../../item.store"; import { SearchInputUrl } from "../input"; -import { namespaceStore } from "../+namespaces/namespace.store"; import { Filter, FilterType, pageFilters } from "./page-filters.store"; import { PageFiltersList } from "./page-filters-list"; import { PageFiltersSelect } from "./page-filters-select"; @@ -22,6 +21,7 @@ import { MenuActions } from "../menu/menu-actions"; import { MenuItem } from "../menu"; import { Checkbox } from "../checkbox"; import { userStore } from "../../../common/user-store"; +import { namespaceStore } from "../+namespaces/namespace.store"; // todo: refactor, split to small re-usable components @@ -40,6 +40,7 @@ export interface ItemListLayoutProps { className: IClassName; store: ItemStore; dependentStores?: ItemStore[]; + preloadStores?: boolean; isClusterScoped?: boolean; hideFilters?: boolean; searchFilters?: SearchFilter[]; @@ -82,6 +83,7 @@ const defaultProps: Partial = { isSelectable: true, isConfigurable: false, copyClassNameFromHeadCells: true, + preloadStores: true, dependentStores: [], filterItems: [], hasDetailsView: true, @@ -97,10 +99,6 @@ interface ItemListLayoutUserSettings { export class ItemListLayout extends React.Component { static defaultProps = defaultProps as object; - private watchDisposers: IReactionDisposer[] = []; - - @observable isUnmounting = false; - @observable userSettings: ItemListLayoutUserSettings = { showAppliedFilters: false, }; @@ -119,54 +117,28 @@ export class ItemListLayout extends React.Component { } async componentDidMount() { - const { isClusterScoped, isConfigurable, tableId } = this.props; + const { isClusterScoped, isConfigurable, tableId, preloadStores } = this.props; if (isConfigurable && !tableId) { throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified"); } - this.loadStores(); + if (preloadStores) { + this.loadStores(); - if (!isClusterScoped) { - disposeOnUnmount(this, [ - namespaceStore.onContextChange(() => this.loadStores()) - ]); + if (!isClusterScoped) { + disposeOnUnmount(this, [ + namespaceStore.onContextChange(() => this.loadStores()) + ]); + } } } - async componentWillUnmount() { - this.isUnmounting = true; - this.unsubscribeStores(); - } - - @computed get stores() { + private loadStores() { const { store, dependentStores } = this.props; + const stores = Array.from(new Set([store, ...dependentStores])); - return new Set([store, ...dependentStores]); - } - - async loadStores() { - this.unsubscribeStores(); // reset first - - // load - for (const store of this.stores) { - if (this.isUnmounting) { - this.unsubscribeStores(); - break; - } - - try { - await store.loadAll(); - this.watchDisposers.push(store.subscribe()); - } catch (error) { - console.error("loading store error", error); - } - } - } - - unsubscribeStores() { - this.watchDisposers.forEach(dispose => dispose()); - this.watchDisposers.length = 0; + stores.forEach(store => store.loadAll()); } private filterCallbacks: { [type: string]: ItemsFilter } = { diff --git a/src/renderer/components/kube-object/kube-object-list-layout.tsx b/src/renderer/components/kube-object/kube-object-list-layout.tsx index 25922f0f72..226023fc8d 100644 --- a/src/renderer/components/kube-object/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object/kube-object-list-layout.tsx @@ -1,15 +1,17 @@ import React from "react"; import { computed } from "mobx"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { cssNames } from "../../utils"; import { KubeObject } from "../../api/kube-object"; import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/item-list-layout"; import { KubeObjectStore } from "../../kube-object.store"; import { KubeObjectMenu } from "./kube-object-menu"; import { kubeSelectedUrlParam, showDetails } from "./kube-object-details"; +import { kubeWatchApi } from "../../api/kube-watch-api"; export interface KubeObjectListLayoutProps extends ItemListLayoutProps { store: KubeObjectStore; + dependentStores?: KubeObjectStore[]; } @observer @@ -18,6 +20,17 @@ export class KubeObjectListLayout extends React.Component { if (this.props.onDetails) { this.props.onDetails(item); @@ -33,6 +46,7 @@ export class KubeObjectListLayout extends React.Component { diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 8a75bc7ae6..760ebd3335 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -2,10 +2,10 @@ import type { Cluster } from "../main/cluster"; import { action, observable, reaction } from "mobx"; import { autobind } from "./utils"; import { KubeObject } from "./api/kube-object"; -import { IKubeWatchEvent, kubeWatchApi } from "./api/kube-watch-api"; +import { IKubeWatchEvent, IKubeWatchMessage, kubeWatchApi } from "./api/kube-watch-api"; import { ItemStore } from "./item.store"; import { apiManager } from "./api/api-manager"; -import { IKubeApiQueryParams, KubeApi } from "./api/kube-api"; +import { IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api"; import { KubeJsonApiData } from "./api/kube-json-api"; export interface KubeObjectStoreLoadingParams { @@ -22,7 +22,6 @@ export abstract class KubeObjectStore extends ItemSt constructor() { super(); this.bindWatchEventsUpdater(); - kubeWatchApi.addListener(this, this.onWatchApiEvent); } get query(): IKubeApiQueryParams { @@ -157,7 +156,7 @@ export abstract class KubeObjectStore extends ItemSt @action async loadFromPath(resourcePath: string) { - const { namespace, name } = KubeApi.parseApi(resourcePath); + const { namespace, name } = parseKubeApi(resourcePath); return this.load({ name, namespace }); } @@ -195,29 +194,29 @@ export abstract class KubeObjectStore extends ItemSt } // collect items from watch-api events to avoid UI blowing up with huge streams of data - protected eventsBuffer = observable>([], { deep: false }); + protected eventsBuffer = observable.array>([], { deep: false }); protected bindWatchEventsUpdater(delay = 1000) { - return reaction(() => this.eventsBuffer.toJS()[0], this.updateFromEventsBuffer, { + kubeWatchApi.onMessage.addListener(({ store, data }: IKubeWatchMessage) => { + if (!this.isLoaded || store !== this) return; + this.eventsBuffer.push(data); + }); + + reaction(() => this.eventsBuffer.length > 0, this.updateFromEventsBuffer, { delay }); } - subscribe(apis = [this.api]) { - return KubeApi.watchAll(...apis); + getSubscribeApis(): KubeApi[] { + return [this.api]; } - protected onWatchApiEvent(evt: IKubeWatchEvent) { - if (!this.isLoaded) return; - this.eventsBuffer.push(evt); + subscribe(apis = this.getSubscribeApis()) { + return kubeWatchApi.subscribeApi(apis); } @action protected updateFromEventsBuffer() { - if (!this.eventsBuffer.length) { - return; - } - // create latest non-observable copy of items to apply updates in one action (==single render) const items = this.items.toJS(); for (const { type, object } of this.eventsBuffer.clear()) { From 1599ee4f6abffe0c3d914e5ef32d3244299319f4 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 1 Feb 2021 16:51:27 +0200 Subject: [PATCH 037/219] Add deb & rpm packages (#2053) Signed-off-by: Jari Kolehmainen --- integration/helpers/utils.ts | 2 +- package.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index a865280fed..195de2d073 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -4,7 +4,7 @@ import { exec } from "child_process"; const AppPaths: Partial> = { "win32": "./dist/win-unpacked/Lens.exe", - "linux": "./dist/linux-unpacked/kontena-lens", + "linux": "./dist/linux-unpacked/lens", "darwin": "./dist/mac/Lens.app/Contents/MacOS/Lens", }; diff --git a/package.json b/package.json index 80d3c1d229..735a5fa341 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,11 @@ ], "linux": { "category": "Network", + "executableName": "lens", + "artifactName": "${productName}-${version}.${arch}.${ext}", "target": [ + "deb", + "rpm", "snap", "AppImage" ], From f8412eaf13a658a3e01ce2d3e041800adda7576e Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 2 Feb 2021 09:32:41 +0200 Subject: [PATCH 038/219] Add release-drafter (#2055) * add release-drafter Signed-off-by: Jari Kolehmainen * add release-drafter Signed-off-by: Jari Kolehmainen * fix token Signed-off-by: Jari Kolehmainen --- .github/release-drafter.yml | 14 ++++++++++++++ .github/workflows/release-drafter.yml | 19 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/release-drafter.yml diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000000..606bf962a6 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,14 @@ +categories: + - title: '🚀 Features' + labels: + - 'enhancement' + - title: '🐛 Bug Fixes' + labels: + - 'bug' + - title: '🧰 Maintenance' + label: 'chore' + +template: | + ## Changes since $PREVIOUS_TAG + + $CHANGES diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000000..8c3183b988 --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,19 @@ +name: Release Drafter + +on: + push: + # branches to consider in the event; optional, defaults to all + branches: + - master + +jobs: + update_release_draft: + runs-on: ubuntu-latest + steps: + # Drafts your next Release notes as Pull Requests are merged into "master" + - uses: release-drafter/release-drafter@v5 + with: + # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml + # config-name: my-config.yml + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} From 06b884f3fba26add72d915c1f8868b88d69eb3fc Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 2 Feb 2021 09:42:14 +0200 Subject: [PATCH 039/219] Fix release-drafter yaml error (#2058) Signed-off-by: Jari Kolehmainen --- .github/workflows/release-drafter.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 8c3183b988..ec49fec6e5 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -12,8 +12,5 @@ jobs: steps: # Drafts your next Release notes as Pull Requests are merged into "master" - uses: release-drafter/release-drafter@v5 - with: - # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml - # config-name: my-config.yml env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} From 9e1487685ce98255204428862870a7c97e017cc6 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Tue, 2 Feb 2021 11:45:45 +0300 Subject: [PATCH 040/219] Fix: charts stripes alignment (#2054) * Align chart stripe on every dataset update Signed-off-by: Alex Andreev * Removing default stripes interval Signed-off-by: Alex Andreev --- src/renderer/components/chart/bar-chart.tsx | 3 ++- .../components/chart/zebra-stripes.plugin.ts | 23 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/chart/bar-chart.tsx b/src/renderer/components/chart/bar-chart.tsx index 4f80258703..19ef031ba6 100644 --- a/src/renderer/components/chart/bar-chart.tsx +++ b/src/renderer/components/chart/bar-chart.tsx @@ -136,7 +136,8 @@ export class BarChart extends React.Component { }, plugins: { ZebraStripes: { - stripeColor: chartStripesColor + stripeColor: chartStripesColor, + interval: chartData.datasets[0].data.length } } }; diff --git a/src/renderer/components/chart/zebra-stripes.plugin.ts b/src/renderer/components/chart/zebra-stripes.plugin.ts index 3a85f8d0a2..f190401066 100644 --- a/src/renderer/components/chart/zebra-stripes.plugin.ts +++ b/src/renderer/components/chart/zebra-stripes.plugin.ts @@ -6,8 +6,6 @@ import moment, { Moment } from "moment"; import get from "lodash/get"; const defaultOptions = { - interval: 61, - stripeMinutes: 10, stripeColor: "#ffffff08", }; @@ -36,12 +34,23 @@ export const ZebraStripes = { chart.canvas.parentElement.removeChild(elem); }, + updateOptions(chart: ChartJS) { + this.options = { + ...defaultOptions, + ...this.getOptions(chart) + }; + }, + + getStripeMinutes() { + return this.options.interval < 10 ? 0 : 10; + }, + renderStripes(chart: ChartJS) { if (!chart.data.datasets.length) return; - const { interval, stripeMinutes, stripeColor } = this.options; + const { interval, stripeColor } = this.options; const { top, left, bottom, right } = chart.chartArea; const step = (right - left) / interval; - const stripeWidth = step * stripeMinutes; + const stripeWidth = step * this.getStripeMinutes(); const cover = document.createElement("div"); const styles = cover.style; @@ -61,14 +70,12 @@ export const ZebraStripes = { afterInit(chart: ChartJS) { if (!chart.data.datasets.length) return; - this.options = { - ...defaultOptions, - ...this.getOptions(chart) - }; + this.updateOptions(chart); this.updated = this.getLastUpdate(chart); }, afterUpdate(chart: ChartJS) { + this.updateOptions(chart); this.renderStripes(chart); }, From b5e7be759100f20794e86594b9105eda761379e2 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 2 Feb 2021 12:34:13 +0200 Subject: [PATCH 041/219] Initial command palette feature (#1957) * wip: command palette Signed-off-by: Jari Kolehmainen * register shortcut to global menu Signed-off-by: Jari Kolehmainen * introduce openCommandDialog & closeCommandDialog Signed-off-by: Jari Kolehmainen * fix ipc broadcast to frames from renderer Signed-off-by: Jari Kolehmainen * tweak Signed-off-by: Jari Kolehmainen * add more commands Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen * ipc fix Signed-off-by: Jari Kolehmainen * add integration tests Signed-off-by: Jari Kolehmainen * ipc fix Signed-off-by: Jari Kolehmainen * implement workspace edit Signed-off-by: Jari Kolehmainen * workspace edit fixes Signed-off-by: Jari Kolehmainen * make tests green Signed-off-by: Jari Kolehmainen * fixes from code review Signed-off-by: Jari Kolehmainen * cleanup ipc Signed-off-by: Jari Kolehmainen * cleanup CommandRegistry Signed-off-by: Jari Kolehmainen * ipc fix Signed-off-by: Jari Kolehmainen * fix ClusterManager cluster auto-init Signed-off-by: Jari Kolehmainen * ensure cluster view is active before sending a command Signed-off-by: Jari Kolehmainen * switch to last active cluster when workspace change Signed-off-by: Jari Kolehmainen * tweak integration tests Signed-off-by: Jari Kolehmainen * run integration tests serially Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen * fixes based on code review Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen * cleanup more Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen * add workspace fixes Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen --- integration/__tests__/app.tests.ts | 554 +----------------- integration/__tests__/cluster-pages.tests.ts | 450 ++++++++++++++ .../__tests__/command-palette.tests.ts | 25 + integration/__tests__/workspace.tests.ts | 75 +++ integration/helpers/minikube.ts | 59 ++ integration/helpers/utils.ts | 32 +- package.json | 2 +- src/common/ipc.ts | 52 +- src/extensions/lens-renderer-extension.ts | 2 + src/extensions/registries/command-registry.ts | 37 ++ src/extensions/renderer-api/components.ts | 3 + src/main/cluster-manager.ts | 8 +- src/main/index.ts | 3 + src/main/menu.ts | 9 + src/renderer/components/+apps/apps.command.ts | 18 + src/renderer/components/+apps/index.ts | 1 + .../cluster-settings.command.ts | 16 + .../components/cluster-workspace-setting.tsx | 7 +- .../components/+cluster-settings/index.ts | 1 + .../components/+config/config.command.ts | 50 ++ src/renderer/components/+config/index.ts | 1 + src/renderer/components/+network/index.ts | 1 + .../components/+network/network.command.ts | 34 ++ src/renderer/components/+nodes/index.ts | 1 + .../components/+nodes/node.command.ts | 10 + .../+preferences/preferences.route.ts | 9 + src/renderer/components/+workloads/index.ts | 1 + .../+workloads/workloads.command.ts | 45 ++ .../components/+workspaces/add-workspace.tsx | 64 ++ .../components/+workspaces/edit-workspace.tsx | 82 +++ src/renderer/components/+workspaces/index.ts | 1 - .../+workspaces/remove-workspace.tsx | 68 +++ .../+workspaces/workspace-menu.scss | 7 - .../components/+workspaces/workspace-menu.tsx | 66 --- .../+workspaces/workspaces.route.ts | 8 - .../components/+workspaces/workspaces.scss | 14 - .../components/+workspaces/workspaces.tsx | 255 ++------ src/renderer/components/app.tsx | 4 + .../components/cluster-manager/bottom-bar.tsx | 10 +- .../cluster-manager/cluster-manager.tsx | 2 - .../cluster-manager/clusters-menu.tsx | 42 ++ .../command-palette/command-container.scss | 11 + .../command-palette/command-container.tsx | 87 +++ .../command-palette/command-dialog.tsx | 88 +++ .../components/command-palette/index.ts | 2 + src/renderer/components/dock/dock.tsx | 9 + src/renderer/components/input/input.tsx | 2 +- src/renderer/lens-app.tsx | 2 + 48 files changed, 1446 insertions(+), 884 deletions(-) create mode 100644 integration/__tests__/cluster-pages.tests.ts create mode 100644 integration/__tests__/command-palette.tests.ts create mode 100644 integration/__tests__/workspace.tests.ts create mode 100644 integration/helpers/minikube.ts create mode 100644 src/extensions/registries/command-registry.ts create mode 100644 src/renderer/components/+apps/apps.command.ts create mode 100644 src/renderer/components/+cluster-settings/cluster-settings.command.ts create mode 100644 src/renderer/components/+config/config.command.ts create mode 100644 src/renderer/components/+network/network.command.ts create mode 100644 src/renderer/components/+nodes/node.command.ts create mode 100644 src/renderer/components/+workloads/workloads.command.ts create mode 100644 src/renderer/components/+workspaces/add-workspace.tsx create mode 100644 src/renderer/components/+workspaces/edit-workspace.tsx create mode 100644 src/renderer/components/+workspaces/remove-workspace.tsx delete mode 100644 src/renderer/components/+workspaces/workspace-menu.scss delete mode 100644 src/renderer/components/+workspaces/workspace-menu.tsx delete mode 100644 src/renderer/components/+workspaces/workspaces.route.ts delete mode 100644 src/renderer/components/+workspaces/workspaces.scss create mode 100644 src/renderer/components/command-palette/command-container.scss create mode 100644 src/renderer/components/command-palette/command-container.tsx create mode 100644 src/renderer/components/command-palette/command-dialog.tsx create mode 100644 src/renderer/components/command-palette/index.ts diff --git a/integration/__tests__/app.tests.ts b/integration/__tests__/app.tests.ts index ca30015fa1..af029a23e9 100644 --- a/integration/__tests__/app.tests.ts +++ b/integration/__tests__/app.tests.ts @@ -1,12 +1,5 @@ -/* - Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE - namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the - TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube - cluster and vice versa. -*/ import { Application } from "spectron"; import * as utils from "../helpers/utils"; -import { spawnSync } from "child_process"; import { listHelmRepositories } from "../helpers/utils"; import { fail } from "assert"; @@ -15,62 +8,10 @@ jest.setTimeout(60000); // FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below) describe("Lens integration tests", () => { - const TEST_NAMESPACE = "integration-tests"; - const BACKSPACE = "\uE003"; let app: Application; - const appStart = async () => { - app = utils.setup(); - await app.start(); - // Wait for splash screen to be closed - while (await app.client.getWindowCount() > 1); - await app.client.windowByIndex(0); - await app.client.waitUntilWindowLoaded(); - }; - const clickWhatsNew = async (app: Application) => { - await app.client.waitUntilTextExists("h1", "What's new?"); - await app.client.click("button.primary"); - await app.client.waitUntilTextExists("h1", "Welcome"); - }; - const minikubeReady = (): boolean => { - // determine if minikube is running - { - const { status } = spawnSync("minikube status", { shell: true }); - - if (status !== 0) { - console.warn("minikube not running"); - - return false; - } - } - - // Remove TEST_NAMESPACE if it already exists - { - const { status } = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true }); - - if (status === 0) { - console.warn(`Removing existing ${TEST_NAMESPACE} namespace`); - - const { status, stdout, stderr } = spawnSync( - `minikube kubectl -- delete namespace ${TEST_NAMESPACE}`, - { shell: true }, - ); - - if (status !== 0) { - console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${stderr.toString()}`); - - return false; - } - - console.log(stdout.toString()); - } - } - - return true; - }; - const ready = minikubeReady(); describe("app start", () => { - beforeAll(appStart, 20000); + beforeAll(async () => app = await utils.appStart(), 20000); afterAll(async () => { if (app?.isRunning()) { @@ -79,7 +20,7 @@ describe("Lens integration tests", () => { }); it('shows "whats new"', async () => { - await clickWhatsNew(app); + await utils.clickWhatsNew(app); }); it('shows "add cluster"', async () => { @@ -113,495 +54,4 @@ describe("Lens integration tests", () => { await app.client.keys("Meta"); }); }); - - utils.describeIf(ready)("workspaces", () => { - beforeAll(appStart, 20000); - - afterAll(async () => { - if (app && app.isRunning()) { - return utils.tearDown(app); - } - }); - - it("creates new workspace", async () => { - await clickWhatsNew(app); - await app.client.click("#current-workspace .Icon"); - await app.client.click('a[href="/workspaces"]'); - await app.client.click(".Workspaces button.Button"); - await app.client.keys("test-workspace"); - await app.client.click(".Workspaces .Input.description input"); - await app.client.keys("test description"); - await app.client.click(".Workspaces .workspace.editing .Icon"); - await app.client.waitUntilTextExists(".workspace .name a", "test-workspace"); - }); - - it("adds cluster in default workspace", async () => { - await addMinikubeCluster(app); - await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); - await app.client.waitForExist(`iframe[name="minikube"]`); - await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active"); - }); - - it("adds cluster in test-workspace", async () => { - await app.client.click("#current-workspace .Icon"); - await app.client.waitForVisible('.WorkspaceMenu li[title="test description"]'); - await app.client.click('.WorkspaceMenu li[title="test description"]'); - await addMinikubeCluster(app); - await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); - await app.client.waitForExist(`iframe[name="minikube"]`); - }); - - it("checks if default workspace has active cluster", async () => { - await app.client.click("#current-workspace .Icon"); - await app.client.waitForVisible(".WorkspaceMenu > li:first-of-type"); - await app.client.click(".WorkspaceMenu > li:first-of-type"); - await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active"); - }); - }); - - const addMinikubeCluster = async (app: Application) => { - await app.client.click("div.add-cluster"); - await app.client.waitUntilTextExists("div", "Select kubeconfig file"); - await app.client.click("div.Select__control"); // show the context drop-down list - await app.client.waitUntilTextExists("div", "minikube"); - - if (!await app.client.$("button.primary").isEnabled()) { - await app.client.click("div.minikube"); // select minikube context - } // else the only context, which must be 'minikube', is automatically selected - await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button) - await app.client.click("button.primary"); // add minikube cluster - }; - const waitForMinikubeDashboard = async (app: Application) => { - await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); - await app.client.waitForExist(`iframe[name="minikube"]`); - await app.client.frame("minikube"); - await app.client.waitUntilTextExists("span.link-text", "Cluster"); - }; - - utils.describeIf(ready)("cluster tests", () => { - let clusterAdded = false; - const addCluster = async () => { - await clickWhatsNew(app); - await addMinikubeCluster(app); - await waitForMinikubeDashboard(app); - await app.client.click('a[href="/nodes"]'); - await app.client.waitUntilTextExists("div.TableCell", "Ready"); - }; - - describe("cluster add", () => { - beforeAll(appStart, 20000); - - afterAll(async () => { - if (app && app.isRunning()) { - return utils.tearDown(app); - } - }); - - it("allows to add a cluster", async () => { - await addCluster(); - clusterAdded = true; - }); - }); - - const appStartAddCluster = async () => { - if (clusterAdded) { - await appStart(); - await addCluster(); - } - }; - - describe("cluster pages", () => { - - beforeAll(appStartAddCluster, 40000); - - afterAll(async () => { - if (app && app.isRunning()) { - return utils.tearDown(app); - } - }); - - const tests: { - drawer?: string - drawerId?: string - pages: { - name: string, - href: string, - expectedSelector: string, - expectedText: string - }[] - }[] = [{ - drawer: "", - drawerId: "", - pages: [{ - name: "Cluster", - href: "cluster", - expectedSelector: "div.ClusterOverview div.label", - expectedText: "Master" - }] - }, - { - drawer: "", - drawerId: "", - pages: [{ - name: "Nodes", - href: "nodes", - expectedSelector: "h5.title", - expectedText: "Nodes" - }] - }, - { - drawer: "Workloads", - drawerId: "workloads", - pages: [{ - name: "Overview", - href: "workloads", - expectedSelector: "h5.box", - expectedText: "Overview" - }, - { - name: "Pods", - href: "pods", - expectedSelector: "h5.title", - expectedText: "Pods" - }, - { - name: "Deployments", - href: "deployments", - expectedSelector: "h5.title", - expectedText: "Deployments" - }, - { - name: "DaemonSets", - href: "daemonsets", - expectedSelector: "h5.title", - expectedText: "Daemon Sets" - }, - { - name: "StatefulSets", - href: "statefulsets", - expectedSelector: "h5.title", - expectedText: "Stateful Sets" - }, - { - name: "ReplicaSets", - href: "replicasets", - expectedSelector: "h5.title", - expectedText: "Replica Sets" - }, - { - name: "Jobs", - href: "jobs", - expectedSelector: "h5.title", - expectedText: "Jobs" - }, - { - name: "CronJobs", - href: "cronjobs", - expectedSelector: "h5.title", - expectedText: "Cron Jobs" - }] - }, - { - drawer: "Configuration", - drawerId: "config", - pages: [{ - name: "ConfigMaps", - href: "configmaps", - expectedSelector: "h5.title", - expectedText: "Config Maps" - }, - { - name: "Secrets", - href: "secrets", - expectedSelector: "h5.title", - expectedText: "Secrets" - }, - { - name: "Resource Quotas", - href: "resourcequotas", - expectedSelector: "h5.title", - expectedText: "Resource Quotas" - }, - { - name: "Limit Ranges", - href: "limitranges", - expectedSelector: "h5.title", - expectedText: "Limit Ranges" - }, - { - name: "HPA", - href: "hpa", - expectedSelector: "h5.title", - expectedText: "Horizontal Pod Autoscalers" - }, - { - name: "Pod Disruption Budgets", - href: "poddisruptionbudgets", - expectedSelector: "h5.title", - expectedText: "Pod Disruption Budgets" - }] - }, - { - drawer: "Network", - drawerId: "networks", - pages: [{ - name: "Services", - href: "services", - expectedSelector: "h5.title", - expectedText: "Services" - }, - { - name: "Endpoints", - href: "endpoints", - expectedSelector: "h5.title", - expectedText: "Endpoints" - }, - { - name: "Ingresses", - href: "ingresses", - expectedSelector: "h5.title", - expectedText: "Ingresses" - }, - { - name: "Network Policies", - href: "network-policies", - expectedSelector: "h5.title", - expectedText: "Network Policies" - }] - }, - { - drawer: "Storage", - drawerId: "storage", - pages: [{ - name: "Persistent Volume Claims", - href: "persistent-volume-claims", - expectedSelector: "h5.title", - expectedText: "Persistent Volume Claims" - }, - { - name: "Persistent Volumes", - href: "persistent-volumes", - expectedSelector: "h5.title", - expectedText: "Persistent Volumes" - }, - { - name: "Storage Classes", - href: "storage-classes", - expectedSelector: "h5.title", - expectedText: "Storage Classes" - }] - }, - { - drawer: "", - drawerId: "", - pages: [{ - name: "Namespaces", - href: "namespaces", - expectedSelector: "h5.title", - expectedText: "Namespaces" - }] - }, - { - drawer: "", - drawerId: "", - pages: [{ - name: "Events", - href: "events", - expectedSelector: "h5.title", - expectedText: "Events" - }] - }, - { - drawer: "Apps", - drawerId: "apps", - pages: [{ - name: "Charts", - href: "apps/charts", - expectedSelector: "div.HelmCharts input", - expectedText: "" - }, - { - name: "Releases", - href: "apps/releases", - expectedSelector: "h5.title", - expectedText: "Releases" - }] - }, - { - drawer: "Access Control", - drawerId: "users", - pages: [{ - name: "Service Accounts", - href: "service-accounts", - expectedSelector: "h5.title", - expectedText: "Service Accounts" - }, - { - name: "Role Bindings", - href: "role-bindings", - expectedSelector: "h5.title", - expectedText: "Role Bindings" - }, - { - name: "Roles", - href: "roles", - expectedSelector: "h5.title", - expectedText: "Roles" - }, - { - name: "Pod Security Policies", - href: "pod-security-policies", - expectedSelector: "h5.title", - expectedText: "Pod Security Policies" - }] - }, - { - drawer: "Custom Resources", - drawerId: "custom-resources", - pages: [{ - name: "Definitions", - href: "crd/definitions", - expectedSelector: "h5.title", - expectedText: "Custom Resources" - }] - }]; - - tests.forEach(({ drawer = "", drawerId = "", pages }) => { - if (drawer !== "") { - it(`shows ${drawer} drawer`, async () => { - expect(clusterAdded).toBe(true); - await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`); - await app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name); - }); - } - pages.forEach(({ name, href, expectedSelector, expectedText }) => { - it(`shows ${drawer}->${name} page`, async () => { - expect(clusterAdded).toBe(true); - await app.client.click(`a[href^="/${href}"]`); - await app.client.waitUntilTextExists(expectedSelector, expectedText); - }); - }); - - if (drawer !== "") { - // hide the drawer - it(`hides ${drawer} drawer`, async () => { - expect(clusterAdded).toBe(true); - await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`); - await expect(app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow(); - }); - } - }); - }); - - describe("viewing pod logs", () => { - beforeEach(appStartAddCluster, 40000); - - afterEach(async () => { - if (app && app.isRunning()) { - return utils.tearDown(app); - } - }); - - it(`shows a logs for a pod`, async () => { - expect(clusterAdded).toBe(true); - // Go to Pods page - await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); - await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); - await app.client.click('a[href^="/pods"]'); - await app.client.click(".NamespaceSelect"); - await app.client.keys("kube-system"); - await app.client.keys("Enter");// "\uE007" - await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver"); - let podMenuItemEnabled = false; - - // Wait until extensions are enabled on renderer - while (!podMenuItemEnabled) { - const logs = await app.client.getRenderProcessLogs(); - - podMenuItemEnabled = !!logs.find(entry => entry.message.includes("[EXTENSION]: enabled lens-pod-menu@")); - - if (!podMenuItemEnabled) { - await new Promise(r => setTimeout(r, 1000)); - } - } - await new Promise(r => setTimeout(r, 500)); // Give some extra time to prepare extensions - // Open logs tab in dock - await app.client.click(".list .TableRow:first-child"); - await app.client.waitForVisible(".Drawer"); - await app.client.click(".drawer-title .Menu li:nth-child(2)"); - // Check if controls are available - await app.client.waitForVisible(".LogList .VirtualList"); - await app.client.waitForVisible(".LogResourceSelector"); - //await app.client.waitForVisible(".LogSearch .SearchInput"); - await app.client.waitForVisible(".LogSearch .SearchInput input"); - // Search for semicolon - await app.client.keys(":"); - await app.client.waitForVisible(".LogList .list span.active"); - // Click through controls - await app.client.click(".LogControls .show-timestamps"); - await app.client.click(".LogControls .show-previous"); - }); - }); - - describe("cluster operations", () => { - beforeEach(appStartAddCluster, 40000); - - afterEach(async () => { - if (app && app.isRunning()) { - return utils.tearDown(app); - } - }); - - it("shows default namespace", async () => { - expect(clusterAdded).toBe(true); - await app.client.click('a[href="/namespaces"]'); - await app.client.waitUntilTextExists("div.TableCell", "default"); - await app.client.waitUntilTextExists("div.TableCell", "kube-system"); - }); - - it(`creates ${TEST_NAMESPACE} namespace`, async () => { - expect(clusterAdded).toBe(true); - await app.client.click('a[href="/namespaces"]'); - await app.client.waitUntilTextExists("div.TableCell", "default"); - await app.client.waitUntilTextExists("div.TableCell", "kube-system"); - await app.client.click("button.add-button"); - await app.client.waitUntilTextExists("div.AddNamespaceDialog", "Create Namespace"); - await app.client.keys(`${TEST_NAMESPACE}\n`); - await app.client.waitForExist(`.name=${TEST_NAMESPACE}`); - }); - - it(`creates a pod in ${TEST_NAMESPACE} namespace`, async () => { - expect(clusterAdded).toBe(true); - await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); - await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); - await app.client.click('a[href^="/pods"]'); - - await app.client.click(".NamespaceSelect"); - await app.client.keys(TEST_NAMESPACE); - await app.client.keys("Enter");// "\uE007" - await app.client.click(".Icon.new-dock-tab"); - await app.client.waitUntilTextExists("li.MenuItem.create-resource-tab", "Create resource"); - await app.client.click("li.MenuItem.create-resource-tab"); - await app.client.waitForVisible(".CreateResource div.ace_content"); - // Write pod manifest to editor - await app.client.keys("apiVersion: v1\n"); - await app.client.keys("kind: Pod\n"); - await app.client.keys("metadata:\n"); - await app.client.keys(" name: nginx-create-pod-test\n"); - await app.client.keys(`namespace: ${TEST_NAMESPACE}\n`); - await app.client.keys(`${BACKSPACE}spec:\n`); - await app.client.keys(" containers:\n"); - await app.client.keys("- name: nginx-create-pod-test\n"); - await app.client.keys(" image: nginx:alpine\n"); - // Create deployment - await app.client.waitForEnabled("button.Button=Create & Close"); - await app.client.click("button.Button=Create & Close"); - // Wait until first bits of pod appears on dashboard - await app.client.waitForExist(".name=nginx-create-pod-test"); - // Open pod details - await app.client.click(".name=nginx-create-pod-test"); - await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test"); - }); - }); - }); }); diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts new file mode 100644 index 0000000000..e73774f86a --- /dev/null +++ b/integration/__tests__/cluster-pages.tests.ts @@ -0,0 +1,450 @@ +/* + Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE + namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the + TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube + cluster and vice versa. +*/ +import { Application } from "spectron"; +import * as utils from "../helpers/utils"; +import { addMinikubeCluster, minikubeReady, waitForMinikubeDashboard } from "../helpers/minikube"; +import { exec } from "child_process"; +import * as util from "util"; + +export const promiseExec = util.promisify(exec); + +jest.setTimeout(60000); + +// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below) +describe("Lens cluster pages", () => { + const TEST_NAMESPACE = "integration-tests"; + const BACKSPACE = "\uE003"; + let app: Application; + const ready = minikubeReady(TEST_NAMESPACE); + + utils.describeIf(ready)("test common pages", () => { + let clusterAdded = false; + const addCluster = async () => { + await utils.clickWhatsNew(app); + await addMinikubeCluster(app); + await waitForMinikubeDashboard(app); + await app.client.click('a[href="/nodes"]'); + await app.client.waitUntilTextExists("div.TableCell", "Ready"); + }; + + describe("cluster add", () => { + beforeAll(async () => app = await utils.appStart(), 20000); + + afterAll(async () => { + if (app && app.isRunning()) { + return utils.tearDown(app); + } + }); + + it("allows to add a cluster", async () => { + await addCluster(); + clusterAdded = true; + }); + }); + + const appStartAddCluster = async () => { + if (clusterAdded) { + app = await utils.appStart(); + await addCluster(); + } + }; + + describe("cluster pages", () => { + + beforeAll(appStartAddCluster, 40000); + + afterAll(async () => { + if (app && app.isRunning()) { + return utils.tearDown(app); + } + }); + + const tests: { + drawer?: string + drawerId?: string + pages: { + name: string, + href: string, + expectedSelector: string, + expectedText: string + }[] + }[] = [{ + drawer: "", + drawerId: "", + pages: [{ + name: "Cluster", + href: "cluster", + expectedSelector: "div.ClusterOverview div.label", + expectedText: "Master" + }] + }, + { + drawer: "", + drawerId: "", + pages: [{ + name: "Nodes", + href: "nodes", + expectedSelector: "h5.title", + expectedText: "Nodes" + }] + }, + { + drawer: "Workloads", + drawerId: "workloads", + pages: [{ + name: "Overview", + href: "workloads", + expectedSelector: "h5.box", + expectedText: "Overview" + }, + { + name: "Pods", + href: "pods", + expectedSelector: "h5.title", + expectedText: "Pods" + }, + { + name: "Deployments", + href: "deployments", + expectedSelector: "h5.title", + expectedText: "Deployments" + }, + { + name: "DaemonSets", + href: "daemonsets", + expectedSelector: "h5.title", + expectedText: "Daemon Sets" + }, + { + name: "StatefulSets", + href: "statefulsets", + expectedSelector: "h5.title", + expectedText: "Stateful Sets" + }, + { + name: "ReplicaSets", + href: "replicasets", + expectedSelector: "h5.title", + expectedText: "Replica Sets" + }, + { + name: "Jobs", + href: "jobs", + expectedSelector: "h5.title", + expectedText: "Jobs" + }, + { + name: "CronJobs", + href: "cronjobs", + expectedSelector: "h5.title", + expectedText: "Cron Jobs" + }] + }, + { + drawer: "Configuration", + drawerId: "config", + pages: [{ + name: "ConfigMaps", + href: "configmaps", + expectedSelector: "h5.title", + expectedText: "Config Maps" + }, + { + name: "Secrets", + href: "secrets", + expectedSelector: "h5.title", + expectedText: "Secrets" + }, + { + name: "Resource Quotas", + href: "resourcequotas", + expectedSelector: "h5.title", + expectedText: "Resource Quotas" + }, + { + name: "Limit Ranges", + href: "limitranges", + expectedSelector: "h5.title", + expectedText: "Limit Ranges" + }, + { + name: "HPA", + href: "hpa", + expectedSelector: "h5.title", + expectedText: "Horizontal Pod Autoscalers" + }, + { + name: "Pod Disruption Budgets", + href: "poddisruptionbudgets", + expectedSelector: "h5.title", + expectedText: "Pod Disruption Budgets" + }] + }, + { + drawer: "Network", + drawerId: "networks", + pages: [{ + name: "Services", + href: "services", + expectedSelector: "h5.title", + expectedText: "Services" + }, + { + name: "Endpoints", + href: "endpoints", + expectedSelector: "h5.title", + expectedText: "Endpoints" + }, + { + name: "Ingresses", + href: "ingresses", + expectedSelector: "h5.title", + expectedText: "Ingresses" + }, + { + name: "Network Policies", + href: "network-policies", + expectedSelector: "h5.title", + expectedText: "Network Policies" + }] + }, + { + drawer: "Storage", + drawerId: "storage", + pages: [{ + name: "Persistent Volume Claims", + href: "persistent-volume-claims", + expectedSelector: "h5.title", + expectedText: "Persistent Volume Claims" + }, + { + name: "Persistent Volumes", + href: "persistent-volumes", + expectedSelector: "h5.title", + expectedText: "Persistent Volumes" + }, + { + name: "Storage Classes", + href: "storage-classes", + expectedSelector: "h5.title", + expectedText: "Storage Classes" + }] + }, + { + drawer: "", + drawerId: "", + pages: [{ + name: "Namespaces", + href: "namespaces", + expectedSelector: "h5.title", + expectedText: "Namespaces" + }] + }, + { + drawer: "", + drawerId: "", + pages: [{ + name: "Events", + href: "events", + expectedSelector: "h5.title", + expectedText: "Events" + }] + }, + { + drawer: "Apps", + drawerId: "apps", + pages: [{ + name: "Charts", + href: "apps/charts", + expectedSelector: "div.HelmCharts input", + expectedText: "" + }, + { + name: "Releases", + href: "apps/releases", + expectedSelector: "h5.title", + expectedText: "Releases" + }] + }, + { + drawer: "Access Control", + drawerId: "users", + pages: [{ + name: "Service Accounts", + href: "service-accounts", + expectedSelector: "h5.title", + expectedText: "Service Accounts" + }, + { + name: "Role Bindings", + href: "role-bindings", + expectedSelector: "h5.title", + expectedText: "Role Bindings" + }, + { + name: "Roles", + href: "roles", + expectedSelector: "h5.title", + expectedText: "Roles" + }, + { + name: "Pod Security Policies", + href: "pod-security-policies", + expectedSelector: "h5.title", + expectedText: "Pod Security Policies" + }] + }, + { + drawer: "Custom Resources", + drawerId: "custom-resources", + pages: [{ + name: "Definitions", + href: "crd/definitions", + expectedSelector: "h5.title", + expectedText: "Custom Resources" + }] + }]; + + tests.forEach(({ drawer = "", drawerId = "", pages }) => { + if (drawer !== "") { + it(`shows ${drawer} drawer`, async () => { + expect(clusterAdded).toBe(true); + await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`); + await app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name); + }); + } + pages.forEach(({ name, href, expectedSelector, expectedText }) => { + it(`shows ${drawer}->${name} page`, async () => { + expect(clusterAdded).toBe(true); + await app.client.click(`a[href^="/${href}"]`); + await app.client.waitUntilTextExists(expectedSelector, expectedText); + }); + }); + + if (drawer !== "") { + // hide the drawer + it(`hides ${drawer} drawer`, async () => { + expect(clusterAdded).toBe(true); + await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`); + await expect(app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow(); + }); + } + }); + }); + + describe("viewing pod logs", () => { + beforeEach(appStartAddCluster, 40000); + + afterEach(async () => { + if (app && app.isRunning()) { + return utils.tearDown(app); + } + }); + + it(`shows a logs for a pod`, async () => { + expect(clusterAdded).toBe(true); + // Go to Pods page + await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); + await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); + await app.client.click('a[href^="/pods"]'); + await app.client.click(".NamespaceSelect"); + await app.client.keys("kube-system"); + await app.client.keys("Enter");// "\uE007" + await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver"); + let podMenuItemEnabled = false; + + // Wait until extensions are enabled on renderer + while (!podMenuItemEnabled) { + const logs = await app.client.getRenderProcessLogs(); + + podMenuItemEnabled = !!logs.find(entry => entry.message.includes("[EXTENSION]: enabled lens-pod-menu@")); + + if (!podMenuItemEnabled) { + await new Promise(r => setTimeout(r, 1000)); + } + } + await new Promise(r => setTimeout(r, 500)); // Give some extra time to prepare extensions + // Open logs tab in dock + await app.client.click(".list .TableRow:first-child"); + await app.client.waitForVisible(".Drawer"); + await app.client.click(".drawer-title .Menu li:nth-child(2)"); + // Check if controls are available + await app.client.waitForVisible(".LogList .VirtualList"); + await app.client.waitForVisible(".LogResourceSelector"); + //await app.client.waitForVisible(".LogSearch .SearchInput"); + await app.client.waitForVisible(".LogSearch .SearchInput input"); + // Search for semicolon + await app.client.keys(":"); + await app.client.waitForVisible(".LogList .list span.active"); + // Click through controls + await app.client.click(".LogControls .show-timestamps"); + await app.client.click(".LogControls .show-previous"); + }); + }); + + describe("cluster operations", () => { + beforeEach(appStartAddCluster, 40000); + + afterEach(async () => { + if (app && app.isRunning()) { + return utils.tearDown(app); + } + }); + + it("shows default namespace", async () => { + expect(clusterAdded).toBe(true); + await app.client.click('a[href="/namespaces"]'); + await app.client.waitUntilTextExists("div.TableCell", "default"); + await app.client.waitUntilTextExists("div.TableCell", "kube-system"); + }); + + it(`creates ${TEST_NAMESPACE} namespace`, async () => { + expect(clusterAdded).toBe(true); + await app.client.click('a[href="/namespaces"]'); + await app.client.waitUntilTextExists("div.TableCell", "default"); + await app.client.waitUntilTextExists("div.TableCell", "kube-system"); + await app.client.click("button.add-button"); + await app.client.waitUntilTextExists("div.AddNamespaceDialog", "Create Namespace"); + await app.client.keys(`${TEST_NAMESPACE}\n`); + await app.client.waitForExist(`.name=${TEST_NAMESPACE}`); + }); + + it(`creates a pod in ${TEST_NAMESPACE} namespace`, async () => { + expect(clusterAdded).toBe(true); + await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); + await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods"); + await app.client.click('a[href^="/pods"]'); + + await app.client.click(".NamespaceSelect"); + await app.client.keys(TEST_NAMESPACE); + await app.client.keys("Enter");// "\uE007" + await app.client.click(".Icon.new-dock-tab"); + await app.client.waitUntilTextExists("li.MenuItem.create-resource-tab", "Create resource"); + await app.client.click("li.MenuItem.create-resource-tab"); + await app.client.waitForVisible(".CreateResource div.ace_content"); + // Write pod manifest to editor + await app.client.keys("apiVersion: v1\n"); + await app.client.keys("kind: Pod\n"); + await app.client.keys("metadata:\n"); + await app.client.keys(" name: nginx-create-pod-test\n"); + await app.client.keys(`namespace: ${TEST_NAMESPACE}\n`); + await app.client.keys(`${BACKSPACE}spec:\n`); + await app.client.keys(" containers:\n"); + await app.client.keys("- name: nginx-create-pod-test\n"); + await app.client.keys(" image: nginx:alpine\n"); + // Create deployment + await app.client.waitForEnabled("button.Button=Create & Close"); + await app.client.click("button.Button=Create & Close"); + // Wait until first bits of pod appears on dashboard + await app.client.waitForExist(".name=nginx-create-pod-test"); + // Open pod details + await app.client.click(".name=nginx-create-pod-test"); + await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test"); + }); + }); + }); +}); diff --git a/integration/__tests__/command-palette.tests.ts b/integration/__tests__/command-palette.tests.ts new file mode 100644 index 0000000000..789806c445 --- /dev/null +++ b/integration/__tests__/command-palette.tests.ts @@ -0,0 +1,25 @@ +import { Application } from "spectron"; +import * as utils from "../helpers/utils"; + +jest.setTimeout(60000); + +describe("Lens command palette", () => { + let app: Application; + + describe("menu", () => { + beforeAll(async () => app = await utils.appStart(), 20000); + + afterAll(async () => { + if (app?.isRunning()) { + await utils.tearDown(app); + } + }); + + it("opens command dialog from menu", async () => { + await utils.clickWhatsNew(app); + await app.electron.ipcRenderer.send("test-menu-item-click", "View", "Command Palette..."); + await app.client.waitUntilTextExists(".Select__option", "Preferences: Open"); + await app.client.keys("Escape"); + }); + }); +}); diff --git a/integration/__tests__/workspace.tests.ts b/integration/__tests__/workspace.tests.ts new file mode 100644 index 0000000000..4164151b0f --- /dev/null +++ b/integration/__tests__/workspace.tests.ts @@ -0,0 +1,75 @@ +import { Application } from "spectron"; +import * as utils from "../helpers/utils"; +import { addMinikubeCluster, minikubeReady } from "../helpers/minikube"; +import { exec } from "child_process"; +import * as util from "util"; + +export const promiseExec = util.promisify(exec); + +jest.setTimeout(60000); + +describe("Lens integration tests", () => { + let app: Application; + const ready = minikubeReady("workspace-int-tests"); + + utils.describeIf(ready)("workspaces", () => { + beforeAll(async () => { + app = await utils.appStart(); + await utils.clickWhatsNew(app); + }, 20000); + + afterAll(async () => { + if (app && app.isRunning()) { + return utils.tearDown(app); + } + }); + + const switchToWorkspace = async (name: string) => { + await app.client.click("[data-test-id=current-workspace]"); + await app.client.keys(name); + await app.client.keys("Enter"); + await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name); + }; + + const createWorkspace = async (name: string) => { + await app.client.click("[data-test-id=current-workspace]"); + await app.client.keys("add workspace"); + await app.client.keys("Enter"); + await app.client.keys(name); + await app.client.keys("Enter"); + await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name); + }; + + it("creates new workspace", async () => { + const name = "test-workspace"; + + await createWorkspace(name); + await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name); + }); + + it("edits current workspaces", async () => { + await createWorkspace("to-be-edited"); + await app.client.click("[data-test-id=current-workspace]"); + await app.client.keys("edit current workspace"); + await app.client.keys("Enter"); + await app.client.keys("edited-workspace"); + await app.client.keys("Enter"); + await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", "edited-workspace"); + }); + + it("adds cluster in default workspace", async () => { + await switchToWorkspace("default"); + await addMinikubeCluster(app); + await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); + await app.client.waitForExist(`iframe[name="minikube"]`); + await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active"); + }); + + it("adds cluster in test-workspace", async () => { + await switchToWorkspace("test-workspace"); + await addMinikubeCluster(app); + await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); + await app.client.waitForExist(`iframe[name="minikube"]`); + }); + }); +}); diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts new file mode 100644 index 0000000000..67ef0145d5 --- /dev/null +++ b/integration/helpers/minikube.ts @@ -0,0 +1,59 @@ +import { spawnSync } from "child_process"; +import { Application } from "spectron"; + +export function minikubeReady(testNamespace: string): boolean { + // determine if minikube is running + { + const { status } = spawnSync("minikube status", { shell: true }); + + if (status !== 0) { + console.warn("minikube not running"); + + return false; + } + } + + // Remove TEST_NAMESPACE if it already exists + { + const { status } = spawnSync(`minikube kubectl -- get namespace ${testNamespace}`, { shell: true }); + + if (status === 0) { + console.warn(`Removing existing ${testNamespace} namespace`); + + const { status, stdout, stderr } = spawnSync( + `minikube kubectl -- delete namespace ${testNamespace}`, + { shell: true }, + ); + + if (status !== 0) { + console.warn(`Error removing ${testNamespace} namespace: ${stderr.toString()}`); + + return false; + } + + console.log(stdout.toString()); + } + } + + return true; +} + +export async function addMinikubeCluster(app: Application) { + await app.client.click("div.add-cluster"); + await app.client.waitUntilTextExists("div", "Select kubeconfig file"); + await app.client.click("div.Select__control"); // show the context drop-down list + await app.client.waitUntilTextExists("div", "minikube"); + + if (!await app.client.$("button.primary").isEnabled()) { + await app.client.click("div.minikube"); // select minikube context + } // else the only context, which must be 'minikube', is automatically selected + await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button) + await app.client.click("button.primary"); // add minikube cluster +} + +export async function waitForMinikubeDashboard(app: Application) { + await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); + await app.client.waitForExist(`iframe[name="minikube"]`); + await app.client.frame("minikube"); + await app.client.waitUntilTextExists("span.link-text", "Cluster"); +} diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 195de2d073..f7fbac5830 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -28,12 +28,29 @@ export function setup(): Application { }); } -type HelmRepository = { - name: string; - url: string; +export const keys = { + backspace: "\uE003" }; + +export async function appStart() { + const app = setup(); + + await app.start(); + // Wait for splash screen to be closed + while (await app.client.getWindowCount() > 1); + await app.client.windowByIndex(0); + await app.client.waitUntilWindowLoaded(); + + return app; +} + +export async function clickWhatsNew(app: Application) { + await app.client.waitUntilTextExists("h1", "What's new?"); + await app.client.click("button.primary"); + await app.client.waitUntilTextExists("h1", "Welcome"); +} + type AsyncPidGetter = () => Promise; -export const promiseExec = util.promisify(exec); export async function tearDown(app: Application) { const pid = await (app.mainProcess.pid as any as AsyncPidGetter)(); @@ -47,6 +64,13 @@ export async function tearDown(app: Application) { } } +export const promiseExec = util.promisify(exec); + +type HelmRepository = { + name: string; + url: string; +}; + export async function listHelmRepositories(retries = 0): Promise{ if (retries < 5) { try { diff --git a/package.json b/package.json index 735a5fa341..2b6e8ca9db 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens", "build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens", "test": "jest --env=jsdom src $@", - "integration": "jest --coverage integration $@", + "integration": "jest --runInBand integration", "dist": "yarn run compile && electron-builder --publish onTag", "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", "dist:dir": "yarn run dist --dir -c.compression=store -c.mac.identity=null", diff --git a/src/common/ipc.ts b/src/common/ipc.ts index 369221815b..c2f8562cf7 100644 --- a/src/common/ipc.ts +++ b/src/common/ipc.ts @@ -3,9 +3,12 @@ // https://www.electronjs.org/docs/api/ipc-renderer import { ipcMain, ipcRenderer, webContents, remote } from "electron"; +import { toJS } from "mobx"; import logger from "../main/logger"; import { ClusterFrameInfo, clusterFrameMap } from "./cluster-frames"; +const subFramesChannel = "ipc:get-sub-frames"; + export function handleRequest(channel: string, listener: (...args: any[]) => any) { ipcMain.handle(channel, listener); } @@ -14,38 +17,39 @@ export async function requestMain(channel: string, ...args: any[]) { return ipcRenderer.invoke(channel, ...args); } -async function getSubFrames(): Promise { - const subFrames: ClusterFrameInfo[] = []; - - clusterFrameMap.forEach(frameInfo => { - subFrames.push(frameInfo); - }); - - return subFrames; +function getSubFrames(): ClusterFrameInfo[] { + return toJS(Array.from(clusterFrameMap.values()), { recurseEverything: true }); } -export function broadcastMessage(channel: string, ...args: any[]) { +export async function broadcastMessage(channel: string, ...args: any[]) { const views = (webContents || remote?.webContents)?.getAllWebContents(); if (!views) return; - views.forEach(webContent => { - const type = webContent.getType(); - - logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args }); - webContent.send(channel, ...args); - getSubFrames().then((frames) => { - frames.map((frameInfo) => { - webContent.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); - }); - }).catch((e) => e); - }); - if (ipcRenderer) { ipcRenderer.send(channel, ...args); } else { ipcMain.emit(channel, ...args); } + + for (const view of views) { + const type = view.getType(); + + logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${view.id}`, { args }); + view.send(channel, ...args); + + try { + const subFrames: ClusterFrameInfo[] = ipcRenderer + ? await requestMain(subFramesChannel) + : getSubFrames(); + + for (const frameInfo of subFrames) { + view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); + } + } catch (error) { + logger.error("[IPC]: failed to send IPC message", { error }); + } + } } export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) { @@ -73,3 +77,9 @@ export function unsubscribeAllFromBroadcast(channel: string) { ipcMain.removeAllListeners(channel); } } + +export function bindBroadcastHandlers() { + handleRequest(subFramesChannel, () => { + return getSubFrames(); + }); +} diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 25afaa76fe..8b9b132114 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -2,6 +2,7 @@ import type { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPage import type { Cluster } from "../main/cluster"; import { LensExtension } from "./lens-extension"; import { getExtensionPageUrl } from "./registries/page-registry"; +import { CommandRegistration } from "./registries/command-registry"; export class LensRendererExtension extends LensExtension { globalPages: PageRegistration[] = []; @@ -14,6 +15,7 @@ export class LensRendererExtension extends LensExtension { statusBarItems: StatusBarRegistration[] = []; kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; kubeObjectMenuItems: KubeObjectMenuRegistration[] = []; + commands: CommandRegistration[] = []; async navigate

(pageId?: string, params?: P) { const { navigate } = await import("../renderer/navigation"); diff --git a/src/extensions/registries/command-registry.ts b/src/extensions/registries/command-registry.ts new file mode 100644 index 0000000000..0b1fc0252c --- /dev/null +++ b/src/extensions/registries/command-registry.ts @@ -0,0 +1,37 @@ +// Extensions API -> Commands + +import type { Cluster } from "../../main/cluster"; +import type { Workspace } from "../../common/workspace-store"; +import { BaseRegistry } from "./base-registry"; +import { action } from "mobx"; +import { LensExtension } from "../lens-extension"; + +export type CommandContext = { + cluster?: Cluster; + workspace?: Workspace; +}; + +export interface CommandRegistration { + id: string; + title: string; + scope: "cluster" | "global"; + action: (context: CommandContext) => void; + isActive?: (context: CommandContext) => boolean; +} + +export class CommandRegistry extends BaseRegistry { + @action + add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) { + const itemArray = [items].flat(); + + const newIds = itemArray.map((item) => item.id); + const currentIds = this.getItems().map((item) => item.id); + + const filteredIds = newIds.filter((id) => !currentIds.includes(id)); + const filteredItems = itemArray.filter((item) => filteredIds.includes(item.id)); + + return super.add(filteredItems, extension); + } +} + +export const commandRegistry = new CommandRegistry(); diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 49c747da3a..55de99bf80 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -13,6 +13,9 @@ export * from "../../renderer/components/select"; export * from "../../renderer/components/slider"; export * from "../../renderer/components/input/input"; +// command-overlay +export { CommandOverlay } from "../../renderer/components/command-palette"; + // other components export * from "../../renderer/components/icon"; export * from "../../renderer/components/tooltip"; diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 1b468e3bb6..dfcda98203 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -1,7 +1,7 @@ import "../common/cluster-ipc"; import type http from "http"; import { ipcMain } from "electron"; -import { autorun } from "mobx"; +import { autorun, reaction } from "mobx"; import { clusterStore, getClusterIdFromHost } from "../common/cluster-store"; import { Cluster } from "./cluster"; import logger from "./logger"; @@ -12,14 +12,14 @@ export class ClusterManager extends Singleton { constructor(public readonly port: number) { super(); // auto-init clusters - autorun(() => { - clusterStore.enabledClustersList.forEach(cluster => { + reaction(() => clusterStore.enabledClustersList, (clusters) => { + clusters.forEach((cluster) => { if (!cluster.initialized && !cluster.initializing) { logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta()); cluster.init(port); } }); - }); + }, { fireImmediately: true }); // auto-stop removed clusters autorun(() => { diff --git a/src/main/index.ts b/src/main/index.ts index 265c91f6d4..2b7817f093 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,6 +26,7 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension- import type { LensExtensionId } from "../extensions/lens-extension"; import { installDeveloperTools } from "./developer-tools"; import { filesystemProvisionerStore } from "./extension-filesystem"; +import { bindBroadcastHandlers } from "../common/ipc"; const workingDir = path.join(app.getPath("appData"), appName); let proxyPort: number; @@ -63,6 +64,8 @@ app.on("ready", async () => { logger.info(`🚀 Starting Lens from "${workingDir}"`); await shellSync(); + bindBroadcastHandlers(); + powerMonitor.on("shutdown", () => { app.exit(); }); diff --git a/src/main/menu.ts b/src/main/menu.ts index 2cddbb1b01..57c6ccab5e 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -10,6 +10,7 @@ import { extensionsURL } from "../renderer/components/+extensions/extensions.rou import { menuRegistry } from "../extensions/registries/menu-registry"; import logger from "./logger"; import { exitApp } from "./exit-app"; +import { broadcastMessage } from "../common/ipc"; export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; @@ -173,6 +174,14 @@ export function buildMenu(windowManager: WindowManager) { const viewMenu: MenuItemConstructorOptions = { label: "View", submenu: [ + { + label: "Command Palette...", + accelerator: "Shift+CmdOrCtrl+P", + click() { + broadcastMessage("command-palette:open"); + } + }, + { type: "separator" }, { label: "Back", accelerator: "CmdOrCtrl+[", diff --git a/src/renderer/components/+apps/apps.command.ts b/src/renderer/components/+apps/apps.command.ts new file mode 100644 index 0000000000..ff6c9d615d --- /dev/null +++ b/src/renderer/components/+apps/apps.command.ts @@ -0,0 +1,18 @@ +import { navigate } from "../../navigation"; +import { commandRegistry } from "../../../extensions/registries/command-registry"; +import { helmChartsURL } from "../+apps-helm-charts"; +import { releaseURL } from "../+apps-releases"; + +commandRegistry.add({ + id: "cluster.viewHelmCharts", + title: "Cluster: View Helm Charts", + scope: "cluster", + action: () => navigate(helmChartsURL()) +}); + +commandRegistry.add({ + id: "cluster.viewHelmReleases", + title: "Cluster: View Helm Releases", + scope: "cluster", + action: () => navigate(releaseURL()) +}); diff --git a/src/renderer/components/+apps/index.ts b/src/renderer/components/+apps/index.ts index 330891b2b1..30fbf65316 100644 --- a/src/renderer/components/+apps/index.ts +++ b/src/renderer/components/+apps/index.ts @@ -1,2 +1,3 @@ export * from "./apps"; export * from "./apps.route"; +export * from "./apps.command"; diff --git a/src/renderer/components/+cluster-settings/cluster-settings.command.ts b/src/renderer/components/+cluster-settings/cluster-settings.command.ts new file mode 100644 index 0000000000..a3b3c8792e --- /dev/null +++ b/src/renderer/components/+cluster-settings/cluster-settings.command.ts @@ -0,0 +1,16 @@ +import { navigate } from "../../navigation"; +import { commandRegistry } from "../../../extensions/registries/command-registry"; +import { clusterSettingsURL } from "./cluster-settings.route"; +import { clusterStore } from "../../../common/cluster-store"; + +commandRegistry.add({ + id: "cluster.viewCurrentClusterSettings", + title: "Cluster: View Settings", + scope: "global", + action: () => navigate(clusterSettingsURL({ + params: { + clusterId: clusterStore.active.id + } + })), + isActive: (context) => !!context.cluster +}); 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 ea4ee5a571..fa76dde806 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-workspace-setting.tsx @@ -1,7 +1,5 @@ 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"; @@ -18,10 +16,7 @@ export class ClusterWorkspaceSetting extends React.Component { <>

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

this.onSubmit(v)} + dirty={true} + showValidationLine={true} /> + + Please provide a new workspace name (Press "Enter" to confirm or "Escape" to cancel) + + + ); + } +} + +commandRegistry.add({ + id: "workspace.addWorkspace", + title: "Workspace: Add workspace ...", + scope: "global", + action: () => CommandOverlay.open() +}); diff --git a/src/renderer/components/+workspaces/edit-workspace.tsx b/src/renderer/components/+workspaces/edit-workspace.tsx new file mode 100644 index 0000000000..3ab4b44d5a --- /dev/null +++ b/src/renderer/components/+workspaces/edit-workspace.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { WorkspaceStore, workspaceStore } from "../../../common/workspace-store"; +import { commandRegistry } from "../../../extensions/registries/command-registry"; +import { Input, InputValidator } from "../input"; +import { CommandOverlay } from "../command-palette/command-container"; + +const validateWorkspaceName: InputValidator = { + condition: ({ required }) => required, + message: () => `Workspace with this name already exists`, + validate: (value) => { + const current = workspaceStore.currentWorkspace; + + if (current.name === value.trim()) { + return true; + } + + return !workspaceStore.enabledWorkspacesList.find((workspace) => workspace.name === value); + } +}; + +interface EditWorkspaceState { + name: string; +} + +@observer +export class EditWorkspace extends React.Component<{}, EditWorkspaceState> { + + state: EditWorkspaceState = { + name: "" + }; + + componentDidMount() { + this.setState({name: workspaceStore.currentWorkspace.name}); + } + + onSubmit(name: string) { + if (name.trim() === "") { + return; + } + + workspaceStore.currentWorkspace.name = name; + CommandOverlay.close(); + } + + onChange(name: string) { + this.setState({name}); + } + + get name() { + return this.state.name; + } + + render() { + return ( + <> + this.onChange(v)} + onSubmit={(v) => this.onSubmit(v)} + dirty={true} + value={this.name} + showValidationLine={true} /> + + Please provide a new workspace name (Press "Enter" to confirm or "Escape" to cancel) + + + ); + } +} + +commandRegistry.add({ + id: "workspace.editCurrentWorkspace", + title: "Workspace: Edit current workspace ...", + scope: "global", + action: () => CommandOverlay.open(), + isActive: (context) => context.workspace?.id !== WorkspaceStore.defaultId +}); diff --git a/src/renderer/components/+workspaces/index.ts b/src/renderer/components/+workspaces/index.ts index db23faa3be..5b84fc9b00 100644 --- a/src/renderer/components/+workspaces/index.ts +++ b/src/renderer/components/+workspaces/index.ts @@ -1,2 +1 @@ -export * from "./workspaces.route"; export * from "./workspaces"; diff --git a/src/renderer/components/+workspaces/remove-workspace.tsx b/src/renderer/components/+workspaces/remove-workspace.tsx new file mode 100644 index 0000000000..9f66292447 --- /dev/null +++ b/src/renderer/components/+workspaces/remove-workspace.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { computed} from "mobx"; +import { WorkspaceStore, workspaceStore } from "../../../common/workspace-store"; +import { ConfirmDialog } from "../confirm-dialog"; +import { commandRegistry } from "../../../extensions/registries/command-registry"; +import { Select } from "../select"; +import { CommandOverlay } from "../command-palette/command-container"; + +@observer +export class RemoveWorkspace extends React.Component { + @computed get options() { + return workspaceStore.enabledWorkspacesList.filter((workspace) => workspace.id !== WorkspaceStore.defaultId).map((workspace) => { + return { value: workspace.id, label: workspace.name }; + }); + } + + onChange(id: string) { + const workspace = workspaceStore.enabledWorkspacesList.find((workspace) => workspace.id === id); + + if (!workspace ) { + return; + } + + CommandOverlay.close(); + ConfirmDialog.open({ + okButtonProps: { + label: `Remove Workspace`, + primary: false, + accent: true, + }, + ok: () => { + workspaceStore.removeWorkspace(workspace); + }, + message: ( +
+

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

+

+ All clusters within workspace will be cleared as well +

+
+ ), + }); + } + + render() { + return ( + editingWorkspace.name = v} - onKeyPress={(e) => this.onInputKeypress(e, workspaceId)} - validators={[isRequired, existenceValidator]} - autoFocus - /> - editingWorkspace.description = v} - onKeyPress={(e) => this.onInputKeypress(e, workspaceId)} - /> - this.saveWorkspace(workspaceId)} - /> - this.clearEditing(workspaceId)} - /> - - )} -
- ); - })} -
-
); diff --git a/src/renderer/components/+apps-releases/release-menu.tsx b/src/renderer/components/+apps-releases/release-menu.tsx index 3b260fc5ca..e482bb000a 100644 --- a/src/renderer/components/+apps-releases/release-menu.tsx +++ b/src/renderer/components/+apps-releases/release-menu.tsx @@ -42,7 +42,7 @@ export class HelmReleaseMenu extends React.Component { <> {hasRollback && ( - + Rollback )} diff --git a/src/renderer/components/+cluster/cluster-pie-charts.tsx b/src/renderer/components/+cluster/cluster-pie-charts.tsx index 1881d5dcce..d64efb6b36 100644 --- a/src/renderer/components/+cluster/cluster-pie-charts.tsx +++ b/src/renderer/components/+cluster/cluster-pie-charts.tsx @@ -143,7 +143,7 @@ export const ClusterPieCharts = observer(() => {
{cpuLimitsOverload && renderLimitWarning()} @@ -151,7 +151,7 @@ export const ClusterPieCharts = observer(() => {
{memoryLimitsOverload && renderLimitWarning()} @@ -159,7 +159,7 @@ export const ClusterPieCharts = observer(() => {
diff --git a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx index ab35505cc6..105ccc41dd 100644 --- a/src/renderer/components/+config-limit-ranges/limit-range-details.tsx +++ b/src/renderer/components/+config-limit-ranges/limit-range-details.tsx @@ -63,21 +63,21 @@ export class LimitRangeDetails extends React.Component { {containerLimits.length > 0 && - + { renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) } } {podLimits.length > 0 && - + { renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) } } {pvcLimits.length > 0 && - + { renderLimitDetails(pvcLimits, [Resource.STORAGE]) } diff --git a/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx b/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx index 032fa6eef2..95f50c935b 100644 --- a/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx +++ b/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx @@ -146,7 +146,7 @@ export class AddQuotaDialog extends React.Component {
this.quotaName = v.toLowerCase()} className="box grow" @@ -156,7 +156,7 @@ export class AddQuotaDialog extends React.Component { this.namespace = value} @@ -167,14 +167,14 @@ export class AddQuotaDialog extends React.Component { this.quotaInputValue = v} onKeyDown={this.onInputQuota} @@ -183,7 +183,7 @@ export class AddQuotaDialog extends React.Component {
diff --git a/src/renderer/components/+config-secrets/add-secret-dialog.tsx b/src/renderer/components/+config-secrets/add-secret-dialog.tsx index 5071c908bf..2fae17d30d 100644 --- a/src/renderer/components/+config-secrets/add-secret-dialog.tsx +++ b/src/renderer/components/+config-secrets/add-secret-dialog.tsx @@ -133,7 +133,7 @@ export class AddSecretDialog extends React.Component { this.addField(field)} /> @@ -146,7 +146,7 @@ export class AddSecretDialog extends React.Component {
{ multiLine maxRows={5} required={required} className="value" - placeholder={`Value`} + placeholder="Value" value={value} onChange={v => item.value = v} /> { this.name = v} /> diff --git a/src/renderer/components/+config-secrets/secret-details.tsx b/src/renderer/components/+config-secrets/secret-details.tsx index 92a58141ac..ab7bc59e46 100644 --- a/src/renderer/components/+config-secrets/secret-details.tsx +++ b/src/renderer/components/+config-secrets/secret-details.tsx @@ -69,7 +69,7 @@ export class SecretDetails extends React.Component { {!isEmpty(this.data) && ( <> - + { Object.entries(this.data).map(([name, value]) => { const revealSecret = this.revealSecret[name]; @@ -107,7 +107,7 @@ export class SecretDetails extends React.Component { }
diff --git a/src/renderer/components/+network-services/service-port-component.tsx b/src/renderer/components/+network-services/service-port-component.tsx index 6946137bde..77070390e0 100644 --- a/src/renderer/components/+network-services/service-port-component.tsx +++ b/src/renderer/components/+network-services/service-port-component.tsx @@ -37,7 +37,7 @@ export class ServicePortComponent extends React.Component { return (
- this.portForward() }> + this.portForward() }> {port.toString()} {this.waiting && ( diff --git a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx index f2c8388247..a0ede38ece 100644 --- a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx +++ b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx @@ -111,7 +111,7 @@ export class AddHelmRepoDialog extends React.Component { <> this.helmRepo.insecureSkipTlsVerify = v} /> @@ -120,12 +120,12 @@ export class AddHelmRepoDialog extends React.Component { {this.renderFileInput(`Cerificate file`, FileType.CertFile, AddHelmRepoDialog.certExtensions)} this.helmRepo.username = v} /> this.helmRepo.password = v} /> ); @@ -148,13 +148,13 @@ export class AddHelmRepoDialog extends React.Component {
this.helmRepo.name = v} /> this.helmRepo.url = v} /> @@ -162,7 +162,7 @@ export class AddHelmRepoDialog extends React.Component { More diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx index 56e881d9f5..b0442f45b6 100644 --- a/src/renderer/components/+preferences/preferences.tsx +++ b/src/renderer/components/+preferences/preferences.tsx @@ -122,7 +122,7 @@ export class Preferences extends React.Component {

HTTP Proxy

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

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

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

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

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

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

+ Cluster with Invalid Kubeconfig Detected! +

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

+

Do you want to remove the cluster now?

+
+
+
+ ), + { + id: notificationId, + timeout: 0 + } + ); +} + + From 4931f681e48564ba05e9ea14e53d92720ee71ab7 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 2 Mar 2021 08:07:16 -0500 Subject: [PATCH 121/219] log JSON api calls when in DEBUG mode (#2263) Signed-off-by: Sebastian Malton --- src/renderer/api/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 1a82ce45f8..7c2b55d0e1 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -1,11 +1,11 @@ import { JsonApi, JsonApiErrorParsed } from "./json-api"; import { KubeJsonApi } from "./kube-json-api"; import { Notifications } from "../components/notifications"; -import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars"; +import { apiKubePrefix, apiPrefix, isDebugging, isDevelopment } from "../../common/vars"; export const apiBase = new JsonApi({ apiBase: apiPrefix, - debug: isDevelopment, + debug: isDevelopment || isDebugging, }); export const apiKube = new KubeJsonApi({ apiBase: apiKubePrefix, From 25a7403f3c05ecf6083c00c97da9d8b671c9d4e6 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Wed, 3 Mar 2021 15:40:19 +0200 Subject: [PATCH 122/219] Render only secret name on pod details without access to secrets (#2244) * Render only secret name on pod details without access to secrets Signed-off-by: Lauri Nevala * Preserving layout when amount of secrets passed Signed-off-by: Alex Andreev * Refactor secrets to observable map Signed-off-by: Lauri Nevala Co-authored-by: Alex Andreev --- .../+workloads-pods/pod-details-secrets.scss | 4 +-- .../+workloads-pods/pod-details-secrets.tsx | 32 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/+workloads-pods/pod-details-secrets.scss b/src/renderer/components/+workloads-pods/pod-details-secrets.scss index d1ee08c51b..f20aced60d 100644 --- a/src/renderer/components/+workloads-pods/pod-details-secrets.scss +++ b/src/renderer/components/+workloads-pods/pod-details-secrets.scss @@ -1,7 +1,7 @@ .PodDetailsSecrets { - a { + > * { display: block; - margin-bottom: $margin; + margin-bottom: var(--margin); &:last-child { margin-bottom: 0; diff --git a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx b/src/renderer/components/+workloads-pods/pod-details-secrets.tsx index af1515c1b4..b2c7cd14c3 100644 --- a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-secrets.tsx @@ -13,33 +13,49 @@ interface Props { @observer export class PodDetailsSecrets extends Component { - @observable secrets: Secret[] = []; + @observable secrets: Map = observable.map(); @disposeOnUnmount secretsLoader = autorun(async () => { const { pod } = this.props; - this.secrets = await Promise.all( + const secrets = await Promise.all( pod.getSecrets().map(secretName => secretsApi.get({ name: secretName, namespace: pod.getNs(), })) ); + + secrets.forEach(secret => secret && this.secrets.set(secret.getName(), secret)); }); render() { + const { pod } = this.props; + return (
{ - this.secrets.map(secret => { - return ( - - {secret.getName()} - - ); + pod.getSecrets().map(secretName => { + const secret = this.secrets.get(secretName); + + if (secret) { + return this.renderSecretLink(secret); + } else { + return ( + {secretName} + ); + } }) }
); } + + protected renderSecretLink(secret: Secret) { + return ( + + {secret.getName()} + + ); + } } From bcaef7938669610ccbda6bbe973e70fd134e244d Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 3 Mar 2021 14:34:35 -0500 Subject: [PATCH 123/219] Cleanup add namespace dialog (#2261) - Remove mixing of async and promises - Remove unnecessary instance close method - Use condition call syntax Signed-off-by: Sebastian Malton --- .../+namespaces/add-namespace-dialog.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx index a3b30235fa..1ad2efae59 100644 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ b/src/renderer/components/+namespaces/add-namespace-dialog.tsx @@ -33,20 +33,18 @@ export class AddNamespaceDialog extends React.Component { this.namespace = ""; }; - close = () => { - AddNamespaceDialog.close(); - }; - addNamespace = async () => { const { namespace } = this; const { onSuccess, onError } = this.props; try { - await namespaceStore.create({ name: namespace }).then(onSuccess); - this.close(); + const created = await namespaceStore.create({ name: namespace }); + + onSuccess?.(created); + AddNamespaceDialog.close(); } catch (err) { Notifications.error(err); - onError && onError(err); + onError?.(err); } }; @@ -61,9 +59,9 @@ export class AddNamespaceDialog extends React.Component { className="AddNamespaceDialog" isOpen={AddNamespaceDialog.isOpen} onOpen={this.reset} - close={this.close} + close={AddNamespaceDialog.close} > - + Date: Thu, 4 Mar 2021 15:12:28 +0300 Subject: [PATCH 124/219] Fix: preventing to render on cluster refresh (#2253) * Removing @observer decorator from Signed-off-by: Alex Andreev * Add observer wrapper to Signed-off-by: Alex Andreev * Fix eslint claim Signed-off-by: Alex Andreev * Moving extension route renderers to components Signed-off-by: Alex Andreev * Clean up Signed-off-by: Alex Andreev * Removing external observables out from App render() Signed-off-by: Alex Andreev * Fetching hosted cluster inside Command Palette Signed-off-by: Alex Andreev * Setting route lists explicitly To avoid using observable data within tabRoutes arrays Signed-off-by: Alex Andreev * Review fixes Signed-off-by: Alex Andreev --- .../components/+config/config.route.ts | 19 +++++-- .../components/+network/network.route.ts | 15 ++++-- .../+pod-security-policies/index.ts | 1 - .../pod-security-policies.route.ts | 8 --- .../components/+storage/storage.route.ts | 13 +++-- .../+user-management/user-management.route.ts | 20 ++++--- .../+user-management/user-management.tsx | 4 +- .../components/+workloads/workloads.route.ts | 20 ++++--- src/renderer/components/app.tsx | 54 ++----------------- .../command-palette/command-container.tsx | 7 ++- .../components/layout/main-layout-header.tsx | 5 +- 11 files changed, 70 insertions(+), 96 deletions(-) delete mode 100644 src/renderer/components/+pod-security-policies/pod-security-policies.route.ts diff --git a/src/renderer/components/+config/config.route.ts b/src/renderer/components/+config/config.route.ts index 8ea637c505..d269455566 100644 --- a/src/renderer/components/+config/config.route.ts +++ b/src/renderer/components/+config/config.route.ts @@ -1,12 +1,21 @@ import { RouteProps } from "react-router"; -import { Config } from "./config"; import { IURLParams } from "../../../common/utils/buildUrl"; -import { configMapsURL } from "../+config-maps/config-maps.route"; +import { configMapsRoute, configMapsURL } from "../+config-maps/config-maps.route"; +import { hpaRoute } from "../+config-autoscalers"; +import { limitRangesRoute } from "../+config-limit-ranges"; +import { pdbRoute } from "../+config-pod-disruption-budgets"; +import { resourceQuotaRoute } from "../+config-resource-quotas"; +import { secretsRoute } from "../+config-secrets"; export const configRoute: RouteProps = { - get path() { - return Config.tabRoutes.map(({ routePath }) => routePath).flat(); - } + path: [ + configMapsRoute, + secretsRoute, + resourceQuotaRoute, + limitRangesRoute, + hpaRoute, + pdbRoute + ].map(route => route.path.toString()) }; export const configURL = (params?: IURLParams) => configMapsURL(params); diff --git a/src/renderer/components/+network/network.route.ts b/src/renderer/components/+network/network.route.ts index de36bfc77a..2e5d5ffcb5 100644 --- a/src/renderer/components/+network/network.route.ts +++ b/src/renderer/components/+network/network.route.ts @@ -1,12 +1,17 @@ import { RouteProps } from "react-router"; -import { Network } from "./network"; -import { servicesURL } from "../+network-services"; +import { endpointRoute } from "../+network-endpoints"; +import { ingressRoute } from "../+network-ingresses"; +import { networkPoliciesRoute } from "../+network-policies"; +import { servicesRoute, servicesURL } from "../+network-services"; import { IURLParams } from "../../../common/utils/buildUrl"; export const networkRoute: RouteProps = { - get path() { - return Network.tabRoutes.map(({ routePath }) => routePath).flat(); - } + path: [ + servicesRoute, + endpointRoute, + ingressRoute, + networkPoliciesRoute + ].map(route => route.path.toString()) }; export const networkURL = (params?: IURLParams) => servicesURL(params); diff --git a/src/renderer/components/+pod-security-policies/index.ts b/src/renderer/components/+pod-security-policies/index.ts index c9379d3381..223affa147 100644 --- a/src/renderer/components/+pod-security-policies/index.ts +++ b/src/renderer/components/+pod-security-policies/index.ts @@ -1,3 +1,2 @@ -export * from "./pod-security-policies.route"; export * from "./pod-security-policies"; export * from "./pod-security-policy-details"; diff --git a/src/renderer/components/+pod-security-policies/pod-security-policies.route.ts b/src/renderer/components/+pod-security-policies/pod-security-policies.route.ts deleted file mode 100644 index 8bee44985e..0000000000 --- a/src/renderer/components/+pod-security-policies/pod-security-policies.route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RouteProps } from "react-router"; -import { buildURL } from "../../../common/utils/buildUrl"; - -export const podSecurityPoliciesRoute: RouteProps = { - path: "/pod-security-policies" -}; - -export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path); diff --git a/src/renderer/components/+storage/storage.route.ts b/src/renderer/components/+storage/storage.route.ts index 6ad17a4fdf..174eaea080 100644 --- a/src/renderer/components/+storage/storage.route.ts +++ b/src/renderer/components/+storage/storage.route.ts @@ -1,12 +1,15 @@ import { RouteProps } from "react-router"; -import { volumeClaimsURL } from "../+storage-volume-claims"; -import { Storage } from "./storage"; +import { storageClassesRoute } from "../+storage-classes"; +import { volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims"; +import { volumesRoute } from "../+storage-volumes"; import { IURLParams } from "../../../common/utils/buildUrl"; export const storageRoute: RouteProps = { - get path() { - return Storage.tabRoutes.map(({ routePath }) => routePath).flat(); - } + path: [ + volumeClaimsRoute, + volumesRoute, + storageClassesRoute + ].map(route => route.path.toString()) }; export const storageURL = (params?: IURLParams) => volumeClaimsURL(params); diff --git a/src/renderer/components/+user-management/user-management.route.ts b/src/renderer/components/+user-management/user-management.route.ts index 9dc17cbbd8..3acebb7899 100644 --- a/src/renderer/components/+user-management/user-management.route.ts +++ b/src/renderer/components/+user-management/user-management.route.ts @@ -1,12 +1,5 @@ import type { RouteProps } from "react-router"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; -import { UserManagement } from "./user-management"; - -export const usersManagementRoute: RouteProps = { - get path() { - return UserManagement.tabRoutes.map(({ routePath }) => routePath).flat(); - } -}; // Routes export const serviceAccountsRoute: RouteProps = { @@ -18,6 +11,18 @@ export const rolesRoute: RouteProps = { export const roleBindingsRoute: RouteProps = { path: "/role-bindings" }; +export const podSecurityPoliciesRoute: RouteProps = { + path: "/pod-security-policies" +}; + +export const usersManagementRoute: RouteProps = { + path: [ + serviceAccountsRoute, + roleBindingsRoute, + rolesRoute, + podSecurityPoliciesRoute + ].map(route => route.path.toString()) +}; // Route params export interface IServiceAccountsRouteParams { @@ -34,3 +39,4 @@ export const usersManagementURL = (params?: IURLParams) => serviceAccountsURL(pa export const serviceAccountsURL = buildURL(serviceAccountsRoute.path); export const roleBindingsURL = buildURL(roleBindingsRoute.path); export const rolesURL = buildURL(rolesRoute.path); +export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path); diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index e851d50424..480808d6a1 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -5,9 +5,9 @@ import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { Roles } from "../+user-management-roles"; import { RoleBindings } from "../+user-management-roles-bindings"; import { ServiceAccounts } from "../+user-management-service-accounts"; -import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; +import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; import { namespaceUrlParam } from "../+namespaces/namespace.store"; -import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies"; +import { PodSecurityPolicies } from "../+pod-security-policies"; import { isAllowedResource } from "../../../common/rbac"; @observer diff --git a/src/renderer/components/+workloads/workloads.route.ts b/src/renderer/components/+workloads/workloads.route.ts index 44c43c5ef9..14a0bbb07d 100644 --- a/src/renderer/components/+workloads/workloads.route.ts +++ b/src/renderer/components/+workloads/workloads.route.ts @@ -1,13 +1,6 @@ import type { RouteProps } from "react-router"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; import { KubeResource } from "../../../common/rbac"; -import { Workloads } from "./workloads"; - -export const workloadsRoute: RouteProps = { - get path() { - return Workloads.tabRoutes.map(({ routePath }) => routePath).flat(); - } -}; // Routes export const overviewRoute: RouteProps = { @@ -35,6 +28,19 @@ export const cronJobsRoute: RouteProps = { path: "/cronjobs" }; +export const workloadsRoute: RouteProps = { + path: [ + overviewRoute, + podsRoute, + deploymentsRoute, + daemonSetsRoute, + statefulSetsRoute, + replicaSetsRoute, + jobsRoute, + cronJobsRoute + ].map(route => route.path.toString()) +}; + // Route params export interface IWorkloadsOverviewRouteParams { } diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 8b7f8a527c..f5ad684595 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -91,27 +91,15 @@ export class App extends React.Component { reaction(() => this.warningsTotal, (count: number) => { broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count); }), - - reaction(() => clusterPageMenuRegistry.getRootItems(), (rootItems) => { - this.generateExtensionTabLayoutRoutes(rootItems); - }, { - fireImmediately: true - }) ]); } + @observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL(); + @computed get warningsTotal(): number { return nodesStore.getWarningsCount() + eventStore.getWarningsCount(); } - get startURL() { - if (isAllowedResource(["events", "nodes", "pods"])) { - return clusterURL(); - } - - return workloadsURL(); - } - getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { const routes: TabLayoutRoute[] = []; @@ -152,38 +140,6 @@ export class App extends React.Component { }); } - @observable extensionRoutes: Map = new Map(); - - generateExtensionTabLayoutRoutes(rootItems: ClusterPageMenuRegistration[]) { - rootItems.forEach((menu, index) => { - let route = this.extensionRoutes.get(menu); - - if (!route) { - const tabRoutes = this.getTabLayoutRoutes(menu); - - if (tabRoutes.length > 0) { - const pageComponent = () => ; - - route = tab.routePath)}/>; - this.extensionRoutes.set(menu, route); - } else { - const page = clusterPageRegistry.getByPageTarget(menu.target); - - if (page) { - route = ; - this.extensionRoutes.set(menu, route); - } - } - } - }); - - for (const menu of this.extensionRoutes.keys()) { - if (!rootItems.includes(menu)) { - this.extensionRoutes.delete(menu); - } - } - } - renderExtensionRoutes() { return clusterPageRegistry.getItems().map((page, index) => { const menu = clusterPageMenuRegistry.getByPage(page); @@ -195,8 +151,6 @@ export class App extends React.Component { } render() { - const cluster = getHostedCluster(); - return ( @@ -215,7 +169,7 @@ export class App extends React.Component { {this.renderExtensionTabLayoutRoutes()} {this.renderExtensionRoutes()} - + @@ -228,7 +182,7 @@ export class App extends React.Component { - + ); diff --git a/src/renderer/components/command-palette/command-container.tsx b/src/renderer/components/command-palette/command-container.tsx index 34ab71ee67..c6950bc5b9 100644 --- a/src/renderer/components/command-palette/command-container.tsx +++ b/src/renderer/components/command-palette/command-container.tsx @@ -10,7 +10,6 @@ import { CommandDialog } from "./command-dialog"; import { CommandRegistration, commandRegistry } from "../../../extensions/registries/command-registry"; import { clusterStore } from "../../../common/cluster-store"; import { workspaceStore } from "../../../common/workspace-store"; -import { Cluster } from "../../../main/cluster"; export type CommandDialogEvent = { component: React.ReactElement @@ -29,7 +28,7 @@ export class CommandOverlay { } @observer -export class CommandContainer extends React.Component<{cluster?: Cluster}> { +export class CommandContainer extends React.Component<{ clusterId?: string }> { @observable.ref commandComponent: React.ReactElement; private escHandler(event: KeyboardEvent) { @@ -56,8 +55,8 @@ export class CommandContainer extends React.Component<{cluster?: Cluster}> { } componentDidMount() { - if (this.props.cluster) { - subscribeToBroadcast(`command-palette:run-action:${this.props.cluster.id}`, (event, commandId: string) => { + if (this.props.clusterId) { + subscribeToBroadcast(`command-palette:run-action:${this.props.clusterId}`, (event, commandId: string) => { const command = this.findCommandById(commandId); if (command) { diff --git a/src/renderer/components/layout/main-layout-header.tsx b/src/renderer/components/layout/main-layout-header.tsx index 6753f8d262..570c04a1f9 100644 --- a/src/renderer/components/layout/main-layout-header.tsx +++ b/src/renderer/components/layout/main-layout-header.tsx @@ -1,3 +1,4 @@ +import { observer } from "mobx-react"; import React from "react"; import { clusterSettingsURL } from "../+cluster-settings"; @@ -11,7 +12,7 @@ interface Props { className?: string } -export function MainLayoutHeader({ cluster, className }: Props) { +export const MainLayoutHeader = observer(({ cluster, className }: Props) => { return (
{cluster.name} @@ -29,4 +30,4 @@ export function MainLayoutHeader({ cluster, className }: Props) { />
); -} +}); From 35e60654875cae6623af665ea133f1855fbf30ad Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 4 Mar 2021 08:38:07 -0500 Subject: [PATCH 125/219] Broadcast update available only after downloading update (#2232) * broadcast update available only after downloading update * remove unnecessary downloads, force silent and restart modes if user chooses to install update immediately * add app.exit() for installing (fixes bug on Windows) Signed-off-by: Sebastian Malton --- src/main/app-updater.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 9893abfa81..8f31df8b57 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -3,8 +3,8 @@ import logger from "./logger"; import { isDevelopment, isTestEnv } from "../common/vars"; import { delay } from "../common/utils"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; -import { ipcMain } from "electron"; import { once } from "lodash"; +import { app, ipcMain } from "electron"; let installVersion: null | string = null; @@ -12,13 +12,11 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd if (arg.doUpdate) { if (arg.now) { logger.info(`${AutoUpdateLogPrefix}: User chose to update now`); - autoUpdater.on("update-downloaded", () => autoUpdater.quitAndInstall()); - autoUpdater.downloadUpdate().catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error })); + autoUpdater.quitAndInstall(true, true); + app.exit(); // this is needed for the installer not to fail on windows. } else { logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`); autoUpdater.autoInstallOnAppQuit = true; - autoUpdater.downloadUpdate() - .catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download update`, { error })); } } else { logger.info(`${AutoUpdateLogPrefix}: User chose not to update`); @@ -39,10 +37,10 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 autoUpdater.autoInstallOnAppQuit = false; autoUpdater - .on("update-available", (args: UpdateInfo) => { + .on("update-available", (info: UpdateInfo) => { if (autoUpdater.autoInstallOnAppQuit) { // a previous auto-update loop was completed with YES+LATER, check if same version - if (installVersion === args.version) { + if (installVersion === info.version) { // same version, don't broadcast return; } @@ -54,10 +52,14 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 * didn't ask for. */ autoUpdater.autoInstallOnAppQuit = false; - installVersion = args.version; + installVersion = info.version; + autoUpdater.downloadUpdate() + .catch(error => logger.error(`${AutoUpdateLogPrefix}: failed to download update`, { error: String(error) })); + }) + .on("update-downloaded", (info: UpdateInfo) => { try { - const backchannel = `auto-update:${args.version}`; + const backchannel = `auto-update:${info.version}`; ipcMain.removeAllListeners(backchannel); // only one handler should be present @@ -68,8 +70,8 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 listener: handleAutoUpdateBackChannel, verifier: areArgsUpdateAvailableToBackchannel, }); - logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: args.version }); - broadcastMessage(UpdateAvailableChannel, backchannel, args); + logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version }); + broadcastMessage(UpdateAvailableChannel, backchannel, info); } catch (error) { logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); installVersion = undefined; From d9daa94c61f6d5d3c0cced49255db2d806b694c6 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 5 Mar 2021 12:26:36 -0500 Subject: [PATCH 126/219] Release 4.2.0-alpha.1 (#2285) --- package.json | 2 +- static/RELEASE_NOTES.md | 44 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3639366298..faaedb77cf 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.2.0-alpha.0", + "version": "4.2.0-alpha.1", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index 8c43fcda18..a8d802316c 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,7 +2,49 @@ Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! -## 4.1.0 (current version) +## 4.2.0-alpha.1 (current version) + +- Add lens:// protocol handling with a routing mechanism +- Notify about update after it has been downloaded +- Add persistent volumes info to storage class submenu +- Fix: proper sorting resources by age column +- Fix: events sorting with compact=true is broken + +## 4.1.4 + +- Ignore clusters with invalid kubeconfig +- Render only secret name on pod details without access to secrets +- Pass Lens wslenvs to terminal session on Windows +- Prevent top-level re-rendering on cluster refresh +- Extract chart version ignoring numbers in chart name +- The select all checkbox should not select disabled items +- Fix: Pdb should have policy group +- Fix: kubectl rollout not exiting properly on Lens terminal + +## 4.1.3 + +- Don't reset selected namespaces to defaults in case of "All namespaces" on page reload +- Fix loading all namespaces for users with limited cluster access +- Display environment variables coming from secret in pod details +- Fix deprecated helm chart filtering +- Fix RoleBindings Namespace and Bindings field not displaying the correct data +- Fix RoleBindingDetails not rendering the name of the role binding +- Fix auto update on quit with newer version + +## 4.1.2 + +**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more. + +- Fix an issue where a cluster gets stuck on "Connecting ..." phase +- Fix an issue with auto-update + +## 4.1.1 + +- Fix an issue where users with rights to a single namespace were seeing an empty dashboard +- Windows: use SHELL for terminal if set +- Keep highlighted table row during navigation in the details panel + +## 4.1.0 **Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more. From e69d008d59a93cb72e8fa1eb1e4d72d85e8715a2 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 5 Mar 2021 12:26:58 -0500 Subject: [PATCH 127/219] Update copyright year (#2286) Signed-off-by: Sebastian Malton --- LICENSE | 8 ++++---- mkdocs.yml | 2 +- package.json | 2 +- src/extensions/npm/extensions/package.json | 2 +- src/main/menu.ts | 3 ++- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/LICENSE b/LICENSE index b7a6d74205..37da2d2e07 100644 --- a/LICENSE +++ b/LICENSE @@ -1,12 +1,12 @@ -Copyright (c) 2020 Mirantis, Inc. +Copyright (c) 2021 Mirantis, Inc. Portions of this software are licensed as follows: -* All content residing under the "docs/" directory of this repository, if that +* All content residing under the "docs/" directory of this repository, if that directory exists, is licensed under "Creative Commons: CC BY-SA 4.0 license". -* All third party components incorporated into the Lens Software are licensed +* All third party components incorporated into the Lens Software are licensed under the original license provided by the owner of the applicable component. -* Content outside of the above mentioned directories or restrictions above is +* Content outside of the above mentioned directories or restrictions above is available under the "MIT" license as defined below. Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/mkdocs.yml b/mkdocs.yml index dd72a677ea..337e0c4b11 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,7 +5,7 @@ site_url: https://docs.k8slens.dev docs_dir: docs/ repo_name: GitHub repo_url: https://github.com/lensapp/lens -copyright: Copyright © 2020 Mirantis Inc. - All rights reserved. +copyright: Copyright © 2021 Mirantis Inc. - All rights reserved. edit_uri: "" nav: - Overview: README.md diff --git a/package.json b/package.json index faaedb77cf..661bd6d0b9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Lens - The Kubernetes IDE", "version": "4.2.0-alpha.1", "main": "static/build/main.js", - "copyright": "© 2020, Mirantis, Inc.", + "copyright": "© 2021, Mirantis, Inc.", "license": "MIT", "author": { "name": "Mirantis, Inc.", diff --git a/src/extensions/npm/extensions/package.json b/src/extensions/npm/extensions/package.json index 5b2b4475ae..77fcc25a11 100644 --- a/src/extensions/npm/extensions/package.json +++ b/src/extensions/npm/extensions/package.json @@ -3,7 +3,7 @@ "productName": "Lens extensions", "description": "Lens - The Kubernetes IDE: extensions", "version": "0.0.0", - "copyright": "© 2020, Mirantis, Inc.", + "copyright": "© 2021, Mirantis, Inc.", "license": "MIT", "main": "dist/src/extensions/extension-api.js", "types": "dist/src/extensions/extension-api.d.ts", diff --git a/src/main/menu.ts b/src/main/menu.ts index 57c6ccab5e..142c776878 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -11,6 +11,7 @@ import { menuRegistry } from "../extensions/registries/menu-registry"; import logger from "./logger"; import { exitApp } from "./exit-app"; import { broadcastMessage } from "../common/ipc"; +import * as packageJson from "../../package.json"; export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; @@ -26,7 +27,7 @@ export function showAbout(browserWindow: BrowserWindow) { `Electron: ${process.versions.electron}`, `Chrome: ${process.versions.chrome}`, `Node: ${process.versions.node}`, - `Copyright 2020 Mirantis, Inc.`, + packageJson.copyright, ]; dialog.showMessageBoxSync(browserWindow, { From a5621b71af4a7a907f5b128ec8efade9419d2307 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 8 Mar 2021 08:12:27 -0500 Subject: [PATCH 128/219] Add more information to a helm chart's ID (#2288) Signed-off-by: Sebastian Malton --- src/renderer/api/endpoints/helm-charts.api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index 02b5b0dbee..093adf9aef 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -83,7 +83,7 @@ export class HelmChart { tillerVersion?: string; getId() { - return `${this.apiVersion}/${this.name}@${this.getAppVersion()}`; + return `${this.repo}:${this.apiVersion}/${this.name}@${this.getAppVersion()}+${this.digest}`; } getName() { From f287b8a3d0dc2a5d60ebe4171c84b3b5419afc36 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 8 Mar 2021 08:15:04 -0500 Subject: [PATCH 129/219] Add workflow to notify on new PR conflicts (#2287) Signed-off-by: Sebastian Malton --- .github/workflows/maintenance.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/maintenance.yml diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml new file mode 100644 index 0000000000..dfa16b99c7 --- /dev/null +++ b/.github/workflows/maintenance.yml @@ -0,0 +1,22 @@ +name: "Maintenance" +on: + # So that PRs touching the same files as the push are updated + push: + # So that the `dirtyLabel` is removed if conflicts are resolve + # We recommend `pull_request_target` so that github secrets are available. + # In `pull_request` we wouldn't be able to change labels of fork PRs + pull_request_target: + types: [synchronize] + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: check if prs are dirty + uses: eps1lon/actions-label-merge-conflict@releases/2.x + with: + dirtyLabel: "PR: needs rebase" + removeOnDirtyLabel: "PR: ready to ship" + repoToken: "${{ secrets.GITHUB_TOKEN }}" + commentOnDirty: "This pull request has conflicts, please resolve those before we can evaluate the pull request." + commentOnClean: "Conflicts have been resolved. A maintainer will review the pull request shortly." From 1b0f56f417462538052036ea386c79e15e4453e1 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 9 Mar 2021 17:57:55 +0200 Subject: [PATCH 130/219] VSCode launch configurations for debugging (#2281) * Add configuration for debugging integration tests Signed-off-by: Alex Culliere * Add launch configuration for debugging main process Signed-off-by: Alex Culliere * Continue polishing debug configurations Signed-off-by: Alex Culliere * Remove unnecessary dependency Signed-off-by: Alex Culliere * Add debug configuration for unit tests + cleanup vscode tasks Signed-off-by: Alex Culliere * Update src/renderer/bootstrap.tsx Add `await` keyword to debugger attachment Co-authored-by: chh <1474479+chenhunghan@users.noreply.github.com> Signed-off-by: Alex Culliere * Update src/renderer/bootstrap.tsx Co-authored-by: chh <1474479+chenhunghan@users.noreply.github.com> Signed-off-by: Alex Culliere * Use existing variable to wait for chrome debugger attachment Signed-off-by: Alex Culliere * Update src/renderer/bootstrap.tsx Use available helper function instead of raw promise Co-authored-by: Sebastian Malton Signed-off-by: Alex Culliere * Import delay utility Signed-off-by: Alex Culliere * Move async function to async context (attaching debugger) Signed-off-by: Alex Culliere Co-authored-by: Alex Culliere Co-authored-by: chh <1474479+chenhunghan@users.noreply.github.com> Co-authored-by: Sebastian Malton --- .gitignore | 1 - .vscode/launch.json | 57 ++++++++++++++++++++++++++++++++++++++ .vscode/tasks.json | 18 ++++++++++++ package.json | 3 +- src/renderer/bootstrap.tsx | 15 +++++++++- 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json diff --git a/.gitignore b/.gitignore index 88d48ab5ee..d018f3b251 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,3 @@ types/extension-renderer-api.d.ts extensions/*/dist docs/extensions/api site/ -.vscode/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..7d7cef13d8 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,57 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Main Process", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "protocol": "inspector", + "preLaunchTask": "compile-dev", + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", + "windows": { + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" + }, + "runtimeArgs": [ + "--remote-debugging-port=9223", + "--inspect", + "." + ], + "outputCapture": "std" + }, + { + "name": "Renderer Process", + "type": "pwa-chrome", + "request": "attach", + "port": 9223, + "webRoot": "${workspaceFolder}", + "timeout": 30000 + }, + { + "name": "Integration Tests", + "type": "node", + "request": "launch", + "console": "integratedTerminal", + "runtimeArgs": [ + "${workspaceFolder}/node_modules/.bin/jest", + "--runInBand", + "integration" + ], + }, + { + "name": "Unit Tests", + "type": "node", + "request": "launch", + "internalConsoleOptions": "openOnSessionStart", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "args": [ + "--env=jsdom", + "-i", + "src" + ] + } + ], +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..03a7b306f7 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "type": "shell", + "group": "build", + "command": "yarn", + "args": [ + "debug-build" + ], + "problemMatcher": [], + "label": "compile-dev", + "detail": "Compiles main and extension types" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 661bd6d0b9..be3e2896bf 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "scripts": { "dev": "concurrently -k \"yarn run dev-run -C\" yarn:dev:*", "dev-build": "concurrently yarn:compile:*", - "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"", + "debug-build": "concurrently yarn:compile:main yarn:compile:extension-types", + "dev-run": "nodemon --watch static/build/main.js --exec \"electron --remote-debugging-port=9223 --inspect .\"", "dev:main": "yarn run compile:main --watch", "dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts", "dev:extension-types": "yarn run compile:extension-types --watch --progress", diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 4d46011442..0d412e257c 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -8,7 +8,8 @@ import * as ReactRouterDom from "react-router-dom"; import { render, unmountComponentAtNode } from "react-dom"; import { clusterStore } from "../common/cluster-store"; import { userStore } from "../common/user-store"; -import { isMac } from "../common/vars"; +import { delay } from "../common/utils"; +import { isMac, isDevelopment } from "../common/vars"; import { workspaceStore } from "../common/workspace-store"; import * as LensExtensions from "../extensions/extension-api"; import { extensionDiscovery } from "../extensions/extension-discovery"; @@ -19,6 +20,17 @@ import { App } from "./components/app"; import { LensApp } from "./lens-app"; import { themeStore } from "./theme.store"; +/** + * If this is a development buid, wait a second to attach + * Chrome Debugger to renderer process + * https://stackoverflow.com/questions/52844870/debugging-electron-renderer-process-with-vscode + */ +async function attachChromeDebugger() { + if (isDevelopment) { + await delay(1000); + } +} + type AppComponent = React.ComponentType & { init?(): Promise; }; @@ -35,6 +47,7 @@ export { export async function bootstrap(App: AppComponent) { const rootElem = document.getElementById("app"); + await attachChromeDebugger(); rootElem.classList.toggle("is-mac", isMac); extensionLoader.init(); From 20709d63e9a3ce43bc718457652d2a5614a145ea Mon Sep 17 00:00:00 2001 From: Violetta <38247153+vshakirova@users.noreply.github.com> Date: Wed, 10 Mar 2021 12:31:24 +0400 Subject: [PATCH 131/219] Add image sha256 in pod inspector (#2252) Signed-off-by: vshakirova --- .../components/+workloads-pods/pod-details-container.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/+workloads-pods/pod-details-container.tsx b/src/renderer/components/+workloads-pods/pod-details-container.tsx index b1596c60f5..7d409a2d2d 100644 --- a/src/renderer/components/+workloads-pods/pod-details-container.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-container.tsx @@ -53,6 +53,7 @@ export class PodDetailsContainer extends React.Component { const state = status ? Object.keys(status.state)[0] : ""; const lastState = status ? Object.keys(status.lastState)[0] : ""; const ready = status ? status.ready : ""; + const imageId = status? status.imageID : ""; const liveness = pod.getLivenessProbe(container); const readiness = pod.getReadinessProbe(container); const startup = pod.getStartupProbe(container); @@ -84,7 +85,7 @@ export class PodDetailsContainer extends React.Component { } - {image} + {imagePullPolicy && imagePullPolicy !== "IfNotPresent" && From 805268a9d13b224761cf28ddbc75372b724fba27 Mon Sep 17 00:00:00 2001 From: pashevskii <53330707+pashevskii@users.noreply.github.com> Date: Wed, 10 Mar 2021 12:47:26 +0400 Subject: [PATCH 132/219] Apply custom filters after selecting source (#2241) Signed-off-by: Pavel Ashevskiy Co-authored-by: Pavel Ashevskiy --- .../item-object-list/item-list-layout.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index adec4bef27..15c833d8aa 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -197,17 +197,12 @@ export class ItemListLayout extends React.Component { return filters.reduce((items, filter) => filter(items), items); } - @computed get allItems() { - const { filterItems, store } = this.props; - - return this.applyFilters(filterItems, store.items); - } - @computed get items() { - const { allItems, filters, filterCallbacks } = this; - const filterItems: ItemsFilter[] = []; + const {filters, filterCallbacks } = this; const filterGroups = groupBy(filters, ({ type }) => type); + const filterItems: ItemsFilter[] = []; + Object.entries(filterGroups).forEach(([type, filtersGroup]) => { const filterCallback = filterCallbacks[type]; @@ -216,9 +211,9 @@ export class ItemListLayout extends React.Component { } }); - const items = this.props.items ?? allItems; + const items = this.props.items ?? this.props.store.items; - return this.applyFilters(filterItems, items); + return this.applyFilters(filterItems.concat(this.props.filterItems), items); } @autobind() From b3ce845d6252ace27d2fd912036163bdff2ad62b Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 10 Mar 2021 09:13:27 -0500 Subject: [PATCH 133/219] Fix font-size on (#2273) Signed-off-by: Sebastian Malton --- src/renderer/components/app.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/app.scss b/src/renderer/components/app.scss index 037b088efe..6144b1f265 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -151,7 +151,7 @@ code { vertical-align: middle; border-radius: $radius; font-family: $font-monospace; - font-size: calc($font-size * .9); + font-size: calc(var(--font-size) * .9); color: #b4b5b4; &.block { From 2e8f94b3ebcfcedb1a10d3fad1cce8d8c25fdada Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 10 Mar 2021 09:30:48 -0500 Subject: [PATCH 134/219] Fix update available notification able to show twice (#2295) Signed-off-by: Sebastian Malton --- src/renderer/ipc/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 544cefbf78..5f5e04d9d2 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -4,7 +4,6 @@ import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, Upda import { Notifications, notificationsStore } from "../components/notifications"; import { Button } from "../components/button"; import { isMac } from "../../common/vars"; -import * as uuid from "uuid"; import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler"; function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void { @@ -31,7 +30,7 @@ function RenderYesButtons(props: { backchannel: string, notificationId: string } } function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, updateInfo]: UpdateAvailableFromMain): void { - const notificationId = uuid.v4(); + const notificationId = `update-available:${updateInfo.version}`; Notifications.info( ( From 713ec8c69ddab13438f53666145d8b23633f9e74 Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Thu, 11 Mar 2021 02:56:12 -0500 Subject: [PATCH 135/219] Basic workspace overview (#2047) * basic workspace overview Signed-off-by: Jim Ehrismann * css tweaks for landing page as a PageLayout Signed-off-by: Jim Ehrismann * address review comments Signed-off-by: Jim Ehrismann * more review comment addressing, added overview to workspace command palette Signed-off-by: Jim Ehrismann * added back the landing page startup hint Signed-off-by: Jim Ehrismann * refactoring as per review comments Signed-off-by: Jim Ehrismann * added original landing page back only for default workspace with no clusters Signed-off-by: Jim Ehrismann * Workspace overview layout tweaks (#2302) * tweaks workspace overview layout Signed-off-by: Jari Kolehmainen * cluster settings on top Signed-off-by: Jari Kolehmainen * header logo for add cluster page Signed-off-by: Jari Kolehmainen * tweak landing page Signed-off-by: Jari Kolehmainen * combine left menu icons Signed-off-by: Jari Kolehmainen * always show bottom status bar Signed-off-by: Jari Kolehmainen * tweak Signed-off-by: Jari Kolehmainen * integration test fixes Signed-off-by: Jari Kolehmainen * change cluster menu Signed-off-by: Jari Kolehmainen * first attempt to fix integration test Signed-off-by: Jim Ehrismann * lint Signed-off-by: Jim Ehrismann * get selectors right for integration test Signed-off-by: Jim Ehrismann Co-authored-by: Jim Ehrismann Co-authored-by: Jim Ehrismann * address review comments, and rebased to master Signed-off-by: Jim Ehrismann Co-authored-by: Jari Kolehmainen Co-authored-by: Jim Ehrismann --- integration/__tests__/cluster-pages.tests.ts | 3 +- integration/helpers/minikube.ts | 2 +- integration/helpers/utils.ts | 12 ++- src/main/cluster.ts | 2 +- .../components/+add-cluster/add-cluster.tsx | 2 +- .../+cluster-settings/cluster-settings.tsx | 2 +- .../+landing-page/landing-page.scss | 65 +++------------- .../components/+landing-page/landing-page.tsx | 57 +++++++------- .../+landing-page/workspace-cluster-menu.tsx | 74 ++++++++++++++++++ .../+landing-page/workspace-cluster.store.ts | 72 ++++++++++++++++++ .../+landing-page/workspace-overview.scss | 32 ++++++++ .../+landing-page/workspace-overview.tsx | 75 +++++++++++++++++++ .../components/+workspaces/workspaces.tsx | 10 +++ .../cluster-manager/clusters-menu.scss | 30 ++------ .../cluster-manager/clusters-menu.tsx | 40 +++++----- .../item-object-list/item-list-layout.tsx | 3 +- .../components/layout/page-layout.scss | 5 +- 17 files changed, 357 insertions(+), 129 deletions(-) create mode 100644 src/renderer/components/+landing-page/workspace-cluster-menu.tsx create mode 100644 src/renderer/components/+landing-page/workspace-cluster.store.ts create mode 100644 src/renderer/components/+landing-page/workspace-overview.scss create mode 100644 src/renderer/components/+landing-page/workspace-overview.tsx diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index e73774f86a..1662f33f4a 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -25,6 +25,7 @@ describe("Lens cluster pages", () => { let clusterAdded = false; const addCluster = async () => { await utils.clickWhatsNew(app); + await utils.clickWelcomeNotification(app); await addMinikubeCluster(app); await waitForMinikubeDashboard(app); await app.client.click('a[href="/nodes"]'); @@ -345,7 +346,7 @@ describe("Lens cluster pages", () => { } }); - it(`shows a logs for a pod`, async () => { + it(`shows a log for a pod`, async () => { expect(clusterAdded).toBe(true); // Go to Pods page await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index 67ef0145d5..e2ca0e0f23 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -39,7 +39,7 @@ export function minikubeReady(testNamespace: string): boolean { } export async function addMinikubeCluster(app: Application) { - await app.client.click("div.add-cluster"); + await app.client.click("button.add-button"); await app.client.waitUntilTextExists("div", "Select kubeconfig file"); await app.client.click("div.Select__control"); // show the context drop-down list await app.client.waitUntilTextExists("div", "minikube"); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index f96c9124e2..1db9af1c4d 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -47,7 +47,17 @@ export async function appStart() { export async function clickWhatsNew(app: Application) { await app.client.waitUntilTextExists("h1", "What's new?"); await app.client.click("button.primary"); - await app.client.waitUntilTextExists("h1", "Welcome"); + await app.client.waitUntilTextExists("h2", "default"); +} + +export async function clickWelcomeNotification(app: Application) { + const itemsText = await app.client.$("div.info-panel").getText(); + + if (itemsText === "0 item") { + // welcome notification should be present, dismiss it + await app.client.waitUntilTextExists("div.message", "Welcome!"); + await app.client.click("i.Icon.close"); + } } type AsyncPidGetter = () => Promise; diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 198d24c2f9..88ae49d123 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -252,7 +252,7 @@ export class Cluster implements ClusterModel, ClusterState { * Kubernetes version */ get version(): string { - return String(this.metadata?.version) || ""; + return String(this.metadata?.version || ""); } constructor(model: ClusterModel) { diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 38d03482e8..dc5cdf4d4c 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -352,7 +352,7 @@ export class AddCluster extends React.Component { return ( - Add Clusters

}> +

Add Clusters

} showOnTop={true}>

Add Clusters from Kubeconfig

{this.renderInfo()} {this.renderKubeConfigSource()} diff --git a/src/renderer/components/+cluster-settings/cluster-settings.tsx b/src/renderer/components/+cluster-settings/cluster-settings.tsx index 0cde390c47..4ec8e1ccdd 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/+cluster-settings/cluster-settings.tsx @@ -59,7 +59,7 @@ export class ClusterSettings extends React.Component { ); return ( - + diff --git a/src/renderer/components/+landing-page/landing-page.scss b/src/renderer/components/+landing-page/landing-page.scss index 4874b37c72..ea1eb664ef 100644 --- a/src/renderer/components/+landing-page/landing-page.scss +++ b/src/renderer/components/+landing-page/landing-page.scss @@ -1,60 +1,15 @@ -.LandingPage { - width: 100%; - height: 100%; +.PageLayout.LandingOverview { + --width: 100%; + --height: 100%; text-align: center; - z-index: 0; - &::after { - content: ""; - background: url(../../components/icon/crane.svg) no-repeat; - background-position: 0 35%; - background-size: 85%; - background-clip: content-box; - opacity: .75; - top: 0; - left: 0; - bottom: 0; - right: 0; - position: absolute; - z-index: -1; - .theme-light & { - opacity: 0.2; + + .content-wrapper { + + .content { + margin: unset; + max-width: unset; } } - - .startup-hint { - $bgc: $mainBackground; - $arrowSize: 10px; - - position: absolute; - left: 0; - top: 25px; - margin: $padding; - padding: $padding * 2; - width: 320px; - background: $bgc; - color: $textColorAccent; - filter: drop-shadow(0 0px 2px #ffffff33); - - &:before { - content: ""; - position: absolute; - width: 0; - height: 0; - border-top: $arrowSize solid transparent; - border-bottom: $arrowSize solid transparent; - border-right: $arrowSize solid $bgc; - right: 100%; - } - - .theme-light & { - filter: drop-shadow(0 0px 2px #777); - background: white; - - &:before { - border-right-color: white; - } - } - } -} \ 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 index ea0b24bb87..15e22b2c9f 100644 --- a/src/renderer/components/+landing-page/landing-page.tsx +++ b/src/renderer/components/+landing-page/landing-page.tsx @@ -1,40 +1,47 @@ import "./landing-page.scss"; import React from "react"; -import { observable } from "mobx"; +import { computed, observable } from "mobx"; import { observer } from "mobx-react"; import { clusterStore } from "../../../common/cluster-store"; -import { workspaceStore } from "../../../common/workspace-store"; +import { Workspace, workspaceStore } from "../../../common/workspace-store"; +import { WorkspaceOverview } from "./workspace-overview"; +import { PageLayout } from "../layout/page-layout"; +import { Notifications } from "../notifications"; +import { Icon } from "../icon"; @observer export class LandingPage extends React.Component { @observable showHint = true; + get workspace(): Workspace { + return workspaceStore.currentWorkspace; + } + + @computed + get clusters() { + return clusterStore.getByWorkspaceId(this.workspace.id); + } + + componentDidMount() { + const noClustersInScope = !this.clusters.length; + const showStartupHint = this.showHint; + + if (showStartupHint && noClustersInScope) { + Notifications.info(<>Welcome!

Get started by associating one or more clusters to Lens

, { + timeout: 30_000, + id: "landing-welcome" + }); + } + } + render() { - const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId); - const noClustersInScope = !clusters.length; - const showStartupHint = this.showHint && noClustersInScope; + const showBackButton = this.clusters.length > 0; + const header = <>

{this.workspace.name}

; return ( -
- {showStartupHint && ( -
this.showHint = false}> -

This is the quick launch menu.

-

- Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button. -

-
- )} - {noClustersInScope && ( -
-

- Welcome! -

-

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

-
- )} -
+ + + ); } } diff --git a/src/renderer/components/+landing-page/workspace-cluster-menu.tsx b/src/renderer/components/+landing-page/workspace-cluster-menu.tsx new file mode 100644 index 0000000000..8c2935c3e1 --- /dev/null +++ b/src/renderer/components/+landing-page/workspace-cluster-menu.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { ClusterItem, WorkspaceClusterStore } from "./workspace-cluster.store"; +import { autobind, cssNames } from "../../utils"; +import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; +import { MenuItem } from "../menu"; +import { Icon } from "../icon"; +import { Workspace } from "../../../common/workspace-store"; +import { clusterSettingsURL } from "../+cluster-settings"; +import { navigate } from "../../navigation"; + +interface Props extends MenuActionsProps { + clusterItem: ClusterItem; + workspace: Workspace; + workspaceClusterStore: WorkspaceClusterStore; +} + +export class WorkspaceClusterMenu extends React.Component { + + @autobind() + remove() { + const { clusterItem, workspaceClusterStore } = this.props; + + return workspaceClusterStore.remove(clusterItem); + } + + @autobind() + gotoSettings() { + const { clusterItem } = this.props; + + navigate(clusterSettingsURL({ + params: { + clusterId: clusterItem.id + } + })); + } + + @autobind() + renderRemoveMessage() { + const { clusterItem, workspace } = this.props; + + return ( +

Remove cluster {clusterItem.name} from workspace {workspace.name}?

+ ); + } + + + renderContent() { + const { toolbar } = this.props; + + return ( + <> + + + Settings + + + ); + } + + render() { + const { clusterItem: { cluster: { isManaged } }, className, ...menuProps } = this.props; + + return ( + + {this.renderContent()} + + ); + } +} diff --git a/src/renderer/components/+landing-page/workspace-cluster.store.ts b/src/renderer/components/+landing-page/workspace-cluster.store.ts new file mode 100644 index 0000000000..24834927b3 --- /dev/null +++ b/src/renderer/components/+landing-page/workspace-cluster.store.ts @@ -0,0 +1,72 @@ +import { WorkspaceId } from "../../../common/workspace-store"; +import { Cluster } from "../../../main/cluster"; +import { clusterStore } from "../../../common/cluster-store"; +import { ItemObject, ItemStore } from "../../item.store"; +import { autobind } from "../../utils"; + +export class ClusterItem implements ItemObject { + constructor(public cluster: Cluster) {} + + get name() { + return this.cluster.name; + } + + get distribution() { + return this.cluster.metadata?.distribution?.toString() ?? "unknown"; + } + + get version() { + return this.cluster.version; + } + + get connectionStatus() { + return this.cluster.online ? "connected" : "disconnected"; + } + + getName() { + return this.name; + } + + get id() { + return this.cluster.id; + } + + get clusterId() { + return this.cluster.id; + } + + getId() { + return this.id; + } +} + +/** an ItemStore of the clusters belonging to a given workspace */ +@autobind() +export class WorkspaceClusterStore extends ItemStore { + + workspaceId: WorkspaceId; + + constructor(workspaceId: WorkspaceId) { + super(); + this.workspaceId = workspaceId; + } + + loadAll() { + return this.loadItems( + () => ( + clusterStore + .getByWorkspaceId(this.workspaceId) + .filter(cluster => cluster.enabled) + .map(cluster => new ClusterItem(cluster)) + ) + ); + } + + async remove(clusterItem: ClusterItem) { + const { cluster: { isManaged, id: clusterId }} = clusterItem; + + if (!isManaged) { + return super.removeItem(clusterItem, () => clusterStore.removeById(clusterId)); + } + } +} diff --git a/src/renderer/components/+landing-page/workspace-overview.scss b/src/renderer/components/+landing-page/workspace-overview.scss new file mode 100644 index 0000000000..fa156bf74f --- /dev/null +++ b/src/renderer/components/+landing-page/workspace-overview.scss @@ -0,0 +1,32 @@ +.WorkspaceOverview { + max-height: 50%; + .Table { + padding-bottom: 60px; + } + .TableCell { + display: flex; + align-items: left; + + &.cluster-icon { + align-items: center; + flex-grow: 0.2; + padding: 0; + } + + &.connected { + color: var(--colorSuccess); + } + } + + .TableCell.status { + flex: 0.1; + } + + .TableCell.distribution { + flex: 0.2; + } + + .TableCell.version { + flex: 0.2; + } +} diff --git a/src/renderer/components/+landing-page/workspace-overview.tsx b/src/renderer/components/+landing-page/workspace-overview.tsx new file mode 100644 index 0000000000..c095abb31b --- /dev/null +++ b/src/renderer/components/+landing-page/workspace-overview.tsx @@ -0,0 +1,75 @@ +import "./workspace-overview.scss"; + +import React, { Component } from "react"; +import { Workspace } from "../../../common/workspace-store"; +import { observer } from "mobx-react"; +import { ItemListLayout } from "../item-object-list/item-list-layout"; +import { ClusterItem, WorkspaceClusterStore } from "./workspace-cluster.store"; +import { navigate } from "../../navigation"; +import { clusterViewURL } from "../cluster-manager/cluster-view.route"; +import { WorkspaceClusterMenu } from "./workspace-cluster-menu"; +import { kebabCase } from "lodash"; +import { addClusterURL } from "../+add-cluster"; + +interface Props { + workspace: Workspace; +} + +enum sortBy { + name = "name", + distribution = "distribution", + version = "version", + online = "online" +} + +@observer +export class WorkspaceOverview extends Component { + + showCluster = ({ clusterId }: ClusterItem) => { + navigate(clusterViewURL({ params: { clusterId } })); + }; + + render() { + const { workspace } = this.props; + const workspaceClusterStore = new WorkspaceClusterStore(workspace.id); + + workspaceClusterStore.loadAll(); + + return ( + Clusters
} + isClusterScoped + isSearchable={false} + isSelectable={false} + className="WorkspaceOverview" + store={workspaceClusterStore} + sortingCallbacks={{ + [sortBy.name]: (item: ClusterItem) => item.name, + [sortBy.distribution]: (item: ClusterItem) => item.distribution, + [sortBy.version]: (item: ClusterItem) => item.version, + [sortBy.online]: (item: ClusterItem) => item.connectionStatus, + }} + renderTableHeader={[ + { title: "Name", className: "name", sortBy: sortBy.name }, + { title: "Distribution", className: "distribution", sortBy: sortBy.distribution }, + { title: "Version", className: "version", sortBy: sortBy.version }, + { title: "Status", className: "status", sortBy: sortBy.online }, + ]} + renderTableContents={(item: ClusterItem) => [ + item.name, + item.distribution, + item.version, + { title: item.connectionStatus, className: kebabCase(item.connectionStatus) } + ]} + onDetails={this.showCluster} + addRemoveButtons={{ + addTooltip: "Add Cluster", + onAdd: () => navigate(addClusterURL()), + }} + renderItemMenu={(clusterItem: ClusterItem) => ( + + )} + /> + ); + } +} diff --git a/src/renderer/components/+workspaces/workspaces.tsx b/src/renderer/components/+workspaces/workspaces.tsx index 7e3b9647f2..3bea020ef3 100644 --- a/src/renderer/components/+workspaces/workspaces.tsx +++ b/src/renderer/components/+workspaces/workspaces.tsx @@ -14,6 +14,7 @@ import { clusterViewURL } from "../cluster-manager/cluster-view.route"; @observer export class ChooseWorkspace extends React.Component { + private static overviewActionId = "__overview__"; private static addActionId = "__add__"; private static removeActionId = "__remove__"; private static editActionId = "__edit__"; @@ -23,6 +24,8 @@ export class ChooseWorkspace extends React.Component { return { value: workspace.id, label: workspace.name }; }); + options.push({ value: ChooseWorkspace.overviewActionId, label: "Show current workspace overview ..." }); + options.push({ value: ChooseWorkspace.addActionId, label: "Add workspace ..." }); if (options.length > 1) { @@ -37,6 +40,13 @@ export class ChooseWorkspace extends React.Component { } onChange(id: string) { + if (id === ChooseWorkspace.overviewActionId) { + navigate(landingURL()); // overview of active workspace. TODO: change name from landing + CommandOverlay.close(); + + return; + } + if (id === ChooseWorkspace.addActionId) { CommandOverlay.open(); diff --git a/src/renderer/components/cluster-manager/clusters-menu.scss b/src/renderer/components/cluster-manager/clusters-menu.scss index b6a1e66d1a..b24daa4522 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.scss +++ b/src/renderer/components/cluster-manager/clusters-menu.scss @@ -27,14 +27,16 @@ } } - > .add-cluster { + > .WorkspaceMenu { position: relative; + margin-bottom: $margin; .Icon { + margin-bottom: $margin * 1.5; border-radius: $radius; padding: $padding / 3; - color: $addClusterIconColor; - background: #ffffff66; + color: #ffffff66; + background: unset; cursor: pointer; &.active { @@ -43,28 +45,10 @@ &:hover { box-shadow: none; - background: #ffffff; + color: #ffffff; + background-color: unset; } } - - .Badge { - $boxSize: 17px; - - position: absolute; - bottom: 0px; - transform: translateX(-50%) translateY(50%); - font-size: $font-size-small; - line-height: $boxSize; - min-width: $boxSize; - min-height: $boxSize; - text-align: center; - color: white; - background: $colorSuccess; - font-weight: normal; - border-radius: $radius; - padding: 0; - pointer-events: none; - } } > .extensions { diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index d963438136..dd36e529e1 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -6,26 +6,24 @@ import { requestMain } from "../../../common/ipc"; import type { Cluster } from "../../../main/cluster"; import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd"; import { observer } from "mobx-react"; -import { userStore } from "../../../common/user-store"; import { ClusterId, clusterStore } from "../../../common/cluster-store"; import { workspaceStore } from "../../../common/workspace-store"; import { ClusterIcon } from "../cluster-icon"; import { Icon } from "../icon"; import { autobind, cssNames, IClassName } from "../../utils"; -import { Badge } from "../badge"; import { isActiveRoute, navigate } from "../../navigation"; import { addClusterURL } from "../+add-cluster"; import { clusterSettingsURL } from "../+cluster-settings"; import { landingURL } from "../+landing-page"; -import { Tooltip } from "../tooltip"; import { ConfirmDialog } from "../confirm-dialog"; import { clusterViewURL } from "./cluster-view.route"; import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries"; import { clusterDisconnectHandler } from "../../../common/cluster-ipc"; import { commandRegistry } from "../../../extensions/registries/command-registry"; import { CommandOverlay } from "../command-palette/command-container"; -import { computed } from "mobx"; +import { computed, observable } from "mobx"; import { Select } from "../select"; +import { Menu, MenuItem } from "../menu"; interface Props { className?: IClassName; @@ -33,14 +31,12 @@ interface Props { @observer export class ClustersMenu extends React.Component { + @observable workspaceMenuVisible = false; + showCluster = (clusterId: ClusterId) => { navigate(clusterViewURL({ params: { clusterId } })); }; - addCluster = () => { - navigate(addClusterURL()); - }; - showContextMenu = (cluster: Cluster) => { const { Menu, MenuItem } = remote; const menu = new Menu(); @@ -111,7 +107,6 @@ export class ClustersMenu extends React.Component { render() { const { className } = this.props; - const { newContexts } = userStore; const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId); const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled); const activeClusterId = clusterStore.activeCluster; @@ -149,14 +144,25 @@ export class ClustersMenu extends React.Component {
-
- - Add Cluster - - - {newContexts.size > 0 && ( - - )} + +
+ + this.workspaceMenuVisible = true} + close={() => this.workspaceMenuVisible = false} + toggleEvent="click" + > + navigate(addClusterURL())} data-test-id="add-cluster-menu-item"> + Add Cluster + + navigate(landingURL())} data-test-id="workspace-overview-menu-item"> + Workspace Overview + +
{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { 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 15c833d8aa..bb4e384c27 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -318,6 +318,7 @@ export class ItemListLayout extends React.Component { } renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { + const { isSearchable, searchFilters } = this.props; const { title, filters, search, info } = placeholders; return ( @@ -327,7 +328,7 @@ export class ItemListLayout extends React.Component { {this.isReady && info}
{filters} - {search} + {isSearchable && searchFilters && search} ); } diff --git a/src/renderer/components/layout/page-layout.scss b/src/renderer/components/layout/page-layout.scss index c975ea3305..d7fd0a8544 100644 --- a/src/renderer/components/layout/page-layout.scss +++ b/src/renderer/components/layout/page-layout.scss @@ -17,7 +17,8 @@ left: 0; top: 0; right: 0; - bottom: 0; + bottom: 24px; + height: unset; background-color: $mainBackground; // adds extra space for traffic-light top buttons (mac only) @@ -73,4 +74,4 @@ box-shadow: 0 0 0 1px $borderFaintColor; } } -} \ No newline at end of file +} From 5c6a6e14f59c0ff9a750c41fa699a8be521d1a37 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Thu, 11 Mar 2021 18:25:06 +0200 Subject: [PATCH 136/219] Allow to define the path of the shell in app preferences (#2194) Co-authored-by: Sebastian Malton --- src/common/user-store.ts | 1 + src/main/shell-session.ts | 7 +++--- .../components/+preferences/preferences.tsx | 23 ++++++++++++++++++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/common/user-store.ts b/src/common/user-store.ts index b0294d9e5a..3df9125224 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -20,6 +20,7 @@ export interface UserStoreModel { export interface UserPreferences { httpsProxy?: string; + shell?: string; colorTheme?: string; allowUntrustedCAs?: boolean; allowTelemetry?: boolean; diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index 10a2f9ed47..a8c6a36e72 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -105,10 +105,11 @@ export class ShellSession extends EventEmitter { protected async getShellEnv() { const env = JSON.parse(JSON.stringify(await shellEnv())); const pathStr = [this.kubectlBinDir, this.helmBinDir, process.env.PATH].join(path.delimiter); + const shell = userStore.preferences.shell || process.env.SHELL || process.env.PTYSHELL; if(isWindows) { env["SystemRoot"] = process.env.SystemRoot; - env["PTYSHELL"] = process.env.SHELL || "powershell.exe"; + env["PTYSHELL"] = shell || "powershell.exe"; env["PATH"] = pathStr; env["LENS_SESSION"] = "true"; const lensWslEnv = "KUBECONFIG/up:LENS_SESSION/u"; @@ -118,8 +119,8 @@ export class ShellSession extends EventEmitter { } else { env["WSLENV"] = lensWslEnv; } - } else if(typeof(process.env.SHELL) != "undefined") { - env["PTYSHELL"] = process.env.SHELL; + } else if(shell !== undefined) { + env["PTYSHELL"] = shell; env["PATH"] = pathStr; } else { env["PTYSHELL"] = ""; // blank runs the system default shell diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx index b0442f45b6..2493599b2a 100644 --- a/src/renderer/components/+preferences/preferences.tsx +++ b/src/renderer/components/+preferences/preferences.tsx @@ -6,6 +6,7 @@ import { action, computed, observable } from "mobx"; import { Icon } from "../icon"; import { Select, SelectOption } from "../select"; import { userStore } from "../../../common/user-store"; +import { isWindows } from "../../../common/vars"; import { HelmRepo, repoManager } from "../../../main/helm/helm-repo-manager"; import { Input } from "../input"; import { Checkbox } from "../checkbox"; @@ -25,6 +26,7 @@ export class Preferences extends React.Component { @observable helmRepos: HelmRepo[] = []; @observable helmAddedRepos = observable.map(); @observable httpProxy = userStore.preferences.httpsProxy || ""; + @observable shell = userStore.preferences.shell || ""; @computed get themeOptions(): SelectOption[] { return themeStore.themes.map(theme => ({ @@ -109,6 +111,15 @@ export class Preferences extends React.Component { render() { const { preferences } = userStore; const header =

Preferences

; + let defaultShell = process.env.SHELL || process.env.PTYSHELL; + + if (!defaultShell) { + if (isWindows) { + defaultShell = "powershell.exe"; + } else { + defaultShell = "System default shell"; + } + } return ( @@ -130,7 +141,17 @@ export class Preferences extends React.Component { Proxy is used only for non-cluster communication. - +

Terminal Shell

+ this.shell = v} + onBlur={() => preferences.shell = this.shell} + /> + + The path of the shell that the terminal uses. +

Helm

From 51715b6a8c87b1923584db44d3ec6453771df738 Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Thu, 11 Mar 2021 16:01:50 -0500 Subject: [PATCH 137/219] properly load workspace cluster store outside of render() (#2320) also minor bulletproofing for cluster version string Signed-off-by: Jim Ehrismann --- src/main/cluster.ts | 2 +- .../+landing-page/workspace-overview.tsx | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 88ae49d123..8920568c73 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -252,7 +252,7 @@ export class Cluster implements ClusterModel, ClusterState { * Kubernetes version */ get version(): string { - return String(this.metadata?.version || ""); + return String(this.metadata?.version ?? ""); } constructor(model: ClusterModel) { diff --git a/src/renderer/components/+landing-page/workspace-overview.tsx b/src/renderer/components/+landing-page/workspace-overview.tsx index c095abb31b..96b8f3a791 100644 --- a/src/renderer/components/+landing-page/workspace-overview.tsx +++ b/src/renderer/components/+landing-page/workspace-overview.tsx @@ -10,7 +10,6 @@ import { clusterViewURL } from "../cluster-manager/cluster-view.route"; import { WorkspaceClusterMenu } from "./workspace-cluster-menu"; import { kebabCase } from "lodash"; import { addClusterURL } from "../+add-cluster"; - interface Props { workspace: Workspace; } @@ -24,6 +23,12 @@ enum sortBy { @observer export class WorkspaceOverview extends Component { + private workspaceClusterStore = new WorkspaceClusterStore(this.props.workspace.id); + + componentDidMount() { + this.workspaceClusterStore.loadAll(); + } + showCluster = ({ clusterId }: ClusterItem) => { navigate(clusterViewURL({ params: { clusterId } })); @@ -31,18 +36,15 @@ export class WorkspaceOverview extends Component { render() { const { workspace } = this.props; - const workspaceClusterStore = new WorkspaceClusterStore(workspace.id); - - workspaceClusterStore.loadAll(); return ( Clusters
} + renderHeaderTitle="Clusters" isClusterScoped isSearchable={false} isSelectable={false} className="WorkspaceOverview" - store={workspaceClusterStore} + store={this.workspaceClusterStore} sortingCallbacks={{ [sortBy.name]: (item: ClusterItem) => item.name, [sortBy.distribution]: (item: ClusterItem) => item.distribution, @@ -67,7 +69,7 @@ export class WorkspaceOverview extends Component { onAdd: () => navigate(addClusterURL()), }} renderItemMenu={(clusterItem: ClusterItem) => ( - + )} /> ); From 65088712099bd96662d312a6d0bc4b03e7acd912 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 12 Mar 2021 08:45:32 -0500 Subject: [PATCH 138/219] Add horizontal scrolling to NamespaceSelect (#2276) --- .../+namespaces/namespace-select.scss | 30 +++++++++++++++++-- .../+namespaces/namespace-select.tsx | 16 +++++++++- src/renderer/components/select/select.tsx | 5 ++-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/renderer/components/+namespaces/namespace-select.scss b/src/renderer/components/+namespaces/namespace-select.scss index f56b8005cc..33293a6d7a 100644 --- a/src/renderer/components/+namespaces/namespace-select.scss +++ b/src/renderer/components/+namespaces/namespace-select.scss @@ -4,6 +4,23 @@ } } +.GradientValueContainer { + width: 8px; + height: var(--font-size); + position: absolute; + z-index: 20; + + &.front { + left: 0px; + background: linear-gradient(to right, var(--contentColor) 0px, transparent); + } + + &.back { + right: 0px; + background: linear-gradient(to left, var(--contentColor) 0px, transparent); + } +} + .NamespaceSelect { @include namespaceSelectCommon; @@ -11,12 +28,19 @@ &__placeholder { width: 100%; white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; + overflow: scroll; + margin-left: -8px; + padding-left: 8px; + margin-right: -8px; + padding-right: 8px; + + &::-webkit-scrollbar { + display: none; + } } } } .NamespaceSelectMenu { @include namespaceSelectCommon; -} \ No newline at end of file +} diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 775cace4d3..d398f91f0a 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -8,6 +8,7 @@ import { cssNames } from "../../utils"; import { Icon } from "../icon"; import { namespaceStore } from "./namespace.store"; import { kubeWatchApi } from "../../api/kube-watch-api"; +import { components, ValueContainerProps } from "react-select"; interface Props extends SelectProps { showIcons?: boolean; @@ -21,6 +22,16 @@ const defaultProps: Partial = { showClusterOption: false, }; +function GradientValueContainer({children, ...rest}: ValueContainerProps) { + return ( + +
+ {children} +
+ + ); +} + @observer export class NamespaceSelect extends React.Component { static defaultProps = defaultProps as object; @@ -64,7 +75,9 @@ export class NamespaceSelect extends React.Component { }; render() { - const { className, showIcons, customizeOptions, ...selectProps } = this.props; + const { className, showIcons, customizeOptions, components = {}, ...selectProps } = this.props; + + components.ValueContainer ??= GradientValueContainer; return ( +
+ this.loadRepos()}/> +
+ {Array.from(this.addedRepos).map(([name, repo]) => { + const tooltipId = `message-${name}`; + + return ( + + {name} + this.removeRepo(repo)} + tooltip="Remove" + /> + + {repo.url} + + + ); + })} +
+
+ ); + } +} diff --git a/src/renderer/components/+preferences/kubectl-binaries.tsx b/src/renderer/components/+preferences/kubectl-binaries.tsx index aebe2e7063..f373c101c6 100644 --- a/src/renderer/components/+preferences/kubectl-binaries.tsx +++ b/src/renderer/components/+preferences/kubectl-binaries.tsx @@ -24,15 +24,12 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre return ( <> -

Kubectl Binary

+ preferences.downloadKubectlBinaries = downloadKubectlBinaries} /> - - Download kubectl binaries matching to Kubernetes cluster version. - (); @observable httpProxy = userStore.preferences.httpsProxy || ""; @observable shell = userStore.preferences.shell || ""; @@ -35,79 +29,6 @@ export class Preferences extends React.Component { })); } - @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(String(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

; @@ -122,110 +43,110 @@ export class Preferences extends React.Component { } return ( - -

Color Theme

- preferences.colorTheme = value} + /> + +
+

Proxy

+ + this.httpProxy = v} + onBlur={() => preferences.httpsProxy = this.httpProxy} + /> + + Proxy is used only for non-cluster communication. + -

HTTP Proxy

- this.httpProxy = v} - onBlur={() => preferences.httpsProxy = this.httpProxy} - /> - - Proxy is used only for non-cluster communication. - -

Terminal Shell

- this.shell = v} - onBlur={() => preferences.shell = this.shell} - /> - - The path of the shell that the terminal uses. - - + + 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! + +
+
+

Terminal Shell

+ + this.shell = v} + onBlur={() => preferences.shell = this.shell} + /> + + The path of the shell that the terminal uses. + +
+
+

Start-up

+ + preferences.openAtLogin = v} + /> +
+ -

Helm

-
- - - - -
- ); - })} -
- +
+
+

Extensions

+
+ {appPreferenceRegistry.getItems().map(({ title, components: { Hint, Input } }, index) => { + return ( +
+

{title}

+ + + + +
+ ); + })} +
+ + )}/> ); } } diff --git a/src/renderer/components/cluster-manager/bottom-bar.scss b/src/renderer/components/cluster-manager/bottom-bar.scss index a40146ad97..0f290eaf82 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.scss +++ b/src/renderer/components/cluster-manager/bottom-bar.scss @@ -4,7 +4,7 @@ background-color: var(--blue); padding: 0 2px; - height: 22px; + height: var(--bottom-bar-height); #current-workspace { font-size: var(--font-size-small); diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index accc72ef40..f05d6b109d 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -1,4 +1,6 @@ .ClusterManager { + --bottom-bar-height: 22px; + display: grid; grid-template-areas: "menu main" "menu main" "bottom-bar bottom-bar"; grid-template-rows: auto 1fr min-content; diff --git a/src/renderer/components/layout/page-layout.scss b/src/renderer/components/layout/page-layout.scss index d7fd0a8544..c4629f0dbe 100644 --- a/src/renderer/components/layout/page-layout.scss +++ b/src/renderer/components/layout/page-layout.scss @@ -1,17 +1,35 @@ .PageLayout { - $spacing: $padding * 2; --width: 60%; - --max-width: 1000px; - --min-width: 570px; + --nav-width: 180px; + --nav-column-width: 30vw; + --spacing: calc(var(--unit) * 2); + --wrapper-padding: calc(var(--spacing) * 2); + --header-height: 64px; + --header-height-mac: 80px; position: relative; width: 100%; height: 100%; display: grid !important; grid-template-rows: min-content 1fr; + grid-template-columns: 1fr; + + &.showNavigation { + --width: 70%; + + grid-template-columns: var(--nav-column-width) 1fr; + + > .content-wrapper { + > .content { + width: 100%; + padding-left: 1px; // Fix visual content crop + padding-right: calc(var(--nav-column-width) - var(--nav-width)); + } + } + } // covers whole app view area - &.top { + &.showOnTop { position: fixed !important; // allow to cover ClustersMenu z-index: 1; left: 0; @@ -19,37 +37,44 @@ right: 0; bottom: 24px; height: unset; - background-color: $mainBackground; + background-color: var(--mainBackground); // adds extra space for traffic-light top buttons (mac only) .is-mac & > .header { - padding-top: $spacing * 2; + height: var(--header-height-mac); + padding-top: calc(var(--spacing) * 2); } } > .header { position: sticky; - padding: $spacing; - background-color: $layoutTabsBackground; + padding: var(--spacing); + background-color: var(--layoutTabsBackground); + height: var(--header-height); + grid-column-start: 1; + grid-column-end: 4; } - > .content-wrapper { - overflow: auto; - padding: $spacing * 2; + > .content-navigation { display: flex; - flex-direction: column; + justify-content: flex-end; + overflow-y: auto; + margin-top: 32px; - > .content { - flex: 1; - margin: 0 auto; - width: var(--width); - min-width: var(--min-width); - max-width: var(--max-width); + ul.TreeView { + width: var(--nav-width); + padding-right: 24px; } } - h2:not(:first-of-type) { - margin-top: $spacing; + > .content-wrapper { + padding: 32px; + overflow: auto; + + > .content { + width: var(--width); + margin: 0 auto; + } } p { @@ -57,21 +82,49 @@ } a { - color: $colorInfo; + color: var(--colorInfo); } .SubTitle { text-transform: none; margin-bottom: 0 !important; - - + * + .hint { - margin-top: -$padding / 2; - } } .Select { &__control { - box-shadow: 0 0 0 1px $borderFaintColor; + box-shadow: 0 0 0 1px var(--borderFaintColor); + } + } + + section { + display: flex; + flex-direction: column; + margin-bottom: var(--spacing); + + > :not(:last-child) { + margin-bottom: var(--spacing); + } + + h1, h2 { + color: var(--textColorAccent); + } + + h1 { + font-size: x-large; + border-bottom: 1px solid var(--borderFaintColor); + padding-bottom: var(--padding); + } + + h2 { + font-size: large; + } + + small.hint { + margin-top: calc(var(--unit) * -1.5); + } + + .SubTitle { + margin-top: 0; } } } diff --git a/src/renderer/components/layout/page-layout.tsx b/src/renderer/components/layout/page-layout.tsx index ce7e1d54c2..b437a53d56 100644 --- a/src/renderer/components/layout/page-layout.tsx +++ b/src/renderer/components/layout/page-layout.tsx @@ -5,6 +5,7 @@ import { observer } from "mobx-react"; import { autobind, cssNames, IClassName } from "../../utils"; import { Icon } from "../icon"; import { navigation } from "../../navigation"; +import { NavigationTree, RecursiveTreeView } from "../tree-view"; export interface PageLayoutProps extends React.DOMAttributes { className?: IClassName; @@ -14,6 +15,7 @@ export interface PageLayoutProps extends React.DOMAttributes { provideBackButtonNavigation?: boolean; contentGaps?: boolean; showOnTop?: boolean; // covers whole app view + navigation?: NavigationTree[]; back?: (evt: React.MouseEvent | KeyboardEvent) => void; } @@ -57,9 +59,9 @@ export class PageLayout extends React.Component { render() { const { contentClass, header, headerClass, provideBackButtonNavigation, - contentGaps, showOnTop, children, ...elemProps + contentGaps, showOnTop, navigation, children, ...elemProps } = this.props; - const className = cssNames("PageLayout", { top: showOnTop }, this.props.className); + const className = cssNames("PageLayout", { showOnTop, showNavigation: navigation }, this.props.className); return (
@@ -73,8 +75,13 @@ export class PageLayout extends React.Component { /> )}
-
-
+ { navigation && ( + + )} +
+
{children}
diff --git a/src/renderer/components/scroll-spy/__tests__/scroll-spy.test.tsx b/src/renderer/components/scroll-spy/__tests__/scroll-spy.test.tsx new file mode 100644 index 0000000000..512d3251ba --- /dev/null +++ b/src/renderer/components/scroll-spy/__tests__/scroll-spy.test.tsx @@ -0,0 +1,186 @@ +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { render, waitFor } from "@testing-library/react"; +import { ScrollSpy } from "../scroll-spy"; +import { RecursiveTreeView } from "../../tree-view"; + +const observe = jest.fn(); + +Object.defineProperty(window, "IntersectionObserver", { + writable: true, + value: jest.fn().mockImplementation(() => ({ + observe, + unobserve: jest.fn(), + })), +}); + +describe("", () => { + it("renders w/o errors", () => { + const { container } = render( ( +
+
+

Application

+
+
+ )}/>); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("calls intersection observer", () => { + render( ( +
+
+

Application

+
+
+ )}/>); + + expect(observe).toHaveBeenCalled(); + }); + + it("renders dataTree component", async () => { + const { queryByTestId } = render( ( +
+ +
+

Application

+
+
+ )}/>); + + await waitFor(() => { + expect(queryByTestId("TreeView")).toBeInTheDocument(); + }); + }); + + it("throws if no sections founded", () => { + // Prevent writing to stderr during this render. + const err = console.error; + + console.error = jest.fn(); + + expect(() => render( ( +
+ Content +
+ )}/>)).toThrow(); + + // Restore writing to stderr. + console.error = err; + }); +}); + + +describe(" dataTree inside ", () => { + it("contains links to all sections", async () => { + const { queryByTitle } = render( ( +
+ +
+

Application

+
+

Appearance

+
+
+

Theme

+
description
+
+
+
+ )}/>); + + await waitFor(() => { + expect(queryByTitle("Application")).toBeInTheDocument(); + expect(queryByTitle("Appearance")).toBeInTheDocument(); + expect(queryByTitle("Theme")).toBeInTheDocument(); + }); + }); + + it("not showing links to sections without id", async () => { + const { queryByTitle } = render( ( +
+ +
+

Application

+
+

Kubectl

+
+
+

Appearance

+
+
+
+ )}/>); + + await waitFor(() => { + expect(queryByTitle("Application")).toBeInTheDocument(); + expect(queryByTitle("Appearance")).toBeInTheDocument(); + expect(queryByTitle("Kubectl")).not.toBeInTheDocument(); + }); + }); + + it("expands parent sections", async () => { + const { queryByTitle } = render( ( +
+ +
+

Application

+
+

Appearance

+
+
+

Theme

+
description
+
+
+
+

Kubernetes

+
+

Kubectl

+
+
+
+ )}/>); + + await waitFor(() => { + expect(queryByTitle("Application")).toHaveAttribute("aria-expanded"); + expect(queryByTitle("Kubernetes")).toHaveAttribute("aria-expanded"); + }); + + // console.log(prettyDOM()); + }); + + it("skips sections without headings", async () => { + const { queryByTitle } = render( ( +
+ +
+

Application

+
+

Appearance

+
+
+

Theme

+
+
+
+ )}/>); + + await waitFor(() => { + expect(queryByTitle("Application")).toBeInTheDocument(); + expect(queryByTitle("appearance")).not.toBeInTheDocument(); + expect(queryByTitle("Appearance")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/renderer/components/scroll-spy/scroll-spy.tsx b/src/renderer/components/scroll-spy/scroll-spy.tsx new file mode 100644 index 0000000000..565eabc9ba --- /dev/null +++ b/src/renderer/components/scroll-spy/scroll-spy.tsx @@ -0,0 +1,96 @@ +import { observer } from "mobx-react"; +import React, { useEffect, useRef, useState } from "react"; +import { useMutationObserver } from "../../hooks"; +import { NavigationTree } from "../tree-view"; + +interface Props extends React.DOMAttributes { + render: (data: NavigationTree[]) => JSX.Element + htmlFor?: string // Id of the element to put observers on + rootMargin?: string // https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#creating_an_intersection_observer +} + +export const ScrollSpy = observer(({ render, htmlFor, rootMargin = "0px 0px -100% 0px" }: Props) => { + const parent = useRef(); + const sections = useRef>(); + const [tree, setTree] = useState([]); + const [activeElementId, setActiveElementId] = useState(""); + + const setSections = () => { + sections.current = parent.current.querySelectorAll("section"); + + if (!sections.current.length) { + throw new Error("No
tag founded! Content should be placed inside
elements to activate navigation."); + } + }; + + const getSectionsParentElement = () => { + return sections.current?.[0].parentElement; + }; + + const updateNavigation = () => { + setTree(getNavigation(getSectionsParentElement())); + }; + + const getNavigation = (element: Element) => { + const sections = element.querySelectorAll(":scope > section"); // Searching only direct children of an element. Impossible without :scope + const children: NavigationTree[] = []; + + sections.forEach(section => { + const id = section.getAttribute("id"); + const parentId = section.parentElement.id; + const name = section.querySelector("h1, h2, h3, h4, h5, h6")?.textContent; + const selected = id === activeElementId; + + if (!name || !id) { + return; + } + + children.push({ + id, + parentId, + name, + selected, + children: getNavigation(section) + }); + }); + + return children; + }; + + const handleIntersect = ([entry]: IntersectionObserverEntry[]) => { + if (entry.isIntersecting) { + setActiveElementId(entry.target.closest("section[id]").id); + } + }; + + const observeSections = () => { + const options: IntersectionObserverInit = { + root: document.getElementById(htmlFor) || getSectionsParentElement(), + rootMargin + }; + + sections.current.forEach((section) => { + const observer = new IntersectionObserver(handleIntersect, options); + const target = section.querySelector("section") || section; + + observer.observe(target); + }); + }; + + useEffect(() => { + setSections(); + observeSections(); + }, []); + + useEffect(() => { + updateNavigation(); + }, [activeElementId]); + + useMutationObserver(getSectionsParentElement(), updateNavigation); + + return ( +
+ {render(tree)} +
+ ); +}); diff --git a/src/renderer/components/tree-view/index.ts b/src/renderer/components/tree-view/index.ts new file mode 100644 index 0000000000..4514f03621 --- /dev/null +++ b/src/renderer/components/tree-view/index.ts @@ -0,0 +1 @@ +export * from "./tree-view"; diff --git a/src/renderer/components/tree-view/tree-view.scss b/src/renderer/components/tree-view/tree-view.scss new file mode 100644 index 0000000000..2817d5ddb7 --- /dev/null +++ b/src/renderer/components/tree-view/tree-view.scss @@ -0,0 +1,27 @@ +.TreeView { + .MuiTypography-body1 { + font-size: var(--font-size); + color: var(--textColorAccent); + } + + .MuiTreeItem-root { + > .MuiTreeItem-content .MuiTreeItem-label { + border-radius: 4px; + border: 1px solid transparent; + } + + &.selected { + > .MuiTreeItem-content .MuiTreeItem-label { + border-color: var(--blue); + font-weight: bold; + } + } + + // Make inner component selected state invisible + &.Mui-selected, &.Mui-selected:focus { + > .MuiTreeItem-content .MuiTreeItem-label { + background-color: transparent; + } + } + } +} \ No newline at end of file diff --git a/src/renderer/components/tree-view/tree-view.tsx b/src/renderer/components/tree-view/tree-view.tsx new file mode 100644 index 0000000000..450b0021aa --- /dev/null +++ b/src/renderer/components/tree-view/tree-view.tsx @@ -0,0 +1,100 @@ +import "./tree-view.scss"; + +import React, { useEffect, useRef } from "react"; +import { Icon } from "../icon"; +import TreeView from "@material-ui/lab/TreeView"; +import TreeItem from "@material-ui/lab/TreeItem"; +import { cssNames } from "../../utils"; + +import _ from "lodash"; +import getDeepDash from "deepdash"; + +const deepDash = getDeepDash(_); + +export interface NavigationTree { + id: string; + parentId: string; + name: string; + selected?: boolean; + children?: NavigationTree[]; +} + +interface Props { + data: NavigationTree[] +} + +function scrollToItem(id: string) { + document.getElementById(id)?.scrollIntoView(); +} + +function getSelectedNode(data: NavigationTree[]) { + return deepDash.findDeep(data, (value, key) => key === "selected" && value === true)?.parent; +} + +export function RecursiveTreeView({ data }: Props) { + const [expanded, setExpanded] = React.useState([]); + const prevData = useRef(data); + + const handleToggle = (event: React.ChangeEvent<{}>, nodeIds: string[]) => { + setExpanded(nodeIds); + }; + + const expandTopLevelNodes = () => { + setExpanded(data.map(node => node.id)); + }; + + const expandParentNode = () => { + const node = getSelectedNode(data) as any as NavigationTree; + const id = node?.parentId; + + if (id && !expanded.includes(id)) { + setExpanded([...expanded, id]); + } + }; + + const onLabelClick = (event: React.MouseEvent, nodeId: string) => { + event.preventDefault(); + scrollToItem(nodeId); + }; + + const renderTree = (nodes: NavigationTree[]) => { + return nodes.map(node => ( + onLabelClick(event, node.id)} + className={cssNames({selected: node.selected})} + title={node.name} + > + {Array.isArray(node.children) ? node.children.map((node) => renderTree([node])) : null} + + )); + }; + + useEffect(() => { + if (!prevData.current.length) { + expandTopLevelNodes(); + } else { + expandParentNode(); + } + prevData.current = data; + }, [data]); + + if (!data.length) { + return null; + } + + return ( + } + defaultExpandIcon={} + > + {renderTree(data)} + + ); +} diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index 860847e979..3d94196ac0 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -3,3 +3,4 @@ export * from "./useStorage"; export * from "./useOnUnmount"; export * from "./useInterval"; +export * from "./useMutationObserver"; diff --git a/src/renderer/hooks/useMutationObserver.ts b/src/renderer/hooks/useMutationObserver.ts new file mode 100644 index 0000000000..327f1e1997 --- /dev/null +++ b/src/renderer/hooks/useMutationObserver.ts @@ -0,0 +1,27 @@ +import { useEffect } from "react"; + +const config: MutationObserverInit = { + subtree: true, + childList: true, + attributes: false, + characterData: false +}; + +export function useMutationObserver( + root: Element, + callback: MutationCallback, + options: MutationObserverInit = config +) { + + useEffect(() => { + if (root) { + const observer = new MutationObserver(callback); + + observer.observe(root, options); + + return () => { + observer.disconnect(); + }; + } + }, [callback, options]); +} diff --git a/yarn.lock b/yarn.lock index bc2e606b71..6da8f68c34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -834,6 +834,17 @@ react-is "^16.8.0" react-transition-group "^4.4.0" +"@material-ui/lab@^4.0.0-alpha.57": + version "4.0.0-alpha.57" + resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a" + integrity sha512-qo/IuIQOmEKtzmRD2E4Aa6DB4A87kmY6h0uYhjUmrrgmEAgbbw9etXpWPVXuRK6AGIQCjFzV6WO2i21m1R4FCw== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/utils" "^4.11.2" + clsx "^1.0.4" + prop-types "^15.7.2" + react-is "^16.8.0 || ^17.0.0" + "@material-ui/styles@^4.10.0": version "4.10.0" resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.10.0.tgz#2406dc23aa358217aa8cc772e6237bd7f0544071" @@ -871,6 +882,15 @@ resolved "https://registry.yarnpkg.com/@material-ui/types/-/types-5.1.0.tgz#efa1c7a0b0eaa4c7c87ac0390445f0f88b0d88f2" integrity sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A== +"@material-ui/utils@^4.11.2": + version "4.11.2" + resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-4.11.2.tgz#f1aefa7e7dff2ebcb97d31de51aecab1bb57540a" + integrity sha512-Uul8w38u+PICe2Fg2pDKCaIG7kOyhowZ9vjiC1FsVwPABTW8vPPKfF6OvxRq3IiBaI1faOJmgdvMG7rMJARBhA== + dependencies: + "@babel/runtime" "^7.4.4" + prop-types "^15.7.2" + react-is "^16.8.0 || ^17.0.0" + "@material-ui/utils@^4.9.12", "@material-ui/utils@^4.9.6": version "4.9.12" resolved "https://registry.yarnpkg.com/@material-ui/utils/-/utils-4.9.12.tgz#0d639f1c1ed83fffb2ae10c21d15a938795d9e65" @@ -4373,6 +4393,14 @@ deep-is@^0.1.3, deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= +deepdash@^5.3.5: + version "5.3.5" + resolved "https://registry.yarnpkg.com/deepdash/-/deepdash-5.3.5.tgz#611bec9c1f2829832d21971dcbefe712e408647d" + integrity sha512-1ZdPPCI1pCEqeAWGSw+Nbpb/2iIV4w3sGPc22H/PDtcApb8+psTzPIoOVD040iBaT2wqZab29Kjrz6G+cd5mqQ== + dependencies: + lodash "^4.17.20" + lodash-es "^4.17.20" + deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" @@ -8634,6 +8662,11 @@ lockfile@^1.0.4: dependencies: signal-exit "^3.0.2" +lodash-es@^4.17.20: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash._baseuniq@~4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8" @@ -8692,6 +8725,11 @@ lodash@^4.0.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.1 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== +lodash@^4.17.20: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + logform@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/logform/-/logform-2.1.2.tgz#957155ebeb67a13164069825ce67ddb5bb2dd360" @@ -11226,7 +11264,7 @@ react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-i resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: +"react-is@^16.8.0 || ^17.0.0", react-is@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== From 6c872c1aadc385715c96d1763031cb9dc7a772c6 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 15 Mar 2021 11:28:14 -0400 Subject: [PATCH 147/219] Fix Lens not clearing other KUBECONFIG env vars (#2297) --- src/main/__test__/shell-session.test.ts | 19 +++++++++++++++++++ src/main/shell-session.ts | 19 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/main/__test__/shell-session.test.ts diff --git a/src/main/__test__/shell-session.test.ts b/src/main/__test__/shell-session.test.ts new file mode 100644 index 0000000000..fe1b0c4288 --- /dev/null +++ b/src/main/__test__/shell-session.test.ts @@ -0,0 +1,19 @@ +/** + * @jest-environment jsdom + */ + +import { clearKubeconfigEnvVars } from "../shell-session"; + +describe("clearKubeconfigEnvVars tests", () => { + it("should not touch non kubeconfig keys", () => { + expect(clearKubeconfigEnvVars({ a: 1 })).toStrictEqual({ a: 1 }); + }); + + it("should remove a single kubeconfig key", () => { + expect(clearKubeconfigEnvVars({ a: 1, kubeconfig: "1" })).toStrictEqual({ a: 1 }); + }); + + it("should remove a two kubeconfig key", () => { + expect(clearKubeconfigEnvVars({ a: 1, kubeconfig: "1", kUbeconfig: "1" })).toStrictEqual({ a: 1 }); + }); +}); diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index a8c6a36e72..a8661bd635 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -12,6 +12,23 @@ import { isWindows } from "../common/vars"; import { appEventBus } from "../common/event-bus"; import { userStore } from "../common/user-store"; +const anyKubeconfig = /^kubeconfig$/i; + +/** + * This function deletes all keys of the form /^kubeconfig$/i, returning a new + * object. + * + * This is needed because `kubectl` checks for other version of kubeconfig + * before KUBECONFIG and we only set KUBECONFIG. + * @param env The current copy of env + */ +export function clearKubeconfigEnvVars(env: Record): Record { + return Object.fromEntries( + Object.entries(env) + .filter(([key]) => anyKubeconfig.exec(key) === null) + ); +} + export class ShellSession extends EventEmitter { static shellEnvs: Map = new Map(); @@ -103,7 +120,7 @@ export class ShellSession extends EventEmitter { } protected async getShellEnv() { - const env = JSON.parse(JSON.stringify(await shellEnv())); + const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv()))); const pathStr = [this.kubectlBinDir, this.helmBinDir, process.env.PATH].join(path.delimiter); const shell = userStore.preferences.shell || process.env.SHELL || process.env.PTYSHELL; From 7d14ba5e48e2b207118dd030f97b3687612863aa Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 16 Mar 2021 08:05:28 -0400 Subject: [PATCH 148/219] Move clearKubeconfigEnvVars to separate file (#2337) Signed-off-by: Sebastian Malton --- src/main/__test__/shell-session.test.ts | 2 +- src/main/shell-session.ts | 18 +----------------- src/main/utils/clear-kube-env-vars.ts | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 18 deletions(-) create mode 100644 src/main/utils/clear-kube-env-vars.ts diff --git a/src/main/__test__/shell-session.test.ts b/src/main/__test__/shell-session.test.ts index fe1b0c4288..d16b5453b9 100644 --- a/src/main/__test__/shell-session.test.ts +++ b/src/main/__test__/shell-session.test.ts @@ -2,7 +2,7 @@ * @jest-environment jsdom */ -import { clearKubeconfigEnvVars } from "../shell-session"; +import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; describe("clearKubeconfigEnvVars tests", () => { it("should not touch non kubeconfig keys", () => { diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index a8661bd635..40b3981d07 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -11,23 +11,7 @@ import { helmCli } from "./helm/helm-cli"; import { isWindows } from "../common/vars"; import { appEventBus } from "../common/event-bus"; import { userStore } from "../common/user-store"; - -const anyKubeconfig = /^kubeconfig$/i; - -/** - * This function deletes all keys of the form /^kubeconfig$/i, returning a new - * object. - * - * This is needed because `kubectl` checks for other version of kubeconfig - * before KUBECONFIG and we only set KUBECONFIG. - * @param env The current copy of env - */ -export function clearKubeconfigEnvVars(env: Record): Record { - return Object.fromEntries( - Object.entries(env) - .filter(([key]) => anyKubeconfig.exec(key) === null) - ); -} +import { clearKubeconfigEnvVars } from "./utils/clear-kube-env-vars"; export class ShellSession extends EventEmitter { static shellEnvs: Map = new Map(); diff --git a/src/main/utils/clear-kube-env-vars.ts b/src/main/utils/clear-kube-env-vars.ts new file mode 100644 index 0000000000..ef14b1a331 --- /dev/null +++ b/src/main/utils/clear-kube-env-vars.ts @@ -0,0 +1,16 @@ +const anyKubeconfig = /^kubeconfig$/i; + +/** + * This function deletes all keys of the form /^kubeconfig$/i, returning a new + * object. + * + * This is needed because `kubectl` checks for other version of kubeconfig + * before KUBECONFIG and we only set KUBECONFIG. + * @param env The current copy of env + */ +export function clearKubeconfigEnvVars(env: Record): Record { + return Object.fromEntries( + Object.entries(env) + .filter(([key]) => anyKubeconfig.exec(key) === null) + ); +} From a9a5766920dc055bdd8e27034fa427728ab1da49 Mon Sep 17 00:00:00 2001 From: Violetta <38247153+vshakirova@users.noreply.github.com> Date: Tue, 16 Mar 2021 17:22:08 +0400 Subject: [PATCH 149/219] Add the ability to hide metrics from the UI (#2036) --- src/common/cluster-store.ts | 10 +- src/common/user-store.ts | 2 +- .../components/cluster-metrics-setting.scss | 12 ++ .../components/cluster-metrics-setting.tsx | 118 ++++++++++++++++++ .../components/show-metrics.tsx | 63 ++++++++++ .../components/+cluster-settings/general.tsx | 4 + .../components/+cluster/cluster-issues.scss | 4 + .../components/+cluster/cluster-overview.scss | 6 +- .../components/+cluster/cluster-overview.tsx | 38 ++++-- .../+network-ingresses/ingress-details.tsx | 19 +-- .../components/+nodes/node-details.tsx | 5 +- .../volume-claim-details.tsx | 17 ++- .../daemonset-details.tsx | 5 +- .../deployment-details.tsx | 5 +- .../+workloads-pods/pod-details-container.tsx | 5 +- .../+workloads-pods/pod-details.tsx | 17 ++- .../replicaset-details.tsx | 5 +- .../statefulset-details.tsx | 5 +- src/renderer/components/chart/pie-chart.scss | 1 + 19 files changed, 303 insertions(+), 38 deletions(-) create mode 100644 src/renderer/components/+cluster-settings/components/cluster-metrics-setting.scss create mode 100644 src/renderer/components/+cluster-settings/components/cluster-metrics-setting.tsx create mode 100644 src/renderer/components/+cluster-settings/components/show-metrics.tsx diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 6bf932f0f4..7ae402eefe 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -15,6 +15,7 @@ import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBro import _ from "lodash"; import move from "array-move"; import type { WorkspaceId } from "./workspace-store"; +import { ResourceType } from "../renderer/components/+cluster-settings/components/cluster-metrics-setting"; export interface ClusterIconUpload { clusterId: string; @@ -34,7 +35,7 @@ export type ClusterPrometheusMetadata = { export interface ClusterStoreModel { activeCluster?: ClusterId; // last opened cluster - clusters?: ClusterModel[] + clusters?: ClusterModel[]; } export type ClusterId = string; @@ -70,12 +71,13 @@ export interface ClusterModel { kubeConfig?: string; // yaml } -export interface ClusterPreferences extends ClusterPrometheusPreferences{ +export interface ClusterPreferences extends ClusterPrometheusPreferences { terminalCWD?: string; clusterName?: string; iconOrder?: number; icon?: string; httpsProxy?: string; + hiddenMetrics?: string[]; } export interface ClusterPrometheusPreferences { @@ -211,6 +213,10 @@ export class ClusterStore extends BaseStore { return this.activeCluster === id; } + isMetricHidden(resource: ResourceType) { + return Boolean(this.active?.preferences.hiddenMetrics?.includes(resource)); + } + @action setActive(id: ClusterId) { const clusterId = this.clusters.has(id) ? id : null; diff --git a/src/common/user-store.ts b/src/common/user-store.ts index 66ab628453..97ba08f3cc 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store.ts @@ -29,7 +29,7 @@ export interface UserPreferences { downloadBinariesPath?: string; kubectlBinariesPath?: string; openAtLogin?: boolean; - hiddenTableColumns?: Record + hiddenTableColumns?: Record; } export class UserStore extends BaseStore { diff --git a/src/renderer/components/+cluster-settings/components/cluster-metrics-setting.scss b/src/renderer/components/+cluster-settings/components/cluster-metrics-setting.scss new file mode 100644 index 0000000000..eede36623e --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-metrics-setting.scss @@ -0,0 +1,12 @@ +.MetricsSelect { + $spacing: $padding; + --flex-gap: #{$spacing}; + + .Badge { + margin-top: $spacing; + } + + .Button { + margin-top: $spacing; + } +} diff --git a/src/renderer/components/+cluster-settings/components/cluster-metrics-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-metrics-setting.tsx new file mode 100644 index 0000000000..bc51ddf061 --- /dev/null +++ b/src/renderer/components/+cluster-settings/components/cluster-metrics-setting.tsx @@ -0,0 +1,118 @@ +import "./cluster-metrics-setting.scss"; + +import React from "react"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { Select, SelectOption } from "../../select/select"; +import { Icon } from "../../icon/icon"; +import { Button } from "../../button/button"; +import { SubTitle } from "../../layout/sub-title"; +import { Cluster } from "../../../../main/cluster"; +import { observable, reaction } from "mobx"; + +interface Props { + cluster: Cluster; +} + +export enum ResourceType { + Cluster = "Cluster", + Node = "Node", + Pod = "Pod", + Deployment = "Deployment", + StatefulSet = "StatefulSet", + Container = "Container", + Ingress = "Ingress", + VolumeClaim = "VolumeClaim", + ReplicaSet = "ReplicaSet", + DaemonSet = "DaemonSet", +} + +@observer +export class ClusterMetricsSetting extends React.Component { + @observable hiddenMetrics = observable.set(); + + componentDidMount() { + this.hiddenMetrics = observable.set(this.props.cluster.preferences.hiddenMetrics ?? []); + + disposeOnUnmount(this, [ + reaction(() => this.props.cluster.preferences.hiddenMetrics, () => { + this.hiddenMetrics = observable.set(this.props.cluster.preferences.hiddenMetrics ?? []); + }), + ]); + } + + save = () => { + this.props.cluster.preferences.hiddenMetrics = Array.from(this.hiddenMetrics); + }; + + onChangeSelect = (values: SelectOption[]) => { + for (const { value } of values) { + if (this.hiddenMetrics.has(value)) { + this.hiddenMetrics.delete(value); + } else { + this.hiddenMetrics.add(value); + } + } + this.save(); + }; + + onChangeButton = () => { + Object.keys(ResourceType).map(value => + this.hiddenMetrics.add(value) + ); + this.save(); + }; + + reset = () => { + this.hiddenMetrics.clear(); + this.save(); + }; + + formatOptionLabel = ({ value: resource }: SelectOption) => ( +
+ {resource} + {this.hiddenMetrics.has(resource) && } +
+ ); + + renderMetricsSelect() { + + return ( + <> + diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index c6f55872a9..f5384b77f1 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -12,7 +12,7 @@ import { ConfirmDialog } from "./components/confirm-dialog"; import { extensionLoader } from "../extensions/extension-loader"; import { broadcastMessage } from "../common/ipc"; import { CommandContainer } from "./components/command-palette/command-container"; -import { LensProtocolRouterRenderer } from "./protocol-handler/router"; +import { LensProtocolRouterRenderer, bindProtocolAddRouteHandlers } from "./protocol-handler"; import { registerIpcHandlers } from "./ipc"; import { ipcRenderer } from "electron"; @@ -21,6 +21,7 @@ export class LensApp extends React.Component { static async init() { extensionLoader.loadOnClusterManagerRenderer(); LensProtocolRouterRenderer.getInstance().init(); + bindProtocolAddRouteHandlers(); window.addEventListener("offline", () => { broadcastMessage("network:offline"); }); diff --git a/src/renderer/navigation/helpers.ts b/src/renderer/navigation/helpers.ts index 0eda77c629..399eee6325 100644 --- a/src/renderer/navigation/helpers.ts +++ b/src/renderer/navigation/helpers.ts @@ -5,11 +5,11 @@ import { clusterViewRoute, IClusterViewRouteParams } from "../components/cluster import { navigation } from "./history"; export function navigate(location: LocationDescriptor) { - const currentLocation = navigation.getPath(); + const currentLocation = navigation.location.pathname; navigation.push(location); - if (currentLocation === navigation.getPath()) { + if (currentLocation === navigation.location.pathname) { navigation.goBack(); // prevent sequences of same url in history } } diff --git a/src/renderer/navigation/history.ts b/src/renderer/navigation/history.ts index f12b89f365..c1ce34625b 100644 --- a/src/renderer/navigation/history.ts +++ b/src/renderer/navigation/history.ts @@ -10,5 +10,5 @@ navigation.listen((location, action) => { const isClusterView = !process.isMainFrame; const domain = global.location.href; - logger.debug(`[NAVIGATION]: ${action}`, { isClusterView, domain, location }); + logger.debug(`[NAVIGATION]: ${action}-ing. Current is now:`, { isClusterView, domain, location }); }); diff --git a/src/renderer/navigation/index.ts b/src/renderer/navigation/index.ts index adf2577f4e..94930fc994 100644 --- a/src/renderer/navigation/index.ts +++ b/src/renderer/navigation/index.ts @@ -1,10 +1,8 @@ // Navigation (renderer) import { bindEvents } from "./events"; -import { bindProtocolHandlers } from "./protocol-handlers"; export * from "./history"; export * from "./helpers"; bindEvents(); -bindProtocolHandlers(); diff --git a/src/renderer/navigation/protocol-handlers.ts b/src/renderer/navigation/protocol-handlers.ts deleted file mode 100644 index 423cc70fd0..0000000000 --- a/src/renderer/navigation/protocol-handlers.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { LensProtocolRouterRenderer } from "../protocol-handler/router"; -import { navigate } from "./helpers"; - -export function bindProtocolHandlers() { - const lprr = LensProtocolRouterRenderer.getInstance(); - - lprr.addInternalHandler("/preferences", () => { - navigate("/preferences"); - }); -} diff --git a/src/renderer/protocol-handler/app-handlers.ts b/src/renderer/protocol-handler/app-handlers.ts new file mode 100644 index 0000000000..256b8b396a --- /dev/null +++ b/src/renderer/protocol-handler/app-handlers.ts @@ -0,0 +1,58 @@ +import { addClusterURL } from "../components/+add-cluster"; +import { clusterSettingsURL } from "../components/+cluster-settings"; +import { extensionsURL } from "../components/+extensions"; +import { landingURL } from "../components/+landing-page"; +import { preferencesURL } from "../components/+preferences"; +import { clusterViewURL } from "../components/cluster-manager/cluster-view.route"; +import { LensProtocolRouterRenderer } from "./router"; +import { navigate } from "../navigation/helpers"; +import { clusterStore } from "../../common/cluster-store"; +import { workspaceStore } from "../../common/workspace-store"; + +export function bindProtocolAddRouteHandlers() { + LensProtocolRouterRenderer + .getInstance() + .addInternalHandler("/preferences", ({ search: { highlight }}) => { + navigate(preferencesURL({ fragment: highlight })); + }) + .addInternalHandler("/", () => { + navigate(landingURL()); + }) + .addInternalHandler("/landing", () => { + navigate(landingURL()); + }) + .addInternalHandler("/landing/:workspaceId", ({ pathname: { workspaceId } }) => { + if (workspaceStore.getById(workspaceId)) { + workspaceStore.setActive(workspaceId); + navigate(landingURL()); + } else { + console.log("[APP-HANDLER]: workspace with given ID does not exist", { workspaceId }); + } + }) + .addInternalHandler("/cluster", () => { + navigate(addClusterURL()); + }) + .addInternalHandler("/cluster/:clusterId", ({ pathname: { clusterId } }) => { + const cluster = clusterStore.getById(clusterId); + + if (cluster) { + workspaceStore.setActive(cluster.workspace); + navigate(clusterViewURL({ params: { clusterId } })); + } else { + console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId }); + } + }) + .addInternalHandler("/cluster/:clusterId/settings", ({ pathname: { clusterId } }) => { + const cluster = clusterStore.getById(clusterId); + + if (cluster) { + workspaceStore.setActive(cluster.workspace); + navigate(clusterSettingsURL({ params: { clusterId } })); + } else { + console.log("[APP-HANDLER]: cluster with given ID does not exist", { clusterId }); + } + }) + .addInternalHandler("/extensions", () => { + navigate(extensionsURL()); + }); +} diff --git a/src/renderer/protocol-handler/index.ts b/src/renderer/protocol-handler/index.ts index d18015da88..5b2f784711 100644 --- a/src/renderer/protocol-handler/index.ts +++ b/src/renderer/protocol-handler/index.ts @@ -1 +1,2 @@ -export * from "./router.ts"; +export * from "./router"; +export * from "./app-handlers"; diff --git a/webpack.extensions.ts b/webpack.extensions.ts index 1b9c812868..24e8aeac0c 100644 --- a/webpack.extensions.ts +++ b/webpack.extensions.ts @@ -53,6 +53,16 @@ export default function generateExtensionTypes(): webpack.Configuration { } } }, + { + test: /\.(jpg|png|svg|map|ico)$/, + use: { + loader: "file-loader", + options: { + name: "images/[name]-[hash:6].[ext]", + esModule: false, // handle media imports in