diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6dbf72784..69440e27c6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,11 @@ name: Test on: - - pull_request + pull_request: + branches: + - "*" + push: + branches: + - master jobs: build: name: Test @@ -20,14 +25,36 @@ jobs: with: node-version: ${{ matrix.node-version }} - - run: make node_modules + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + + - uses: actions/cache@v2 + id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - uses: nick-invision/retry@v2 name: Install dependencies + with: + timeout_minutes: 10 + max_attempts: 3 + retry_on: error + command: make node_modules - run: make build-npm name: Generate npm package - - run: make -j2 build-extensions + - uses: nick-invision/retry@v2 name: Build bundled extensions + with: + timeout_minutes: 15 + max_attempts: 3 + retry_on: error + command: make -j2 build-extensions - run: make test name: Run tests diff --git a/Makefile b/Makefile index 658be09690..b5a652a51b 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ binaries/client: node_modules yarn download-bins node_modules: yarn.lock - yarn install --frozen-lockfile + yarn install --frozen-lockfile --network-timeout=100000 yarn check --verify-tree --integrity static/build/LensDev.html: node_modules diff --git a/README.md b/README.md index dc018b3ac2..cc38522c53 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Lens | The Kubernetes IDE -[![Build Status](https://dev.azure.com/lensapp/lensapp/_apis/build/status/lensapp.lens?branchName=master)](https://dev.azure.com/lensapp/lensapp/_build/latest?definitionId=1&branchName=master) +[![Build Status](https://github.com/lensapp/lens/actions/workflows/test.yml/badge.svg)](https://github.com/lensapp/lens/actions/workflows/test.yml) [![Releases](https://img.shields.io/github/downloads/lensapp/lens/total.svg)](https://github.com/lensapp/lens/releases?label=Downloads) [![Chat on Slack](https://img.shields.io/badge/chat-on%20slack-blue.svg?logo=slack&longCache=true&style=flat)](https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI) @@ -25,7 +25,7 @@ The Lens open source project is backed by a number of Kubernetes and cloud nativ * Lens Extensions are used to add custom visualizations and functionality to accelerate development workflows for all the technologies and services that integrate with Kubernetes * Port forwarding * Helm package deployment: Browse and deploy Helm charts with one click-Install -* Extensions via Lens Extensions API +* Extensions via Lens Extensions API ## Installation diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 2d7807e22b..12d95f887c 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -387,10 +387,11 @@ describe("Lens cluster pages", () => { 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.waitForVisible(`ul.KubeObjectMenu li.MenuItem i[title="Logs"]`); await app.client.click(".drawer-title .Menu li:nth-child(2)"); // Check if controls are available await app.client.waitForVisible(".LogList .VirtualList"); diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index 2c62279e4b..4c7120c4c5 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -39,7 +39,10 @@ export function minikubeReady(testNamespace: string): boolean { } export async function addMinikubeCluster(app: Application) { - await app.client.click("button.add-button"); + await app.client.waitForVisible("button.MuiSpeedDial-fab"); + await app.client.click("button.MuiSpeedDial-fab"); + await app.client.waitForVisible(`button[title="Add from kubeconfig"]`); + await app.client.click(`button[title="Add from kubeconfig"]`); 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/package.json b/package.json index 2171b39a00..e5414ad371 100644 --- a/package.json +++ b/package.json @@ -206,6 +206,7 @@ "electron-devtools-installer": "^3.1.1", "electron-updater": "^4.3.1", "electron-window-state": "^5.0.3", + "filehound": "^1.17.4", "filenamify": "^4.1.0", "fs-extra": "^9.0.1", "handlebars": "^4.7.6", @@ -216,7 +217,7 @@ "jsonpath": "^1.0.2", "lodash": "^4.17.15", "mac-ca": "^1.0.4", - "marked": "^1.2.7", + "marked": "^2.0.3", "md5-file": "^5.0.0", "mobx": "^5.15.7", "mobx-observable-history": "^1.0.3", diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 78b99d3f5c..d0760dcdf1 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -1,7 +1,7 @@ import { EventEmitter } from "events"; import { observable } from "mobx"; import { catalogCategoryRegistry } from "../catalog-category-registry"; -import { CatalogCategory, CatalogEntity, CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityData, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity"; +import { CatalogCategory, CatalogEntity, CatalogEntityActionContext, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityData, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog-entity"; import { clusterDisconnectHandler } from "../cluster-ipc"; import { clusterStore } from "../cluster-store"; import { requestMain } from "../ipc"; @@ -49,11 +49,13 @@ export class KubernetesCluster implements CatalogEntity { { icon: "settings", title: "Settings", + onlyVisibleForSource: "local", onClick: async () => context.navigate(`/cluster/${this.metadata.uid}/settings`) }, { icon: "delete", title: "Delete", + onlyVisibleForSource: "local", onClick: async () => clusterStore.removeById(this.metadata.uid), confirm: { message: `Remove Kubernetes Cluster "${this.metadata.name} from Lens?` @@ -97,6 +99,20 @@ export class KubernetesClusterCategory extends EventEmitter implements CatalogCa } }; + constructor() { + super(); + + this.on("onCatalogAddMenu", (ctx: CatalogEntityAddMenuContext) => { + ctx.menuItems.push({ + icon: "text_snippet", + title: "Add from kubeconfig", + onClick: async () => { + ctx.navigate("/add-cluster"); + } + }); + }); + } + getId() { return `${this.spec.group}/${this.spec.names.kind}`; } diff --git a/src/common/catalog-entity.ts b/src/common/catalog-entity.ts index 8a00297d3c..19d83d5437 100644 --- a/src/common/catalog-entity.ts +++ b/src/common/catalog-entity.ts @@ -45,6 +45,7 @@ export interface CatalogEntityActionContext { export type CatalogEntityContextMenu = { icon: string; title: string; + onlyVisibleForSource?: string; // show only if empty or if matches with entity source onClick: () => Promise; confirm?: { message: string; @@ -56,6 +57,11 @@ export interface CatalogEntityContextMenuContext { menuItems: CatalogEntityContextMenu[]; } +export interface CatalogEntityAddMenuContext { + navigate: (url: string) => void; + menuItems: CatalogEntityContextMenu[]; +} + export type CatalogEntityData = { apiVersion: string; kind: string; diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 62a3687fdb..1a48fc54e3 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -1,5 +1,6 @@ // Common UI components export * from "../../renderer/components/layout/sub-title"; +export * from "../../renderer/components/input/search-input"; // layouts export * from "../../renderer/components/layout/page-layout"; diff --git a/src/main/__test__/kubeconfig-manager.test.ts b/src/main/__test__/kubeconfig-manager.test.ts index a272d1e597..1cc39a4ada 100644 --- a/src/main/__test__/kubeconfig-manager.test.ts +++ b/src/main/__test__/kubeconfig-manager.test.ts @@ -84,7 +84,9 @@ describe("kubeconfig manager tests", () => { expect(logger.error).not.toBeCalled(); expect(await kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`); - const file = await fse.readFile(await kubeConfManager.getPath()); + // this causes an intermittent "ENXIO: no such device or address, read" error + // const file = await fse.readFile(await kubeConfManager.getPath()); + const file = fse.readFileSync(await kubeConfManager.getPath()); const yml = loadYaml(file.toString()); expect(yml["current-context"]).toBe("minikube"); diff --git a/src/renderer/api/catalog-entity.ts b/src/renderer/api/catalog-entity.ts index a8006be0f7..ad82af10e6 100644 --- a/src/renderer/api/catalog-entity.ts +++ b/src/renderer/api/catalog-entity.ts @@ -2,7 +2,15 @@ import { navigate } from "../navigation"; import { commandRegistry } from "../../extensions/registries"; import { CatalogEntity } from "../../common/catalog-entity"; -export { CatalogEntity, CatalogEntityData, CatalogEntityActionContext, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../common/catalog-entity"; +export { + CatalogCategory, + CatalogEntity, + CatalogEntityData, + CatalogEntityActionContext, + CatalogEntityAddMenuContext, + CatalogEntityContextMenu, + CatalogEntityContextMenuContext +} from "../../common/catalog-entity"; export const catalogEntityRunContext = { navigate: (url: string) => navigate(url), diff --git a/src/renderer/components/+catalog/catalog-add-button.scss b/src/renderer/components/+catalog/catalog-add-button.scss new file mode 100644 index 0000000000..7377a60dfc --- /dev/null +++ b/src/renderer/components/+catalog/catalog-add-button.scss @@ -0,0 +1,9 @@ +.CatalogAddButton { + position: absolute; + right: 40px; + bottom: 30px; + + .MuiFab-primary { + background-color: var(--blue); + } +} diff --git a/src/renderer/components/+catalog/catalog-add-button.tsx b/src/renderer/components/+catalog/catalog-add-button.tsx new file mode 100644 index 0000000000..6da733af7c --- /dev/null +++ b/src/renderer/components/+catalog/catalog-add-button.tsx @@ -0,0 +1,74 @@ +import "./catalog-add-button.scss"; +import React from "react"; +import { SpeedDial, SpeedDialAction } from "@material-ui/lab"; +import { Icon } from "../icon"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { observable, reaction } from "mobx"; +import { autobind } from "../../../common/utils"; +import { CatalogCategory, CatalogEntityAddMenuContext, CatalogEntityContextMenu } from "../../api/catalog-entity"; +import { EventEmitter } from "events"; +import { navigate } from "../../navigation"; + +export type CatalogAddButtonProps = { + category: CatalogCategory +}; + +@observer +export class CatalogAddButton extends React.Component { + @observable protected isOpen = false; + protected menuItems = observable.array([]); + + componentDidMount() { + disposeOnUnmount(this, [ + reaction(() => this.props.category, (category) => { + this.menuItems.clear(); + + if (category && category instanceof EventEmitter) { + const context: CatalogEntityAddMenuContext = { + navigate: (url: string) => navigate(url), + menuItems: this.menuItems + }; + + category.emit("onCatalogAddMenu", context); + } + }, { fireImmediately: true }) + ]); + } + + @autobind() + onOpen() { + this.isOpen = true; + } + + @autobind() + onClose() { + this.isOpen = false; + } + + render() { + if (this.menuItems.length === 0) { + return null; + } + + return ( + } + direction="up" + > + { this.menuItems.map((menuItem, index) => { + return } + tooltipTitle={menuItem.title} + onClick={() => menuItem.onClick()} + />; + })} + + ); + } +} diff --git a/src/renderer/components/+catalog/catalog-entity.store.ts b/src/renderer/components/+catalog/catalog-entity.store.ts index 2c867ca02b..49043fed70 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.ts +++ b/src/renderer/components/+catalog/catalog-entity.store.ts @@ -61,8 +61,6 @@ export class CatalogEntityStore extends ItemStore { @computed get entities() { if (!this.activeCategory) return []; - console.log("computing entities", this.activeCategory); - return catalogEntityRegistry.getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity)); } diff --git a/src/renderer/components/+catalog/catalog.scss b/src/renderer/components/+catalog/catalog.scss index 1d321ac232..e88e786800 100644 --- a/src/renderer/components/+catalog/catalog.scss +++ b/src/renderer/components/+catalog/catalog.scss @@ -21,6 +21,7 @@ > .sidebar { width: 100%; + padding: 0; .sidebarTabs { margin-top: 5px; diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index ebd17fd676..a73ba86c28 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -2,7 +2,7 @@ import "./catalog.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { ItemListLayout } from "../item-object-list"; -import { observable, reaction } from "mobx"; +import { action, observable, reaction } from "mobx"; import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store"; import { navigate } from "../../navigation"; import { kebabCase } from "lodash"; @@ -12,12 +12,12 @@ import { Icon } from "../icon"; import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; import { Badge } from "../badge"; import { hotbarStore } from "../../../common/hotbar-store"; -import { addClusterURL } from "../+add-cluster"; import { autobind } from "../../utils"; import { Notifications } from "../notifications"; import { ConfirmDialog } from "../confirm-dialog"; import { Tab, Tabs } from "../tabs"; import { catalogCategoryRegistry } from "../../../common/catalog-category-registry"; +import { CatalogAddButton } from "./catalog-add-button"; enum sortBy { name = "name", @@ -101,6 +101,7 @@ export class Catalog extends React.Component { return catalogCategoryRegistry.items; } + @action onTabChange = (tabId: string) => { this.activeTab = tabId; @@ -126,6 +127,7 @@ export class Catalog extends React.Component { @autobind() renderItemMenu(item: CatalogEntityItem) { + const menuItems = this.contextMenu.menuItems.filter((menuItem) => !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === item.entity.metadata.source); const onOpen = async () => { await item.onContextMenuOpen(this.contextMenu); }; @@ -138,7 +140,7 @@ export class Catalog extends React.Component { this.removeFromHotbar(item) }> Remove from Hotbar - { this.contextMenu.menuItems.map((menuItem, index) => { + { menuItems.map((menuItem, index) => { return ( this.onMenuItemClick(menuItem)}> {menuItem.title} @@ -149,6 +151,7 @@ export class Catalog extends React.Component { ); } + render() { if (!this.catalogEntityStore) { return null; @@ -161,6 +164,7 @@ export class Catalog extends React.Component { provideBackButtonNavigation={false} contentGaps={false}> this.onDetails(item) } renderItemMenu={this.renderItemMenu} - addRemoveButtons={{ - addTooltip: "Add Kubernetes Cluster", - onAdd: () => navigate(addClusterURL()), - }} /> + ); } diff --git a/src/renderer/components/dock/create-resource.scss b/src/renderer/components/dock/create-resource.scss index 027b37763d..48231378c9 100644 --- a/src/renderer/components/dock/create-resource.scss +++ b/src/renderer/components/dock/create-resource.scss @@ -1,2 +1,2 @@ .CreateResource { -} \ No newline at end of file +} diff --git a/src/renderer/components/dock/create-resource.store.ts b/src/renderer/components/dock/create-resource.store.ts index 8933ddf4d0..bdc1f4c139 100644 --- a/src/renderer/components/dock/create-resource.store.ts +++ b/src/renderer/components/dock/create-resource.store.ts @@ -1,13 +1,61 @@ +import fs from "fs-extra"; +import path from "path"; +import os from "os"; +import groupBy from "lodash/groupBy"; +import filehound from "filehound"; +import { watch } from "chokidar"; import { autobind } from "../../utils"; import { DockTabStore } from "./dock-tab.store"; import { dockStore, IDockTab, TabKind } from "./dock.store"; @autobind() export class CreateResourceStore extends DockTabStore { + constructor() { super({ storageKey: "create_resource" }); + fs.ensureDirSync(this.userTemplatesFolder); + } + + get lensTemplatesFolder():string { + return path.resolve(__static, "../templates/create-resource"); + } + + get userTemplatesFolder():string { + return path.join(os.homedir(), ".k8slens", "templates"); + } + + async getTemplates(templatesPath: string, defaultGroup: string) { + const templates = await filehound.create().path(templatesPath).ext(["yaml", "json"]).depth(1).find(); + + return templates ? this.groupTemplates(templates, templatesPath, defaultGroup) : {}; + } + + groupTemplates(templates: string[], templatesPath: string, defaultGroup: string) { + return groupBy(templates,(v:string) => + path.relative(templatesPath,v).split(path.sep).length>1 + ? path.parse(path.relative(templatesPath,v)).dir + : defaultGroup); + } + + async getMergedTemplates() { + const userTemplates = await this.getTemplates(this.userTemplatesFolder, "ungrouped"); + const lensTemplates = await this.getTemplates(this.lensTemplatesFolder, "lens"); + + return {...userTemplates,...lensTemplates}; + } + + async watchUserTemplates(callback: ()=> void){ + watch(this.userTemplatesFolder, { + depth: 1, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 500 + } + }).on("all", () => { + callback(); + }); } } diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx index 01e6002309..29e2bf60e7 100644 --- a/src/renderer/components/dock/create-resource.tsx +++ b/src/renderer/components/dock/create-resource.tsx @@ -1,6 +1,9 @@ import "./create-resource.scss"; import React from "react"; +import path from "path"; +import fs from "fs-extra"; +import {Select, GroupSelectOption, SelectOption} from "../select"; import jsYaml from "js-yaml"; import { observable } from "mobx"; import { observer } from "mobx-react"; @@ -20,7 +23,25 @@ interface Props { @observer export class CreateResource extends React.Component { + @observable currentTemplates:Map = new Map(); @observable error = ""; + @observable templates:GroupSelectOption[] = []; + + componentDidMount() { + createResourceStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v)); + createResourceStore.watchUserTemplates(() => createResourceStore.getMergedTemplates().then(v => this.updateGroupSelectOptions(v))); + } + + updateGroupSelectOptions(templates :Record) { + this.templates = Object.entries(templates) + .map(([name, grouping]) => this.convertToGroup(name, grouping)); + } + + convertToGroup(group:string, items:string[]):GroupSelectOption { + const options = items.map(v => ({label: path.parse(v).name, value: v})); + + return {label: group, options}; + } get tabId() { return this.props.tab.id; @@ -30,11 +51,20 @@ export class CreateResource extends React.Component { return createResourceStore.getData(this.tabId); } + get currentTemplate() { + return this.currentTemplates.get(this.tabId) ?? null; + } + onChange = (value: string, error?: string) => { createResourceStore.setData(this.tabId, value); this.error = error; }; + onSelectTemplate = (item: SelectOption) => { + this.currentTemplates.set(this.tabId, item); + fs.readFile(item.value,"utf8").then(v => createResourceStore.setData(this.tabId,v)); + }; + create = async () => { if (this.error) return; if (!this.data.trim()) return; // do not save when field is empty @@ -67,6 +97,24 @@ export class CreateResource extends React.Component { return successMessage; }; + renderControls(){ + return ( +
+