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] 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 +}