1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Basic workspace overview (#2047)

* basic workspace overview

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* css tweaks for landing page as a PageLayout

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* address review comments

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* more review comment addressing, added overview to workspace command palette

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* added back the landing page startup hint

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* refactoring as per review comments

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* added original landing page back only for default workspace with no clusters

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* Workspace overview layout tweaks (#2302)

* tweaks workspace overview layout

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* cluster settings on top

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* header logo for add cluster page

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* tweak landing page

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* combine left menu icons

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* always show bottom status bar

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* tweak

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* integration test fixes

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* change cluster menu

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>

* first attempt to fix integration test

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* lint

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* get selectors right for integration test

Signed-off-by: Jim Ehrismann <jehrismann@miranits.com>

Co-authored-by: Jim Ehrismann <jehrismann@mirantis.com>
Co-authored-by: Jim Ehrismann <jehrismann@miranits.com>

* address review comments, and rebased to master

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

Co-authored-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
Co-authored-by: Jim Ehrismann <jehrismann@miranits.com>
This commit is contained in:
Jim Ehrismann 2021-03-11 02:56:12 -05:00 committed by GitHub
parent 2e8f94b3eb
commit 713ec8c69d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 357 additions and 129 deletions

View File

@ -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");

View File

@ -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");

View File

@ -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<number>;

View File

@ -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) {

View File

@ -352,7 +352,7 @@ export class AddCluster extends React.Component {
return (
<DropFileInput onDropFiles={this.onDropKubeConfig}>
<PageLayout className="AddClusters" header={<h2>Add Clusters</h2>}>
<PageLayout className="AddClusters" header={<><Icon svg="logo-lens" big /> <h2>Add Clusters</h2></>} showOnTop={true}>
<h2>Add Clusters from Kubeconfig</h2>
{this.renderInfo()}
{this.renderKubeConfigSource()}

View File

@ -59,7 +59,7 @@ export class ClusterSettings extends React.Component<Props> {
);
return (
<PageLayout className="ClusterSettings" header={header}>
<PageLayout className="ClusterSettings" header={header} showOnTop={true}>
<Status cluster={cluster}></Status>
<General cluster={cluster}></General>
<Features cluster={cluster}></Features>

View File

@ -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;
}
}
.startup-hint {
$bgc: $mainBackground;
$arrowSize: 10px;
.content-wrapper {
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;
}
.content {
margin: unset;
max-width: unset;
}
}
}

View File

@ -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(<><b>Welcome!</b><p>Get started by associating one or more clusters to Lens</p></>, {
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 = <><Icon svg="logo-lens" big /> <h2>{this.workspace.name}</h2></>;
return (
<div className="LandingPage flex">
{showStartupHint && (
<div className="startup-hint flex column gaps" onMouseEnter={() => this.showHint = false}>
<p>This is the quick launch menu.</p>
<p>
Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button.
</p>
</div>
)}
{noClustersInScope && (
<div className="no-clusters flex column gaps box center">
<h1>
Welcome!
</h1>
<p>
Get started by associating one or more clusters to Lens.
</p>
</div>
)}
</div>
<PageLayout className="LandingOverview flex" header={header} provideBackButtonNavigation={showBackButton} showOnTop={true}>
<WorkspaceOverview workspace={this.workspace}/>
</PageLayout>
);
}
}

View File

@ -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<Props> {
@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 (
<p>Remove cluster <b>{clusterItem.name}</b> from workspace <b>{workspace.name}</b>?</p>
);
}
renderContent() {
const { toolbar } = this.props;
return (
<>
<MenuItem onClick={this.gotoSettings}>
<Icon material="settings" interactive={toolbar} title="Settings"/>
<span className="title">Settings</span>
</MenuItem>
</>
);
}
render() {
const { clusterItem: { cluster: { isManaged } }, className, ...menuProps } = this.props;
return (
<MenuActions
{...menuProps}
className={cssNames("WorkspaceClusterMenu", className)}
removeAction={isManaged ? null : this.remove}
removeConfirmationMessage={this.renderRemoveMessage}
>
{this.renderContent()}
</MenuActions>
);
}
}

View File

@ -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<ClusterItem> {
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));
}
}
}

View File

@ -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;
}
}

View File

@ -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<Props> {
showCluster = ({ clusterId }: ClusterItem) => {
navigate(clusterViewURL({ params: { clusterId } }));
};
render() {
const { workspace } = this.props;
const workspaceClusterStore = new WorkspaceClusterStore(workspace.id);
workspaceClusterStore.loadAll();
return (
<ItemListLayout
renderHeaderTitle={<div>Clusters</div>}
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) => (
<WorkspaceClusterMenu clusterItem={clusterItem} workspace={workspace} workspaceClusterStore={workspaceClusterStore}/>
)}
/>
);
}
}

View File

@ -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(<AddWorkspace />);

View File

@ -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 {

View File

@ -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<Props> {
@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<Props> {
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<Props> {
</Droppable>
</DragDropContext>
</div>
<div className="add-cluster">
<Tooltip targetId="add-cluster-icon">
Add Cluster
</Tooltip>
<Icon big material="add" id="add-cluster-icon" disabled={workspace.isManaged} onClick={this.addCluster}/>
{newContexts.size > 0 && (
<Badge className="counter" label={newContexts.size} tooltip="new"/>
)}
<div className="WorkspaceMenu">
<Icon big material="menu" id="workspace-menu-icon" data-test-id="workspace-menu" />
<Menu
usePortal
htmlFor="workspace-menu-icon"
className="WorkspaceMenu"
isOpen={this.workspaceMenuVisible}
open={() => this.workspaceMenuVisible = true}
close={() => this.workspaceMenuVisible = false}
toggleEvent="click"
>
<MenuItem onClick={() => navigate(addClusterURL())} data-test-id="add-cluster-menu-item">
<Icon small material="add" /> Add Cluster
</MenuItem>
<MenuItem onClick={() => navigate(landingURL())} data-test-id="workspace-overview-menu-item">
<Icon small material="dashboard" /> Workspace Overview
</MenuItem>
</Menu>
</div>
<div className="extensions">
{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => {

View File

@ -318,6 +318,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
}
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<ItemListLayoutProps> {
{this.isReady && info}
</div>
{filters}
{search}
{isSearchable && searchFilters && search}
</>
);
}

View File

@ -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)