1
0
mirror of https://github.com/lensapp/lens.git synced 2024-09-20 13:57:23 +03:00

catalog details panel (#2939)

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

Co-authored-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Jari Kolehmainen 2021-06-07 10:09:00 +03:00 committed by GitHub
parent 031c57962b
commit 0fb927f96b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 395 additions and 17 deletions

View File

@ -64,6 +64,8 @@ export async function waitForMinikubeDashboard(app: Application) {
await app.client.setValue(".Input.SearchInput input", "minikube");
await app.client.waitUntilTextExists("div.TableCell", "minikube");
await app.client.click("div.TableRow");
await app.client.waitUntilTextExists("div.drawer-title-text", "KubernetesCluster: minikube");
await app.client.click("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root");
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
await app.client.frame("minikube");

View File

@ -104,6 +104,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
context.menuItems = [
{
title: "Settings",
icon: "edit",
onlyVisibleForSource: "local",
onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`)
},
@ -112,6 +113,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) {
context.menuItems.push({
title: "Delete",
icon: "delete",
onlyVisibleForSource: "local",
onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid),
confirm: {
@ -123,6 +125,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
if (this.status.phase == "connected") {
context.menuItems.push({
title: "Disconnect",
icon: "link_off",
onClick: async () => {
requestMain(clusterDisconnectHandler, this.metadata.uid);
}
@ -130,6 +133,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
} else {
context.menuItems.push({
title: "Connect",
icon: "link",
onClick: async () => {
context.navigate(`/cluster/${this.metadata.uid}`);
}
@ -147,7 +151,7 @@ export class KubernetesClusterCategory extends CatalogCategory {
public readonly kind = "CatalogCategory";
public metadata = {
name: "Kubernetes Clusters",
icon: require(`!!raw-loader!./icons/kubernetes.svg`).default // eslint-disable-line
icon: require(`!!raw-loader!./icons/kubernetes.svg`).default, // eslint-disable-line
};
public spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",

View File

@ -96,9 +96,25 @@ export interface CatalogEntityActionContext {
}
export interface CatalogEntityContextMenu {
/**
* Menu title
*/
title: string;
onlyVisibleForSource?: string; // show only if empty or if matches with entity source
/**
* Menu icon
*/
icon?: string;
/**
* Show only if empty or if value matches with entity.metadata.source
*/
onlyVisibleForSource?: string;
/**
* OnClick handler
*/
onClick: () => void | Promise<void>;
/**
* Confirm click with a message
*/
confirm?: {
message: string;
}
@ -175,7 +191,6 @@ export abstract class CatalogEntity<
}
public abstract onRun?(context: CatalogEntityActionContext): void | Promise<void>;
public abstract onDetailsOpen(context: CatalogEntityActionContext): void | Promise<void>;
public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise<void>;
public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise<void>;
}

View File

@ -250,6 +250,7 @@ export class ExtensionLoader extends Singleton {
registries.statusBarRegistry.add(extension.statusBarItems),
registries.commandRegistry.add(extension.commands),
registries.welcomeMenuRegistry.add(extension.welcomeMenus),
registries.catalogEntityDetailRegistry.add(extension.catalogEntityDetailItems),
];
this.events.on("remove", (removedExtension: LensRendererExtension) => {

View File

@ -20,7 +20,7 @@
*/
import type {
AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration,
AppPreferenceRegistration, CatalogEntityDetailRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration,
KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, WorkloadsOverviewDetailRegistration,
} from "./registries";
import type { Cluster } from "../main/cluster";
@ -43,6 +43,7 @@ export class LensRendererExtension extends LensExtension {
kubeWorkloadsOverviewItems: WorkloadsOverviewDetailRegistration[] = [];
commands: CommandRegistration[] = [];
welcomeMenus: WelcomeMenuRegistration[] = [];
catalogEntityDetailItems: CatalogEntityDetailRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation");

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type React from "react";
import { BaseRegistry } from "./base-registry";
export interface CatalogEntityDetailComponents {
Details: React.ComponentType<any>;
}
export interface CatalogEntityDetailRegistration {
kind: string;
apiVersions: string[];
components: CatalogEntityDetailComponents;
priority?: number;
}
export class CatalogEntityDetailRegistry extends BaseRegistry<CatalogEntityDetailRegistration> {
getItemsForKind(kind: string, apiVersion: string) {
const items = this.getItems().filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion);
});
return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50));
}
}
export const catalogEntityDetailRegistry = new CatalogEntityDetailRegistry();

View File

@ -33,4 +33,5 @@ export * from "./command-registry";
export * from "./entity-setting-registry";
export * from "./welcome-menu-registry";
export * from "./protocol-handler-registry";
export * from "./catalog-entity-detail-registry";
export * from "./workloads-overview-detail-registry";

View File

@ -0,0 +1,43 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.CatalogEntityDetails {
.EntityMetadata {
margin-right: $margin;
}
.EntityIcon.box.top.left {
margin-right: $margin * 2;
.IconHint {
text-align: center;
font-size: var(--font-size-small);
text-transform: uppercase;
margin-top: $margin;
cursor: default;
user-select: none;
opacity: 0.5;
}
div * {
font-size: 1.5em;
}
}
}

View File

@ -0,0 +1,129 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./catalog-entity-details.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { Drawer, DrawerItem, DrawerItemLabels } from "../drawer";
import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity";
import type { CatalogCategory } from "../../../common/catalog";
import { Icon } from "../icon";
import { KubeObject } from "../../api/kube-object";
import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu";
import { catalogEntityDetailRegistry } from "../../../extensions/registries";
import { HotbarIcon } from "../hotbar/hotbar-icon";
interface Props {
entity: CatalogEntity;
hideDetails(): void;
}
@observer
export class CatalogEntityDetails extends Component<Props> {
private abortController?: AbortController;
constructor(props: Props) {
super(props);
}
componentWillUnmount() {
this.abortController?.abort();
}
categoryIcon(category: CatalogCategory) {
if (category.metadata.icon.includes("<svg")) {
return <Icon svg={category.metadata.icon} smallest />;
} else {
return <Icon material={category.metadata.icon} smallest />;
}
}
openEntity() {
this.props.entity.onRun(catalogEntityRunContext);
}
renderContent() {
const { entity } = this.props;
const labels = KubeObject.stringifyLabels(entity.metadata.labels);
const detailItems = catalogEntityDetailRegistry.getItemsForKind(entity.kind, entity.apiVersion);
const details = detailItems.map((item, index) => {
return <item.components.Details entity={entity} key={index}/>;
});
const showDetails = detailItems.find((item) => item.priority > 999) === undefined;
return (
<>
{showDetails && (
<div className="flex CatalogEntityDetails">
<div className="EntityIcon box top left">
<HotbarIcon
uid={entity.metadata.uid}
title={entity.metadata.name}
source={entity.metadata.source}
onClick={() => this.openEntity()}
size={128} />
<div className="IconHint">
Click to open
</div>
</div>
<div className="box grow EntityMetadata">
<DrawerItem name="Name">
{entity.metadata.name}
</DrawerItem>
<DrawerItem name="Kind">
{entity.kind}
</DrawerItem>
<DrawerItem name="Source">
{entity.metadata.source}
</DrawerItem>
<DrawerItemLabels
name="Labels"
labels={labels}
/>
</div>
</div>
)}
<div className="box grow">
{details}
</div>
</>
);
}
render() {
const { entity, hideDetails } = this.props;
const title = `${entity.kind}: ${entity.metadata.name}`;
return (
<Drawer
className="CatalogEntityDetails"
usePortal={true}
open={true}
title={title}
toolbar={<CatalogEntityDrawerMenu entity={entity} key={entity.getId()} />}
onClose={hideDetails}
>
{this.renderContent()}
</Drawer>
);
}
}

View File

@ -0,0 +1,127 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { cssNames } from "../../utils";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import { observer } from "mobx-react";
import { makeObservable, observable } from "mobx";
import { navigate } from "../../navigation";
import { MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog";
import { HotbarStore } from "../../../common/hotbar-store";
import { Icon } from "../icon";
export interface CatalogEntityDrawerMenuProps<T extends CatalogEntity> extends MenuActionsProps {
entity: T | null | undefined;
}
@observer
export class CatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Component<CatalogEntityDrawerMenuProps<T>> {
@observable private contextMenu: CatalogEntityContextMenuContext;
constructor(props: CatalogEntityDrawerMenuProps<T>) {
super(props);
makeObservable(this);
}
componentDidMount() {
this.contextMenu = {
menuItems: [],
navigate: (url: string) => navigate(url)
};
this.props.entity?.onContextMenuOpen(this.contextMenu);
}
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
if (menuItem.confirm) {
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
},
ok: () => {
menuItem.onClick();
},
message: menuItem.confirm.message
});
} else {
menuItem.onClick();
}
}
addToHotbar(entity: CatalogEntity): void {
HotbarStore.getInstance().addToHotbar(entity);
}
getMenuItems(entity: T): React.ReactChild[] {
if (!entity) {
return [];
}
const menuItems = this.contextMenu.menuItems.filter((menuItem) => {
return menuItem.icon && !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source;
});
const items = menuItems.map((menuItem, index) => {
const props = menuItem.icon.includes("<svg") ? { svg: menuItem.icon } : { material: menuItem.icon };
return (
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>
<Icon
title={menuItem.title}
{...props}
/>
</MenuItem>
);
});
items.unshift(
<MenuItem key="add-to-hotbar" onClick={() => this.addToHotbar(entity) }>
<Icon material="playlist_add" small title="Add to Hotbar" />
</MenuItem>
);
items.reverse();
return items;
}
render() {
if (!this.contextMenu) {
return null;
}
const { className, entity, ...menuProps } = this.props;
return (
<MenuActions
className={cssNames("CatalogEntityDrawerMenu", className)}
toolbar
{...menuProps}
>
{this.getMenuItems(entity)}
</MenuActions>
);
}
}

View File

@ -29,7 +29,7 @@ import { navigate } from "../../navigation";
import { kebabCase } from "lodash";
import { PageLayout } from "../layout/page-layout";
import { MenuItem, MenuActions } from "../menu";
import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity";
import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import { Badge } from "../badge";
import { HotbarStore } from "../../../common/hotbar-store";
import { ConfirmDialog } from "../confirm-dialog";
@ -40,6 +40,7 @@ import type { RouteComponentProps } from "react-router";
import type { ICatalogViewRouteParam } from "./catalog.route";
import { Notifications } from "../notifications";
import { Avatar } from "../avatar/avatar";
import { CatalogEntityDetails } from "./catalog-entity-details";
enum sortBy {
name = "name",
@ -55,6 +56,7 @@ export class Catalog extends React.Component<Props> {
@observable private catalogEntityStore?: CatalogEntityStore;
@observable private contextMenu: CatalogEntityContextMenuContext;
@observable activeTab?: string;
@observable selectedItem?: CatalogEntityItem;
constructor(props: Props) {
super(props);
@ -103,7 +105,7 @@ export class Catalog extends React.Component<Props> {
}
onDetails(item: CatalogEntityItem) {
item.onRun(catalogEntityRunContext);
this.selectedItem = item;
}
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
@ -181,12 +183,6 @@ export class Catalog extends React.Component<Props> {
};
renderIcon(item: CatalogEntityItem) {
const category = catalogCategoryRegistry.getCategoryForEntity(item.entity);
if (!category) {
return null;
}
return (
<Avatar
title={item.name}
@ -269,6 +265,7 @@ export class Catalog extends React.Component<Props> {
item.labels.map((label) => <Badge key={label} label={label} title={label} />),
{ title: item.phase, className: kebabCase(item.phase) }
]}
detailsItem={this.selectedItem}
onDetails={(item: CatalogEntityItem) => this.onDetails(item) }
renderItemMenu={this.renderItemMenu}
/>
@ -287,7 +284,15 @@ export class Catalog extends React.Component<Props> {
provideBackButtonNavigation={false}
contentGaps={false}>
{ this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() }
{ !this.selectedItem && (
<CatalogAddButton category={this.catalogEntityStore.activeCategory} />
)}
{ this.selectedItem && (
<CatalogEntityDetails
entity={this.selectedItem.entity}
hideDetails={() => this.selectedItem = null}
/>
)}
</PageLayout>
);
}

View File

@ -39,6 +39,7 @@ interface Props extends DOMAttributes<HTMLElement> {
errorClass?: IClassName;
add: (item: CatalogEntity, index: number) => void;
remove: (uid: string) => void;
size?: number;
}
@observer

View File

@ -31,7 +31,7 @@ import { MaterialTooltip } from "../material-tooltip/material-tooltip";
import { observer } from "mobx-react";
import { Avatar } from "../avatar/avatar";
interface Props extends DOMAttributes<HTMLElement> {
export interface HotbarIconProps extends DOMAttributes<HTMLElement> {
uid: string;
title: string;
source: string;
@ -40,6 +40,7 @@ interface Props extends DOMAttributes<HTMLElement> {
active?: boolean;
menuItems?: CatalogEntityContextMenu[];
disabled?: boolean;
size?: number;
}
function onMenuItemClick(menuItem: CatalogEntityContextMenu) {
@ -59,7 +60,7 @@ function onMenuItemClick(menuItem: CatalogEntityContextMenu) {
}
}
export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => {
export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: HotbarIconProps) => {
const { uid, title, active, className, source, disabled, onMenuOpen, children, ...rest } = props;
const id = `hotbarIcon-${uid}`;
const [menuOpen, setMenuOpen] = useState(false);
@ -77,8 +78,8 @@ export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => {
title={title}
colorHash={`${title}-${source}`}
className={active ? "active" : "default"}
width={40}
height={40}
width={size}
height={size}
/>
{children}
</div>

View File

@ -157,6 +157,7 @@ export class HotbarMenu extends React.Component<Props> {
className={cssNames({ isDragging: snapshot.isDragging })}
remove={this.removeItem}
add={this.addItem}
size={40}
/>
) : (
<HotbarIcon
@ -165,6 +166,7 @@ export class HotbarMenu extends React.Component<Props> {
source={item.entity.source}
menuItems={disabledMenuItems}
disabled
size={40}
/>
)}
</div>