Catalog & Hotbar - initial groundwork (#2418)

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen authored and GitHub committed 2021-04-09 09:11:58 +03:00
1 parent e18a041b13
commit 99a464c61d
109 files changed
+1604 -2612

No files matched your search

@@ -0,0 +1,135 @@
import { CatalogEntityRegistry } from "../catalog-entity-registry";
import "../../../common/catalog-entities";
import { catalogCategoryRegistry } from "../../../common/catalog-category-registry";
describe("CatalogEntityRegistry", () => {
describe("updateItems", () => {
it("adds new catalog item", () => {
const catalog = new CatalogEntityRegistry(catalogCategoryRegistry);
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {}
},
status: {
phase: "disconnected"
},
spec: {}
}];
catalog.updateItems(items);
expect(catalog.items.length).toEqual(1);
items.push({
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "456",
name: "barbaz",
source: "test",
labels: {}
},
status: {
phase: "disconnected"
},
spec: {}
});
catalog.updateItems(items);
expect(catalog.items.length).toEqual(2);
});
it("ignores unknown items", () => {
const catalog = new CatalogEntityRegistry(catalogCategoryRegistry);
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "FooBar",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {}
},
status: {
phase: "disconnected"
},
spec: {}
}];
catalog.updateItems(items);
expect(catalog.items.length).toEqual(0);
});
it("updates existing items", () => {
const catalog = new CatalogEntityRegistry(catalogCategoryRegistry);
const items = [{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {}
},
status: {
phase: "disconnected"
},
spec: {}
}];
catalog.updateItems(items);
expect(catalog.items.length).toEqual(1);
expect(catalog.items[0].status.phase).toEqual("disconnected");
items[0].status.phase = "connected";
catalog.updateItems(items);
expect(catalog.items.length).toEqual(1);
expect(catalog.items[0].status.phase).toEqual("connected");
});
it("removes deleted items", () => {
const catalog = new CatalogEntityRegistry(catalogCategoryRegistry);
const items = [
{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "123",
name: "foobar",
source: "test",
labels: {}
},
status: {
phase: "disconnected"
},
spec: {}
},
{
apiVersion: "entity.k8slens.dev/v1alpha1",
kind: "KubernetesCluster",
metadata: {
uid: "456",
name: "barbaz",
source: "test",
labels: {}
},
status: {
phase: "disconnected"
},
spec: {}
}
];
catalog.updateItems(items);
items.splice(0, 1);
catalog.updateItems(items);
expect(catalog.items.length).toEqual(1);
expect(catalog.items[0].metadata.uid).toEqual("456");
});
});
});
+4 -4
View File
@@ -145,10 +145,10 @@ function getDummyPod(opts: GetDummyPodOptions = getDummyPodDefaultOptions()): Po
describe("Pods", () => {
const podTests = [];
for (let r = 0; r < 10; r += 1) {
for (let d = 0; d < 10; d += 1) {
for (let ir = 0; ir < 10; ir += 1) {
for (let id = 0; id < 10; id += 1) {
for (let r = 0; r < 3; r += 1) {
for (let d = 0; d < 3; d += 1) {
for (let ir = 0; ir < 3; ir += 1) {
for (let id = 0; id < 3; id += 1) {
podTests.push([r, d, ir, id]);
}
}
@@ -0,0 +1,61 @@
import { action, observable } from "mobx";
import { broadcastMessage, subscribeToBroadcast } from "../../common/ipc";
import { CatalogCategory, CatalogEntity, CatalogEntityData } from "../../common/catalog-entity";
import { catalogCategoryRegistry, CatalogCategoryRegistry } from "../../common/catalog-category-registry";
import "../../common/catalog-entities";
export class CatalogEntityRegistry {
@observable protected _items: CatalogEntity[] = observable.array([], { deep: true });
constructor(private categoryRegistry: CatalogCategoryRegistry) {}
init() {
subscribeToBroadcast("catalog:items", (ev, items: CatalogEntityData[]) => {
this.updateItems(items);
});
broadcastMessage("catalog:broadcast");
}
@action updateItems(items: CatalogEntityData[]) {
this._items.forEach((item, index) => {
const foundIndex = items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid);
if (foundIndex === -1) {
this._items.splice(index, 1);
}
});
items.forEach((data) => {
const item = this.categoryRegistry.getEntityForData(data);
if (!item) return; // invalid data
const index = this._items.findIndex((i) => i.apiVersion === item.apiVersion && i.kind === item.kind && i.metadata.uid === item.metadata.uid);
if (index === -1) {
this._items.push(item);
} else {
this._items.splice(index, 1, item);
}
});
}
get items() {
return this._items;
}
getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {
const items = this._items.filter((item) => item.apiVersion === apiVersion && item.kind === kind);
return items as T[];
}
getItemsForCategory<T extends CatalogEntity>(category: CatalogCategory): T[] {
const supportedVersions = category.spec.versions.map((v) => `${category.spec.group}/${v.name}`);
const items = this._items.filter((item) => supportedVersions.includes(item.apiVersion) && item.kind === category.spec.names.kind);
return items as T[];
}
}
export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
+12
View File
@@ -0,0 +1,12 @@
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 const catalogEntityRunContext = {
navigate: (url: string) => navigate(url),
setCommandPaletteContext: (entity?: CatalogEntity) => {
commandRegistry.activeEntity = entity;
}
};
+4 -2
View File
@@ -62,7 +62,9 @@ export interface IKubeResourceList {
}
export interface IKubeApiCluster {
id: string;
metadata: {
uid: string;
}
}
export function forCluster<T extends KubeObject>(cluster: IKubeApiCluster, kubeClass: IKubeObjectConstructor<T>): KubeApi<T> {
@@ -71,7 +73,7 @@ export function forCluster<T extends KubeObject>(cluster: IKubeApiCluster, kubeC
debug: isDevelopment,
}, {
headers: {
"X-Cluster-ID": cluster.id
"X-Cluster-ID": cluster.metadata.uid
}
});
+2 -4
View File
@@ -10,11 +10,11 @@ import { clusterStore } from "../common/cluster-store";
import { userStore } from "../common/user-store";
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";
import { extensionLoader } from "../extensions/extension-loader";
import { extensionsStore } from "../extensions/extensions-store";
import { hotbarStore } from "../common/hotbar-store";
import { filesystemProvisionerStore } from "../main/extension-filesystem";
import { App } from "./components/app";
import { LensApp } from "./lens-app";
@@ -56,7 +56,7 @@ export async function bootstrap(App: AppComponent) {
// preload common stores
await Promise.all([
userStore.load(),
workspaceStore.load(),
hotbarStore.load(),
clusterStore.load(),
extensionsStore.load(),
filesystemProvisionerStore.load(),
@@ -65,7 +65,6 @@ export async function bootstrap(App: AppComponent) {
// Register additional store listeners
clusterStore.registerIpcListener();
workspaceStore.registerIpcListener();
// init app's dependencies if any
if (App.init) {
@@ -74,7 +73,6 @@ export async function bootstrap(App: AppComponent) {
window.addEventListener("message", (ev: MessageEvent) => {
if (ev.data === "teardown") {
userStore.unregisterIpcListener();
workspaceStore.unregisterIpcListener();
clusterStore.unregisterIpcListener();
unmountComponentAtNode(rootElem);
window.location.href = "about:blank";
@@ -12,11 +12,9 @@ import { Button } from "../button";
import { Icon } from "../icon";
import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers";
import { ClusterModel, ClusterStore, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid";
import { navigate } from "../../navigation";
import { userStore } from "../../../common/user-store";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
import { cssNames } from "../../utils";
import { Notifications } from "../notifications";
import { Tab, Tabs } from "../tabs";
@@ -24,6 +22,7 @@ import { ExecValidationNotFoundError } from "../../../common/custom-errors";
import { appEventBus } from "../../../common/event-bus";
import { PageLayout } from "../layout/page-layout";
import { docsUrl } from "../../../common/vars";
import { catalogURL } from "../+catalog";
enum KubeConfigSourceTab {
FILE = "file",
@@ -171,7 +170,6 @@ export class AddCluster extends React.Component {
return {
id: clusterId,
kubeConfigPath,
workspace: workspaceStore.currentWorkspaceId,
contextName: kubeConfig.currentContext,
preferences: {
clusterName: kubeConfig.currentContext,
@@ -183,18 +181,11 @@ export class AddCluster extends React.Component {
runInAction(() => {
clusterStore.addClusters(...newClusters);
if (newClusters.length === 1) {
const clusterId = newClusters[0].id;
Notifications.ok(
<>Successfully imported <b>{newClusters.length}</b> cluster(s)</>
);
clusterStore.setActive(clusterId);
navigate(clusterViewURL({ params: { clusterId } }));
} else {
if (newClusters.length > 1) {
Notifications.ok(
<>Successfully imported <b>{newClusters.length}</b> cluster(s)</>
);
}
}
navigate(catalogURL());
});
this.refreshContexts();
} catch (err) {
@@ -6,13 +6,13 @@ import { releaseURL } from "../+apps-releases";
commandRegistry.add({
id: "cluster.viewHelmCharts",
title: "Cluster: View Helm Charts",
scope: "cluster",
scope: "entity",
action: () => navigate(helmChartsURL())
});
commandRegistry.add({
id: "cluster.viewHelmReleases",
title: "Cluster: View Helm Releases",
scope: "cluster",
scope: "entity",
action: () => navigate(releaseURL())
});
@@ -0,0 +1,81 @@
import { action, computed, IReactionDisposer, observable, reaction } from "mobx";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity";
import { ItemObject, ItemStore } from "../../item.store";
import { autobind } from "../../utils";
import { CatalogCategory } from "../../../common/catalog-entity";
export class CatalogEntityItem implements ItemObject {
constructor(public entity: CatalogEntity) {}
get name() {
return this.entity.metadata.name;
}
getName() {
return this.entity.metadata.name;
}
get id() {
return this.entity.metadata.uid;
}
getId() {
return this.id;
}
@computed get phase() {
return this.entity.status.phase;
}
get labels() {
const labels: string[] = [];
Object.keys(this.entity.metadata.labels).forEach((key) => {
const value = this.entity.metadata.labels[key];
labels.push(`${key}=${value}`);
});
return labels;
}
get source() {
return this.entity.metadata.source || "unknown";
}
onRun(ctx: CatalogEntityActionContext) {
this.entity.onRun(ctx);
}
@action
async onContextMenuOpen(ctx: any) {
return this.entity.onContextMenuOpen(ctx);
}
}
@autobind()
export class CatalogEntityStore extends ItemStore<CatalogEntityItem> {
@observable activeCategory: CatalogCategory;
@computed get entities() {
if (!this.activeCategory) return [];
console.log("computing entities", this.activeCategory);
return catalogEntityRegistry.getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity));
}
watch() {
const disposers: IReactionDisposer[] = [
reaction(() => this.entities, () => this.loadAll()),
reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100})
];
return () => disposers.forEach((dispose) => dispose());
}
loadAll() {
return this.loadItems(() => this.entities);
}
}
@@ -0,0 +1,8 @@
import type { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const catalogRoute: RouteProps = {
path: "/catalog"
};
export const catalogURL = buildURL(catalogRoute.path);
@@ -0,0 +1,26 @@
.CatalogPage {
--width: 100%;
--height: 100%;
--nav-column-width: 230px;
text-align: left;
.sidebarRegion {
justify-content: flex-start;
}
.contentRegion {
.content {
padding: 20px 20px;
}
}
.TableCell.status {
&.connected {
color: var(--colorSuccess);
}
&.disconnected {
color: var(--halfGray);
}
}
}
@@ -0,0 +1,195 @@
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 { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store";
import { navigate } from "../../navigation";
import { kebabCase } from "lodash";
import { PageLayout } from "../layout/page-layout";
import { MenuItem, MenuActions } from "../menu";
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";
enum sortBy {
name = "name",
source = "source",
status = "status"
}
@observer
export class Catalog extends React.Component {
@observable private catalogEntityStore?: CatalogEntityStore;
@observable.deep private contextMenu: CatalogEntityContextMenuContext;
@observable activeTab: string;
async componentDidMount() {
this.contextMenu = {
menuItems: [],
navigate: (url: string) => navigate(url)
};
this.catalogEntityStore = new CatalogEntityStore();
disposeOnUnmount(this, [
this.catalogEntityStore.watch(),
reaction(() => catalogCategoryRegistry.items, (items) => {
if (!this.activeTab && items.length > 0) {
this.activeTab = items[0].getId();
this.catalogEntityStore.activeCategory = items[0];
}
}, { fireImmediately: true })
]);
setTimeout(() => {
if (this.catalogEntityStore.items.length === 0) {
Notifications.info(<><b>Welcome!</b><p>Get started by associating one or more clusters to Lens</p></>, {
timeout: 30_000,
id: "catalog-welcome"
});
}
}, 2_000);
}
addToHotbar(item: CatalogEntityItem) {
const hotbar = hotbarStore.getByName("default"); // FIXME
if (!hotbar) {
return;
}
hotbar.items.push({ entity: { uid: item.id }});
}
removeFromHotbar(item: CatalogEntityItem) {
const hotbar = hotbarStore.getByName("default"); // FIXME
if (!hotbar) {
return;
}
hotbar.items = hotbar.items.filter((i) => i.entity.uid !== item.id);
}
onDetails(item: CatalogEntityItem) {
item.onRun(catalogEntityRunContext);
}
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
if (menuItem.confirm) {
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
},
ok: () => {
menuItem.onClick();
},
message: menuItem.confirm.message
});
} else {
menuItem.onClick();
}
}
get categories() {
return catalogCategoryRegistry.items;
}
onTabChange = (tabId: string) => {
this.activeTab = tabId;
const activeCategory = this.categories.find((category) => category.getId() === tabId);
if (activeCategory) {
this.catalogEntityStore.activeCategory = activeCategory;
}
};
renderNavigation() {
return (
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<div className="header">Catalog</div>
{ this.categories.map((category, index) => {
return <Tab value={category.getId()} key={index} label={category.metadata.name} data-testid={`${category.getId()}-tab`} />;
})}
</Tabs>
);
}
@autobind()
renderItemMenu(item: CatalogEntityItem) {
const onOpen = async () => {
await item.onContextMenuOpen(this.contextMenu);
};
return (
<MenuActions onOpen={() => onOpen()}>
<MenuItem key="add-to-hotbar" onClick={() => this.addToHotbar(item) }>
<Icon material="add" small interactive={true} title="Add to hotbar"/> Add to Hotbar
</MenuItem>
<MenuItem key="remove-from-hotbar" onClick={() => this.removeFromHotbar(item) }>
<Icon material="clear" small interactive={true} title="Remove from hotbar"/> Remove from Hotbar
</MenuItem>
{ this.contextMenu.menuItems.map((menuItem, index) => {
return (
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>
<Icon material={menuItem.icon} small interactive={true} title={menuItem.title}/> {menuItem.title}
</MenuItem>
);
})}
</MenuActions>
);
}
render() {
if (!this.catalogEntityStore) {
return null;
}
return (
<PageLayout
className="CatalogPage"
navigation={this.renderNavigation()}
provideBackButtonNavigation={false}
contentGaps={false}>
<ItemListLayout
isClusterScoped
isSearchable={true}
isSelectable={false}
className="CatalogItemList"
store={this.catalogEntityStore}
tableId="catalog-items"
sortingCallbacks={{
[sortBy.name]: (item: CatalogEntityItem) => item.name,
[sortBy.source]: (item: CatalogEntityItem) => item.source,
[sortBy.status]: (item: CatalogEntityItem) => item.phase,
}}
renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name },
{ title: "Source", className: "source" },
{ title: "Labels", className: "labels" },
{ title: "Status", className: "status", sortBy: sortBy.status },
]}
renderTableContents={(item: CatalogEntityItem) => [
item.name,
item.source,
item.labels.map((label) => <Badge key={label} label={label} title={label} />),
{ title: item.phase, className: kebabCase(item.phase) }
]}
onDetails={(item: CatalogEntityItem) => this.onDetails(item) }
renderItemMenu={this.renderItemMenu}
addRemoveButtons={{
addTooltip: "Add Kubernetes Cluster",
onAdd: () => navigate(addClusterURL()),
}}
/>
</PageLayout>
);
}
}
@@ -0,0 +1,2 @@
export * from "./catalog.route";
export * from "./catalog";
@@ -12,5 +12,5 @@ commandRegistry.add({
clusterId: clusterStore.active.id
}
})),
isActive: (context) => !!context.cluster
isActive: (context) => !!context.entity
});
@@ -4,12 +4,9 @@ import React from "react";
import { reaction } from "mobx";
import { RouteComponentProps } from "react-router";
import { observer, disposeOnUnmount } from "mobx-react";
import { Features } from "./features";
import { Removal } from "./removal";
import { Status } from "./status";
import { General } from "./general";
import { Cluster } from "../../../main/cluster";
import { ClusterIcon } from "../cluster-icon";
import { IClusterSettingsRouteParams } from "./cluster-settings.route";
import { clusterStore } from "../../../common/cluster-store";
import { PageLayout } from "../layout/page-layout";
@@ -58,7 +55,6 @@ export class ClusterSettings extends React.Component<Props> {
if (!cluster) return null;
const header = (
<>
<ClusterIcon cluster={cluster} showErrors={false} showTooltip={false}/>
<h2>{cluster.preferences.clusterName}</h2>
</>
);
@@ -67,8 +63,6 @@ export class ClusterSettings extends React.Component<Props> {
<PageLayout className="ClusterSettings" header={header} showOnTop={true}>
<Status cluster={cluster}></Status>
<General cluster={cluster}></General>
<Features cluster={cluster}></Features>
<Removal cluster={cluster}></Removal>
</PageLayout>
);
}
@@ -1,79 +0,0 @@
import React from "react";
import { Cluster } from "../../../../main/cluster";
import { FilePicker, OverSizeLimitStyle } from "../../file-picker";
import { autobind } from "../../../utils";
import { Button } from "../../button";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { SubTitle } from "../../layout/sub-title";
import { ClusterIcon } from "../../cluster-icon";
enum GeneralInputStatus {
CLEAN = "clean",
ERROR = "error",
}
interface Props {
cluster: Cluster;
}
@observer
export class ClusterIconSetting extends React.Component<Props> {
@observable status = GeneralInputStatus.CLEAN;
@observable errorText?: string;
@autobind()
async onIconPick([file]: File[]) {
const { cluster } = this.props;
try {
if (file) {
const buf = Buffer.from(await file.arrayBuffer());
cluster.preferences.icon = `data:${file.type};base64,${buf.toString("base64")}`;
} else {
// this has to be done as a seperate branch (and not always) because `cluster`
// is observable and triggers an update loop.
cluster.preferences.icon = undefined;
}
} catch (e) {
this.errorText = e.toString();
this.status = GeneralInputStatus.ERROR;
}
}
getClearButton() {
if (this.props.cluster.preferences.icon) {
return <Button tooltip="Revert back to default icon" accent onClick={() => this.onIconPick([])}>Clear</Button>;
}
}
render() {
const label = (
<>
<ClusterIcon
cluster={this.props.cluster}
showErrors={false}
showTooltip={false}
/>
{"Browse for new icon..."}
</>
);
return (
<>
<SubTitle title="Cluster Icon" />
<p>Define cluster icon. By default automatically generated.</p>
<div className="file-loader">
<FilePicker
accept="image/*"
label={label}
onOverSizeLimit={OverSizeLimitStyle.FILTER}
handler={this.onIconPick}
/>
{this.getClearButton()}
</div>
</>
);
}
}
@@ -1,31 +0,0 @@
import React from "react";
import { observer } from "mobx-react";
import { workspaceStore } from "../../../../common/workspace-store";
import { Cluster } from "../../../../main/cluster";
import { Select } from "../../../components/select";
import { SubTitle } from "../../layout/sub-title";
interface Props {
cluster: Cluster;
}
@observer
export class ClusterWorkspaceSetting extends React.Component<Props> {
render() {
return (
<>
<SubTitle title="Cluster Workspace"/>
<p>
Define cluster workspace.
</p>
<Select
value={this.props.cluster.workspace}
onChange={({value}) => this.props.cluster.workspace = value}
options={workspaceStore.enabledWorkspacesList.map(w =>
({value: w.id, label: w.name})
)}
/>
</>
);
}
}
@@ -1,110 +0,0 @@
import React from "react";
import { observable, reaction, comparer } from "mobx";
import { observer, disposeOnUnmount } from "mobx-react";
import { Cluster } from "../../../../main/cluster";
import { Button } from "../../button";
import { Notifications } from "../../notifications";
import { Spinner } from "../../spinner";
import { ClusterFeature } from "../../../../extensions/cluster-feature";
import { interval } from "../../../utils";
interface Props {
cluster: Cluster
feature: ClusterFeature
}
@observer
export class InstallFeature extends React.Component<Props> {
@observable loading = false;
@observable message = "";
componentDidMount() {
const feature = this.props.feature;
const cluster = this.props.cluster;
const statusUpdate = interval(20, () => {
feature.updateStatus(cluster);
});
statusUpdate.start(true);
disposeOnUnmount(this, () => {
statusUpdate.stop();
});
disposeOnUnmount(this,
reaction(() => feature.status.installed, () => {
this.loading = false;
this.message = "";
}, { equals: comparer.structural })
);
}
getActionButtons() {
const { cluster, feature } = this.props;
const disabled = !cluster.isAdmin || this.loading;
const loadingIcon = this.loading ? <Spinner/> : null;
return (
<div className="flex gaps align-center">
{feature.status.canUpgrade &&
<Button
primary
disabled={disabled}
onClick={this.runAction(() =>
feature.upgrade(cluster)
)}
>
Upgrade
</Button>
}
{feature.status.installed &&
<Button
accent
disabled={disabled}
onClick={this.runAction(async () => {
this.message = "Uninstalling feature ...";
feature.uninstall(cluster);
})}
>
Uninstall
</Button>
}
{!feature.status.installed && !feature.status.canUpgrade &&
<Button
primary
disabled={disabled}
onClick={this.runAction(async () =>{
this.message = "Installing feature ...";
feature.install(cluster);
})}
>
Install
</Button>
}
{loadingIcon}
{!cluster.isAdmin && <span className='admin-note'>Actions can only be performed by admins.</span>}
{cluster.isAdmin && this.loading && this.message !== "" && <span className='admin-note'>{this.message}</span>}
</div>
);
}
runAction(action: () => Promise<any>): () => Promise<void> {
return async () => {
try {
this.loading = true;
await action();
} catch (err) {
Notifications.error(err.toString());
}
};
}
render() {
return (
<>
{this.props.children}
<div className="button-area">{this.getActionButtons()}</div>
</>
);
}
}
@@ -1,33 +0,0 @@
import React from "react";
import { Cluster } from "../../../main/cluster";
import { InstallFeature } from "./components/install-feature";
import { SubTitle } from "../layout/sub-title";
import { clusterFeatureRegistry } from "../../../extensions/registries/cluster-feature-registry";
interface Props {
cluster: Cluster;
}
export class Features extends React.Component<Props> {
render() {
const { cluster } = this.props;
return (
<div>
<h2>Features</h2>
{
clusterFeatureRegistry
.getItems()
.map((f) => (
<InstallFeature key={f.title} cluster={cluster} feature={f.feature}>
<>
<SubTitle title={f.title} />
<p><f.components.Description /></p>
</>
</InstallFeature>
))
}
</div>
);
}
}
@@ -1,8 +1,6 @@
import React from "react";
import { Cluster } from "../../../main/cluster";
import { ClusterNameSetting } from "./components/cluster-name-setting";
import { ClusterWorkspaceSetting } from "./components/cluster-workspace-setting";
import { ClusterIconSetting } from "./components/cluster-icon-setting";
import { ClusterProxySetting } from "./components/cluster-proxy-setting";
import { ClusterPrometheusSetting } from "./components/cluster-prometheus-setting";
import { ClusterHomeDirSetting } from "./components/cluster-home-dir-setting";
@@ -19,8 +17,6 @@ export class General extends React.Component<Props> {
return <div>
<h2>General</h2>
<ClusterNameSetting cluster={this.props.cluster} />
<ClusterWorkspaceSetting cluster={this.props.cluster} />
<ClusterIconSetting cluster={this.props.cluster} />
<ClusterProxySetting cluster={this.props.cluster} />
<ClusterPrometheusSetting cluster={this.props.cluster} />
<ClusterHomeDirSetting cluster={this.props.cluster} />
@@ -1,20 +0,0 @@
import React from "react";
import { Cluster } from "../../../main/cluster";
import { RemoveClusterButton } from "./components/remove-cluster-button";
interface Props {
cluster: Cluster;
}
export class Removal extends React.Component<Props> {
render() {
const { cluster } = this.props;
return (
<div>
<h2>Removal</h2>
<RemoveClusterButton cluster={cluster} />
</div>
);
}
}
@@ -10,41 +10,41 @@ import { pdbURL } from "../+config-pod-disruption-budgets";
commandRegistry.add({
id: "cluster.viewConfigMaps",
title: "Cluster: View ConfigMaps",
scope: "cluster",
scope: "entity",
action: () => navigate(configMapsURL())
});
commandRegistry.add({
id: "cluster.viewSecrets",
title: "Cluster: View Secrets",
scope: "cluster",
scope: "entity",
action: () => navigate(secretsURL())
});
commandRegistry.add({
id: "cluster.viewResourceQuotas",
title: "Cluster: View ResourceQuotas",
scope: "cluster",
scope: "entity",
action: () => navigate(resourceQuotaURL())
});
commandRegistry.add({
id: "cluster.viewLimitRanges",
title: "Cluster: View LimitRanges",
scope: "cluster",
scope: "entity",
action: () => navigate(limitRangeURL())
});
commandRegistry.add({
id: "cluster.viewHorizontalPodAutoscalers",
title: "Cluster: View HorizontalPodAutoscalers (HPA)",
scope: "cluster",
scope: "entity",
action: () => navigate(hpaURL())
});
commandRegistry.add({
id: "cluster.viewPodDisruptionBudget",
title: "Cluster: View PodDisruptionBudgets",
scope: "cluster",
scope: "entity",
action: () => navigate(pdbURL())
});
@@ -1,2 +0,0 @@
export * from "./landing-page.route";
export * from "./landing-page";
@@ -1,8 +0,0 @@
import type { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const landingRoute: RouteProps = {
path: "/landing"
};
export const landingURL = buildURL(landingRoute.path);
@@ -1,14 +0,0 @@
.PageLayout.LandingOverview {
--width: 100%;
--height: 100%;
text-align: center;
bottom: 22px; // Making bottom bar visible
.content-wrapper {
.content {
margin: unset;
max-width: unset;
height: 100%;
}
}
}
@@ -1,43 +0,0 @@
import "./landing-page.scss";
import React from "react";
import { computed, observable } from "mobx";
import { observer } from "mobx-react";
import { clusterStore } from "../../../common/cluster-store";
import { 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;
@computed
get clusters() {
return clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId);
}
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 showBackButton = this.clusters.length > 0;
const header = <><Icon svg="logo-lens" big /> <h2>{workspaceStore.currentWorkspace.name}</h2></>;
return (
<PageLayout className="LandingOverview flex" header={header} provideBackButtonNavigation={showBackButton} showOnTop={true}>
<WorkspaceOverview />
</PageLayout>
);
}
}
@@ -1,74 +0,0 @@
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>
);
}
}
@@ -1,79 +0,0 @@
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";
import { computed, reaction } from "mobx";
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;
}
@computed get clusters(): ClusterItem[] {
return clusterStore
.getByWorkspaceId(this.workspaceId)
.filter(cluster => cluster.enabled)
.map(cluster => new ClusterItem(cluster));
}
watch() {
return reaction(() => this.clusters, () => this.loadAll(), {
fireImmediately: true
});
}
loadAll() {
return this.loadItems(() => this.clusters);
}
async remove(clusterItem: ClusterItem) {
const { cluster: { isManaged, id: clusterId }} = clusterItem;
if (!isManaged) {
return super.removeItem(clusterItem, () => clusterStore.removeById(clusterId));
}
}
}
@@ -1,32 +0,0 @@
.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;
}
}
@@ -1,92 +0,0 @@
import "./workspace-overview.scss";
import React, { Component } from "react";
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";
import { IReactionDisposer, observable, reaction } from "mobx";
import { workspaceStore } from "../../../common/workspace-store";
enum sortBy {
name = "name",
distribution = "distribution",
version = "version",
online = "online"
}
@observer
export class WorkspaceOverview extends Component {
@observable private workspaceClusterStore?: WorkspaceClusterStore;
disposeWorkspaceWatch: IReactionDisposer;
disposeClustersWatch: IReactionDisposer;
componentDidMount() {
this.disposeWorkspaceWatch = reaction(() => workspaceStore.currentWorkspaceId, workspaceId => {
this.workspaceClusterStore = new WorkspaceClusterStore(workspaceId);
this.disposeClustersWatch?.();
this.disposeClustersWatch = this.workspaceClusterStore.watch();
}, {
fireImmediately: true,
});
}
componentWillUnmount() {
this.disposeWorkspaceWatch?.();
this.disposeClustersWatch?.();
}
showCluster = ({ clusterId }: ClusterItem) => {
navigate(clusterViewURL({ params: { clusterId } }));
};
render() {
const { workspaceClusterStore } = this;
if (!workspaceClusterStore) {
return null;
}
return (
<ItemListLayout
renderHeaderTitle="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) => (
<WorkspaceClusterMenu clusterItem={clusterItem} workspace={workspaceStore.currentWorkspace} workspaceClusterStore={workspaceClusterStore}/>
)}
/>
);
}
}
@@ -8,27 +8,27 @@ import { networkPoliciesURL } from "../+network-policies";
commandRegistry.add({
id: "cluster.viewServices",
title: "Cluster: View Services",
scope: "cluster",
scope: "entity",
action: () => navigate(servicesURL())
});
commandRegistry.add({
id: "cluster.viewEndpoints",
title: "Cluster: View Endpoints",
scope: "cluster",
scope: "entity",
action: () => navigate(endpointURL())
});
commandRegistry.add({
id: "cluster.viewIngresses",
title: "Cluster: View Ingresses",
scope: "cluster",
scope: "entity",
action: () => navigate(ingressURL())
});
commandRegistry.add({
id: "cluster.viewNetworkPolicies",
title: "Cluster: View NetworkPolicies",
scope: "cluster",
scope: "entity",
action: () => navigate(networkPoliciesURL())
});
@@ -5,6 +5,6 @@ import { nodesURL } from "./nodes.route";
commandRegistry.add({
id: "cluster.viewNodes",
title: "Cluster: View Nodes",
scope: "cluster",
scope: "entity",
action: () => navigate(nodesURL())
});
@@ -5,41 +5,41 @@ import { cronJobsURL, daemonSetsURL, deploymentsURL, jobsURL, podsURL, statefulS
commandRegistry.add({
id: "cluster.viewPods",
title: "Cluster: View Pods",
scope: "cluster",
scope: "entity",
action: () => navigate(podsURL())
});
commandRegistry.add({
id: "cluster.viewDeployments",
title: "Cluster: View Deployments",
scope: "cluster",
scope: "entity",
action: () => navigate(deploymentsURL())
});
commandRegistry.add({
id: "cluster.viewDaemonSets",
title: "Cluster: View DaemonSets",
scope: "cluster",
scope: "entity",
action: () => navigate(daemonSetsURL())
});
commandRegistry.add({
id: "cluster.viewStatefulSets",
title: "Cluster: View StatefulSets",
scope: "cluster",
scope: "entity",
action: () => navigate(statefulSetsURL())
});
commandRegistry.add({
id: "cluster.viewJobs",
title: "Cluster: View Jobs",
scope: "cluster",
scope: "entity",
action: () => navigate(jobsURL())
});
commandRegistry.add({
id: "cluster.viewCronJobs",
title: "Cluster: View CronJobs",
scope: "cluster",
scope: "entity",
action: () => navigate(cronJobsURL())
});
@@ -1,64 +0,0 @@
import React from "react";
import { observer } from "mobx-react";
import { Workspace, workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { Input, InputValidator } from "../input";
import { navigate } from "../../navigation";
import { CommandOverlay } from "../command-palette/command-container";
import { landingURL } from "../+landing-page";
import { clusterStore } from "../../../common/cluster-store";
const uniqueWorkspaceName: InputValidator = {
condition: ({ required }) => required,
message: () => `Workspace with this name already exists`,
validate: value => !workspaceStore.getByName(value),
};
@observer
export class AddWorkspace extends React.Component {
onSubmit(name: string) {
if (!name.trim()) {
return;
}
const workspace = workspaceStore.addWorkspace(new Workspace({
id: uuid(),
name
}));
if (!workspace) {
return;
}
workspaceStore.setActive(workspace.id);
clusterStore.setActive(null);
navigate(landingURL());
CommandOverlay.close();
}
render() {
return (
<>
<Input
placeholder="Workspace name"
autoFocus={true}
theme="round-black"
data-test-id="command-palette-workspace-add-name"
validators={[uniqueWorkspaceName]}
onSubmit={(v) => this.onSubmit(v)}
dirty={true}
showValidationLine={true} />
<small className="hint">
Please provide a new workspace name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel)
</small>
</>
);
}
}
commandRegistry.add({
id: "workspace.addWorkspace",
title: "Workspace: Add workspace ...",
scope: "global",
action: () => CommandOverlay.open(<AddWorkspace />)
});
@@ -1,82 +0,0 @@
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 (
<>
<Input
placeholder="Workspace name"
autoFocus={true}
theme="round-black"
data-test-id="command-palette-workspace-add-name"
validators={[validateWorkspaceName]}
onChange={(v) => this.onChange(v)}
onSubmit={(v) => this.onSubmit(v)}
dirty={true}
value={this.name}
showValidationLine={true} />
<small className="hint">
Please provide a new workspace name (Press &quot;Enter&quot; to confirm or &quot;Escape&quot; to cancel)
</small>
</>
);
}
}
commandRegistry.add({
id: "workspace.editCurrentWorkspace",
title: "Workspace: Edit current workspace ...",
scope: "global",
action: () => CommandOverlay.open(<EditWorkspace />),
isActive: (context) => context.workspace?.id !== WorkspaceStore.defaultId
});
@@ -1 +0,0 @@
export * from "./workspaces";
@@ -1,68 +0,0 @@
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: (
<div className="confirm flex column gaps">
<p>
Are you sure you want remove workspace <b>{workspace.name}</b>?
</p>
<p className="info">
All clusters within workspace will be cleared as well
</p>
</div>
),
});
}
render() {
return (
<Select
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
data-test-id="command-palette-workspace-remove-select"
placeholder="Remove workspace" />
);
}
}
commandRegistry.add({
id: "workspace.removeWorkspace",
title: "Workspace: Remove workspace ...",
scope: "global",
action: () => CommandOverlay.open(<RemoveWorkspace />)
});
@@ -1,99 +0,0 @@
import React from "react";
import { observer } from "mobx-react";
import { computed} from "mobx";
import { WorkspaceStore, workspaceStore } from "../../../common/workspace-store";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { Select } from "../select";
import { navigate } from "../../navigation";
import { CommandOverlay } from "../command-palette/command-container";
import { AddWorkspace } from "./add-workspace";
import { RemoveWorkspace } from "./remove-workspace";
import { EditWorkspace } from "./edit-workspace";
import { landingURL } from "../+landing-page";
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__";
@computed get options() {
const options = workspaceStore.enabledWorkspacesList.map((workspace) => {
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) {
options.push({ value: ChooseWorkspace.removeActionId, label: "Remove workspace ..." });
if (workspaceStore.currentWorkspace.id !== WorkspaceStore.defaultId) {
options.push({ value: ChooseWorkspace.editActionId, label: "Edit current workspace ..." });
}
}
return options;
}
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 />);
return;
}
if (id === ChooseWorkspace.removeActionId) {
CommandOverlay.open(<RemoveWorkspace />);
return;
}
if (id === ChooseWorkspace.editActionId) {
CommandOverlay.open(<EditWorkspace />);
return;
}
workspaceStore.setActive(id);
const clusterId = workspaceStore.getById(id).lastActiveClusterId;
if (clusterId) {
navigate(clusterViewURL({ params: { clusterId } }));
} else {
navigate(landingURL());
}
CommandOverlay.close();
}
render() {
return (
<Select
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Switch to workspace" />
);
}
}
commandRegistry.add({
id: "workspace.chooseWorkspace",
title: "Workspace: Switch to workspace ...",
scope: "global",
action: () => CommandOverlay.open(<ChooseWorkspace />)
});
+3 -11
View File
@@ -1,5 +1,5 @@
import React from "react";
import { computed, observable, reaction } from "mobx";
import { observable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { Redirect, Route, Router, Switch } from "react-router";
import { history } from "../navigation";
@@ -36,7 +36,7 @@ import { webFrame } from "electron";
import { clusterPageRegistry, getExtensionPageUrl } from "../../extensions/registries/page-registry";
import { extensionLoader } from "../../extensions/extension-loader";
import { appEventBus } from "../../common/event-bus";
import { broadcastMessage, requestMain } from "../../common/ipc";
import { requestMain } from "../../common/ipc";
import whatInput from "what-input";
import { clusterSetFrameIdHandler } from "../../common/cluster-ipc";
import { ClusterPageMenuRegistration, clusterPageMenuRegistry } from "../../extensions/registries";
@@ -86,20 +86,12 @@ export class App extends React.Component {
disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([podsStore, nodesStore, eventStore], {
preload: true,
}),
reaction(() => this.warningsTotal, (count: number) => {
broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count);
}),
})
]);
}
@observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL();
@computed get warningsTotal(): number {
return nodesStore.getWarningsCount() + eventStore.getWarningsCount();
}
getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) {
const routes: TabLayoutRoute[] = [];
@@ -1,38 +0,0 @@
.ClusterIcon {
--size: 37px;
position: relative;
border-radius: $radius;
padding: $radius;
user-select: none;
cursor: pointer;
&.interactive {
img {
opacity: .55;
}
}
&.active, &.interactive:hover {
background-color: #fff;
img {
opacity: 1;
}
}
img {
width: var(--size);
height: var(--size);
}
.Badge {
position: absolute;
right: 0;
bottom: 0;
margin: -$padding;
font-size: $font-size-small;
background: $colorError;
color: white;
}
}
@@ -1,81 +0,0 @@
import "./cluster-icon.scss";
import React, { DOMAttributes } from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { Params as HashiconParams } from "@emeraldpay/hashicon";
import { Hashicon } from "@emeraldpay/hashicon-react";
import { Cluster } from "../../../main/cluster";
import { cssNames, IClassName } from "../../utils";
import { Badge } from "../badge";
import { Tooltip } from "../tooltip";
import { subscribeToBroadcast } from "../../../common/ipc";
import { observable } from "mobx";
interface Props extends DOMAttributes<HTMLElement> {
cluster: Cluster;
className?: IClassName;
errorClass?: IClassName;
showErrors?: boolean;
showTooltip?: boolean;
interactive?: boolean;
isActive?: boolean;
options?: HashiconParams;
}
const defaultProps: Partial<Props> = {
showErrors: true,
showTooltip: true,
};
@observer
export class ClusterIcon extends React.Component<Props> {
static defaultProps = defaultProps as object;
@observable eventCount = 0;
get eventCountBroadcast() {
return `cluster-warning-event-count:${this.props.cluster.id}`;
}
componentDidMount() {
const subscriber = subscribeToBroadcast(this.eventCountBroadcast, (ev, eventCount) => {
this.eventCount = eventCount;
});
disposeOnUnmount(this, [
subscriber
]);
}
render() {
const {
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
children, ...elemProps
} = this.props;
const { name, preferences, id: clusterId, online } = cluster;
const eventCount = this.eventCount;
const { icon } = preferences;
const clusterIconId = `cluster-icon-${clusterId}`;
const className = cssNames("ClusterIcon flex inline", this.props.className, {
interactive: interactive !== undefined ? interactive : !!this.props.onClick,
active: isActive,
});
return (
<div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
{showTooltip && (
<Tooltip targetId={clusterIconId}>{name}</Tooltip>
)}
{icon && <img src={icon} alt={name}/>}
{!icon && <Hashicon value={clusterId} options={options}/>}
{showErrors && eventCount > 0 && !isActive && online && (
<Badge
className={cssNames("events-count", errorClass)}
label={eventCount >= 1000 ? `${Math.ceil(eventCount / 1000)}k+` : eventCount}
/>
)}
{children}
</div>
);
}
}
@@ -1 +0,0 @@
export * from "./cluster-icon";
@@ -6,7 +6,7 @@
padding: 0 2px;
height: var(--bottom-bar-height);
#current-workspace {
#catalog-link {
font-size: var(--font-size-small);
color: white;
padding: $padding / 4 $padding / 2;
@@ -2,11 +2,10 @@ import "./bottom-bar.scss";
import React from "react";
import { observer } from "mobx-react";
import { Icon } from "../icon";
import { workspaceStore } from "../../../common/workspace-store";
import { StatusBarRegistration, statusBarRegistry } from "../../../extensions/registries";
import { CommandOverlay } from "../command-palette/command-container";
import { ChooseWorkspace } from "../+workspaces";
import { navigate } from "../../navigation";
import { catalogURL } from "../+catalog";
import { Icon } from "../icon";
@observer
export class BottomBar extends React.Component {
@@ -45,13 +44,11 @@ export class BottomBar extends React.Component {
}
render() {
const { currentWorkspace } = workspaceStore;
return (
<div className="BottomBar flex gaps">
<div id="current-workspace" data-test-id="current-workspace" className="flex gaps align-center" onClick={() => CommandOverlay.open(<ChooseWorkspace />)}>
<Icon smallest material="layers"/>
<span className="workspace-name" data-test-id="current-workspace-name">{currentWorkspace.name}</span>
<div id="catalog-link" data-test-id="catalog-link" className="flex gaps align-center" onClick={() => navigate(catalogURL())}>
<Icon smallest material="view_list"/>
<span className="workspace-name" data-test-id="current-workspace-name">Catalog</span>
</div>
{this.renderRegisteredItems()}
</div>
@@ -1,7 +1,7 @@
import React from "react";
import uniqueId from "lodash/uniqueId";
import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page";
import { catalogURL } from "../+catalog";
import { clusterStore } from "../../../common/cluster-store";
import { broadcastMessage, requestMain } from "../../../common/ipc";
@@ -25,7 +25,7 @@ export const ClusterActions = (cluster: Cluster) => ({
})),
disconnect: async () => {
clusterStore.deactivate(cluster.id);
navigate(landingURL());
navigate(catalogURL());
await requestMain(clusterDisconnectHandler, cluster.id);
},
remove: () => {
@@ -40,7 +40,7 @@ export const ClusterActions = (cluster: Cluster) => ({
ok: () => {
clusterStore.deactivate(cluster.id);
clusterStore.removeById(cluster.id);
navigate(landingURL());
navigate(catalogURL());
},
message: <p>
Are you sure want to remove cluster <b id={tooltipId}>{cluster.name}</b>?
@@ -13,7 +13,7 @@
display: flex;
}
.ClustersMenu {
.HotbarMenu {
grid-area: menu;
}
@@ -34,4 +34,4 @@
flex: 1;
}
}
}
}
@@ -4,19 +4,19 @@ import React from "react";
import { Redirect, Route, Switch } from "react-router";
import { comparer, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { ClustersMenu } from "./clusters-menu";
import { BottomBar } from "./bottom-bar";
import { LandingPage, landingRoute, landingURL } from "../+landing-page";
import { Catalog, catalogRoute, catalogURL } from "../+catalog";
import { Preferences, preferencesRoute } from "../+preferences";
import { AddCluster, addClusterRoute } from "../+add-cluster";
import { ClusterView } from "./cluster-view";
import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
import { clusterViewRoute, clusterViewURL } from "./cluster-view.route";
import { clusterViewRoute } from "./cluster-view.route";
import { clusterStore } from "../../../common/cluster-store";
import { hasLoadedView, initView, lensViews, refreshViews } from "./lens-views";
import { globalPageRegistry } from "../../../extensions/registries/page-registry";
import { Extensions, extensionsRoute } from "../+extensions";
import { getMatchedClusterId } from "../../navigation";
import { HotbarMenu } from "../hotbar/hotbar-menu";
@observer
export class ClusterManager extends React.Component {
@@ -44,17 +44,7 @@ export class ClusterManager extends React.Component {
}
get startUrl() {
const { activeClusterId } = clusterStore;
if (activeClusterId) {
return clusterViewURL({
params: {
clusterId: activeClusterId
}
});
}
return landingURL();
return catalogURL();
}
render() {
@@ -63,7 +53,7 @@ export class ClusterManager extends React.Component {
<main>
<div id="lens-views"/>
<Switch>
<Route component={LandingPage} {...landingRoute} />
<Route component={Catalog} {...catalogRoute} />
<Route component={Preferences} {...preferencesRoute} />
<Route component={Extensions} {...extensionsRoute} />
<Route component={AddCluster} {...addClusterRoute} />
@@ -75,7 +65,7 @@ export class ClusterManager extends React.Component {
<Redirect exact to={this.startUrl}/>
</Switch>
</main>
<ClustersMenu/>
<HotbarMenu/>
<BottomBar/>
</div>
);
@@ -8,6 +8,8 @@ import { ClusterStatus } from "./cluster-status";
import { hasLoadedView } from "./lens-views";
import { Cluster } from "../../../main/cluster";
import { clusterStore } from "../../../common/cluster-store";
import { navigate } from "../../navigation";
import { catalogURL } from "../+catalog";
interface Props extends RouteComponentProps<IClusterViewRouteParams> {
}
@@ -26,6 +28,9 @@ export class ClusterView extends React.Component<Props> {
disposeOnUnmount(this, [
reaction(() => this.clusterId, clusterId => clusterStore.setActive(clusterId), {
fireImmediately: true,
}),
reaction(() => this.cluster.online, (online) => {
if (!online) navigate(catalogURL());
})
]);
}
@@ -1,67 +0,0 @@
.ClustersMenu {
$spacing: $padding * 2;
position: relative;
text-align: center;
background: $clusterMenuBackground;
border-right: 1px solid $clusterMenuBorderColor;
padding: $spacing 0;
min-width: 75px;
.is-mac &:before {
content: "";
height: 20px; // extra spacing for mac-os "traffic-light" buttons
}
.clusters {
@include hidden-scrollbar;
padding: 0 $spacing; // extra spacing for cluster-icon's badge
margin-bottom: $margin;
.ClusterIcon {
margin-bottom: $margin * 1.5;
}
&:empty {
display: none;
}
}
> .WorkspaceMenu {
position: relative;
margin-bottom: $margin;
.Icon {
margin-bottom: $margin * 1.5;
border-radius: $radius;
padding: $padding / 3;
color: var(--textColorPrimary);
background: unset;
cursor: pointer;
&.active {
opacity: 1;
}
&:hover {
box-shadow: none;
color: var(--textColorAccent);
background-color: unset;
}
}
}
> .extensions {
&:not(:empty) {
padding-top: $spacing;
}
.Icon {
--size: 40px;
}
}
}
.Menu.WorkspaceMenu {
z-index: 2; // Place behind Preferences, Extension pages etc...
}
@@ -1,198 +0,0 @@
import "./clusters-menu.scss";
import React from "react";
import { remote } from "electron";
import type { Cluster } from "../../../main/cluster";
import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd";
import { observer } from "mobx-react";
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 { isActiveRoute, navigate } from "../../navigation";
import { addClusterURL } from "../+add-cluster";
import { landingURL } from "../+landing-page";
import { clusterViewURL } from "./cluster-view.route";
import { ClusterActions } from "./cluster-actions";
import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { CommandOverlay } from "../command-palette/command-container";
import { computed, observable } from "mobx";
import { Select } from "../select";
import { Menu, MenuItem } from "../menu";
interface Props {
className?: IClassName;
}
@observer
export class ClustersMenu extends React.Component<Props> {
@observable workspaceMenuVisible = false;
showCluster = (clusterId: ClusterId) => {
navigate(clusterViewURL({ params: { clusterId } }));
};
showContextMenu = (cluster: Cluster) => {
const { Menu, MenuItem } = remote;
const menu = new Menu();
const actions = ClusterActions(cluster);
menu.append(new MenuItem({
label: `Settings`,
click: actions.showSettings
}));
if (cluster.online) {
menu.append(new MenuItem({
label: `Disconnect`,
click: actions.disconnect
}));
}
if (!cluster.isManaged) {
menu.append(new MenuItem({
label: `Remove`,
click: actions.remove
}));
}
menu.popup({
window: remote.getCurrentWindow()
});
};
@autobind()
swapClusterIconOrder(result: DropResult) {
if (result.reason === "DROP") {
const { currentWorkspaceId } = workspaceStore;
const {
source: { index: from },
destination: { index: to },
} = result;
clusterStore.swapIconOrders(currentWorkspaceId, from, to);
}
}
render() {
const { className } = this.props;
const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId);
const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled);
const activeClusterId = clusterStore.activeCluster;
return (
<div className={cssNames("ClustersMenu flex column", className)}>
<div className="clusters flex column gaps">
<DragDropContext onDragEnd={this.swapClusterIconOrder}>
<Droppable droppableId="cluster-menu" type="CLUSTER">
{({ innerRef, droppableProps, placeholder }: DroppableProvided) => (
<div ref={innerRef} {...droppableProps}>
{clusters.map((cluster, index) => {
const isActive = cluster.id === activeClusterId;
return (
<Draggable draggableId={cluster.id} index={index} key={cluster.id}>
{({ draggableProps, dragHandleProps, innerRef }: DraggableProvided) => (
<div ref={innerRef} {...draggableProps} {...dragHandleProps}>
<ClusterIcon
key={cluster.id}
showErrors={true}
cluster={cluster}
isActive={isActive}
onClick={() => this.showCluster(cluster.id)}
onContextMenu={() => this.showContextMenu(cluster)}
/>
</div>
)}
</Draggable>
);
})}
{placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
<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 } }) => {
const registeredPage = globalPageRegistry.getByPageTarget(target);
if (!registeredPage){
return;
}
const pageUrl = getExtensionPageUrl(target);
const isActive = isActiveRoute(registeredPage.url);
return (
<Icon
key={pageUrl}
tooltip={title}
active={isActive}
onClick={() => navigate(pageUrl)}
/>
);
})}
</div>
</div>
);
}
}
@observer
export class ChooseCluster extends React.Component {
@computed get options() {
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId).filter(cluster => cluster.enabled);
const options = clusters.map((cluster) => {
return { value: cluster.id, label: cluster.name };
});
return options;
}
onChange(clusterId: string) {
navigate(clusterViewURL({ params: { clusterId } }));
CommandOverlay.close();
}
render() {
return (
<Select
onChange={(v) => this.onChange(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Switch to cluster" />
);
}
}
commandRegistry.add({
id: "workspace.chooseCluster",
title: "Workspace: Switch to cluster ...",
scope: "global",
action: () => CommandOverlay.open(<ChooseCluster />)
});
@@ -8,8 +8,6 @@ import { EventEmitter } from "../../../common/event-emitter";
import { subscribeToBroadcast } from "../../../common/ipc";
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";
export type CommandDialogEvent = {
component: React.ReactElement
@@ -49,8 +47,7 @@ export class CommandContainer extends React.Component<{ clusterId?: string }> {
private runCommand(command: CommandRegistration) {
command.action({
cluster: clusterStore.active,
workspace: workspaceStore.currentWorkspace
entity: commandRegistry.activeEntity
});
}
@@ -5,7 +5,6 @@ import { observer } from "mobx-react";
import React from "react";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { CommandOverlay } from "./command-container";
import { broadcastMessage } from "../../../common/ipc";
import { navigate } from "../../navigation";
@@ -17,12 +16,11 @@ export class CommandDialog extends React.Component {
@computed get options() {
const context = {
cluster: clusterStore.active,
workspace: workspaceStore.currentWorkspace
entity: commandRegistry.activeEntity
};
return commandRegistry.getItems().filter((command) => {
if (command.scope === "cluster" && !clusterStore.active) {
if (command.scope === "entity" && !clusterStore.active) {
return false;
}
@@ -56,16 +54,15 @@ export class CommandDialog extends React.Component {
if (command.scope === "global") {
action({
cluster: clusterStore.active,
workspace: workspaceStore.currentWorkspace
entity: commandRegistry.activeEntity
});
} else if(clusterStore.active) {
} else if(commandRegistry.activeEntity) {
navigate(clusterViewURL({
params: {
clusterId: clusterStore.active.id
clusterId: commandRegistry.activeEntity.metadata.uid
}
}));
broadcastMessage(`command-palette:run-action:${clusterStore.active.id}`, command.id);
broadcastMessage(`command-palette:run-action:${commandRegistry.activeEntity.metadata.uid}`, command.id);
}
} catch(error) {
console.error("[COMMAND-DIALOG] failed to execute command", command.id, error);
+2 -2
View File
@@ -136,7 +136,7 @@ export class Dock extends React.Component<Props> {
commandRegistry.add({
id: "cluster.openTerminal",
title: "Cluster: Open terminal",
scope: "cluster",
scope: "entity",
action: () => createTerminalTab(),
isActive: (context) => !!context.cluster
isActive: (context) => !!context.entity
});
@@ -39,7 +39,7 @@ export class ErrorBoundary extends React.Component<Props, State> {
if (error) {
const slackLink = <a href={slackUrl} rel="noreferrer" target="_blank">Slack</a>;
const githubLink = <a href={issuesTrackerUrl} rel="noreferrer" target="_blank">Github</a>;
const pageUrl = location.href;
const pageUrl = location.pathname;
return (
<div className="ErrorBoundary flex column gaps">
@@ -0,0 +1,58 @@
.HotbarMenu {
.HotbarIcon {
--size: 37px;
position: relative;
border-radius: 8px;
padding: 2px;
user-select: none;
cursor: pointer;
div.MuiAvatar-colorDefault {
font-weight:500;
text-transform: uppercase;
border-radius: 4px;
}
div.active {
background-color: var(--primary);
}
div.default {
background-color: var(--halfGray);
}
&.active {
margin-left: -3px;
border: 3px solid #fff;
}
&.active, &.interactive:hover {
div {
background-color: var(--primary);
}
img {
opacity: 1;
}
}
img {
width: var(--size);
height: var(--size);
}
}
.HotbarIconMenu {
left: 30px;
min-width: 250px;
ul {
li {
font-size: 12px;
}
}
}
}
@@ -0,0 +1,132 @@
import "./hotbar-icon.scss";
import React, { DOMAttributes } from "react";
import { observer } from "mobx-react";
import { cssNames, IClassName } from "../../utils";
import { Tooltip } from "../tooltip";
import { Avatar } from "@material-ui/core";
import { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../../common/catalog-entity";
import { Menu, MenuItem } from "../menu";
import { Icon } from "../icon";
import { observable } from "mobx";
import { navigate } from "../../navigation";
import { hotbarStore } from "../../../common/hotbar-store";
import { ConfirmDialog } from "../confirm-dialog";
interface Props extends DOMAttributes<HTMLElement> {
entity: CatalogEntity;
index: number;
className?: IClassName;
errorClass?: IClassName;
isActive?: boolean;
}
@observer
export class HotbarIcon extends React.Component<Props> {
@observable.deep private contextMenu: CatalogEntityContextMenuContext;
@observable menuOpen = false;
componentDidMount() {
this.contextMenu = {
menuItems: [],
navigate: (url: string) => navigate(url)
};
}
get iconString() {
let splittedName = this.props.entity.metadata.name.split(" ");
if (splittedName.length === 1) {
splittedName = splittedName[0].split("-");
}
if (splittedName.length === 1) {
splittedName = splittedName[0].split("@");
}
splittedName = splittedName.map((part) => part.replace(/\W/g, ""));
if (splittedName.length === 1) {
return splittedName[0].substring(0, 2);
} else if (splittedName.length === 2) {
return splittedName[0].substring(0, 1) + splittedName[1].substring(0, 1);
} else {
return splittedName[0].substring(0, 1) + splittedName[1].substring(0, 1) + splittedName[2].substring(0, 1);
}
}
toggleMenu() {
this.menuOpen = !this.menuOpen;
}
removeFromHotbar(item: CatalogEntity) {
const hotbar = hotbarStore.getByName("default"); // FIXME
if (!hotbar) {
return;
}
hotbar.items = hotbar.items.filter((i) => i.entity.uid !== item.metadata.uid);
}
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
if (menuItem.confirm) {
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
},
ok: () => {
menuItem.onClick();
},
message: menuItem.confirm.message
});
} else {
menuItem.onClick();
}
}
render() {
const {
entity, errorClass, isActive,
children, ...elemProps
} = this.props;
const entityIconId = `hotbar-icon-${this.props.index}`;
const className = cssNames("HotbarIcon flex inline", this.props.className, {
interactive: true,
active: isActive,
});
const onOpen = async () => {
await entity.onContextMenuOpen(this.contextMenu);
this.toggleMenu();
};
return (
<div className={className}>
<Tooltip targetId={entityIconId}>{entity.metadata.name}</Tooltip>
<Avatar {...elemProps} id={entityIconId} variant="square" className={isActive ? "active" : "default"}>{this.iconString}</Avatar>
<Menu
usePortal={false}
htmlFor={entityIconId}
className="HotbarIconMenu"
isOpen={this.menuOpen}
toggleEvent="contextmenu"
position={{right: true, bottom: true }} // FIXME: position does not work
open={() => onOpen()}
close={() => this.toggleMenu()}>
<MenuItem key="remove-from-hotbar" onClick={() => this.removeFromHotbar(entity) }>
<Icon material="clear" small interactive={true} title="Remove from hotbar"/> Remove from Hotbar
</MenuItem>
{ this.contextMenu && this.contextMenu.menuItems.map((menuItem) => {
return (
<MenuItem key={menuItem.title} onClick={() => this.onMenuItemClick(menuItem) }>
<Icon material={menuItem.icon} small interactive={true} title={menuItem.title}/> {menuItem.title}
</MenuItem>
);
})}
</Menu>
{children}
</div>
);
}
}
@@ -0,0 +1,25 @@
.HotbarMenu {
$spacing: $padding * 2;
position: relative;
text-align: center;
background: $clusterMenuBackground;
border-right: 1px solid $clusterMenuBorderColor;
padding: $spacing 0;
min-width: 75px;
.is-mac &:before {
content: "";
height: 20px; // extra spacing for mac-os "traffic-light" buttons
}
.items {
padding: 0 $spacing; // extra spacing for cluster-icon's badge
margin-bottom: $margin;
overflow: visible;
&:empty {
display: none;
}
}
}
@@ -0,0 +1,46 @@
import "./hotbar-menu.scss";
import React from "react";
import { observer } from "mobx-react";
import { HotbarIcon } from "./hotbar-icon";
import { cssNames, IClassName } from "../../utils";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { hotbarStore } from "../../../common/hotbar-store";
import { catalogEntityRunContext } from "../../api/catalog-entity";
interface Props {
className?: IClassName;
}
@observer
export class HotbarMenu extends React.Component<Props> {
render() {
const { className } = this.props;
const hotbar = hotbarStore.getByName("default"); // FIXME
if (!hotbar) {
return null;
}
const items = hotbar.items.map((item) => catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid)).filter(Boolean);
return (
<div className={cssNames("HotbarMenu flex column", className)}>
<div className="items flex column gaps">
{items.map((entity, index) => {
return (
<HotbarIcon
key={index}
index={index}
entity={entity}
isActive={entity.status.active}
onClick={() => entity.onRun(catalogEntityRunContext)}
/>
);
})}
</div>
</div>
);
}
}
@@ -1,25 +1,16 @@
jest.mock("../../../../common/ipc");
import React from "react";
import { fireEvent, render } from "@testing-library/react";
import { render } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { MainLayoutHeader } from "../main-layout-header";
import { Cluster } from "../../../../main/cluster";
import { workspaceStore } from "../../../../common/workspace-store";
import { broadcastMessage, requestMain } from "../../../../common/ipc";
import { clusterDisconnectHandler } from "../../../../common/cluster-ipc";
import { ConfirmDialog } from "../../confirm-dialog";
import { IpcRendererNavigationEvents } from "../../../navigation/events";
const mockBroadcastIpc = broadcastMessage as jest.MockedFunction<typeof broadcastMessage>;
const mockRequestMain = requestMain as jest.MockedFunction<typeof requestMain>;
const cluster: Cluster = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId,
});
describe("<MainLayoutHeader />", () => {
@@ -29,73 +20,9 @@ describe("<MainLayoutHeader />", () => {
expect(container).toBeInstanceOf(HTMLElement);
});
it("renders three dots icon", () => {
const { container } = render(<MainLayoutHeader cluster={cluster} />);
const icon = container.querySelector(".Icon .icon");
expect(icon).toBeInstanceOf(HTMLElement);
expect(icon).toHaveTextContent("more_vert");
});
it("renders cluster name", () => {
const { getByText } = render(<MainLayoutHeader cluster={cluster} />);
expect(getByText("minikube")).toBeInTheDocument();
});
describe("Cluster Actions Menu", () => {
let settingsBtn: Element;
let disconnectBtn: Element;
let removeBtn: Element;
beforeEach(() => {
const { container } = render(<div>
<MainLayoutHeader cluster={cluster} />
<ConfirmDialog />
</div>);
const icon = container.querySelector(".Icon");
cluster.online = true;
fireEvent.click(icon);
[settingsBtn, disconnectBtn, removeBtn] = Array.from(document.querySelectorAll("ul.ClusterActionsMenu > li"))
.map(el => el.querySelector("span"));
});
afterEach(() => {
cluster.online = false;
});
it("renders cluster menu items", () => {
expect(settingsBtn).toBeDefined();
expect(settingsBtn.textContent).toBe("Settings");
expect(disconnectBtn).toBeDefined();
expect(disconnectBtn.textContent).toBe("Disconnect");
expect(removeBtn).toBeDefined();
expect(removeBtn.textContent).toBe("Remove");
});
it("navigates to cluster settings", () => {
fireEvent.click(settingsBtn);
expect(mockBroadcastIpc).toBeCalledWith(IpcRendererNavigationEvents.NAVIGATE_IN_APP, "/cluster/foo/settings");
});
it("disconnects from cluster", () => {
fireEvent.click(disconnectBtn);
expect(mockRequestMain).toBeCalledWith(clusterDisconnectHandler, cluster.id);
});
it("opens 'Remove cluster' dialog", async () => {
fireEvent.click(removeBtn);
const dialog = document.querySelector(".ConfirmDialog");
expect(dialog).toBeDefined();
expect(dialog).not.toBe(null);
const okBtn = dialog.querySelector("button.ok");
expect(okBtn.textContent).toBe("Remove");
});
});
});
@@ -1,10 +1,7 @@
import React from "react";
import { observer } from "mobx-react";
import { ClusterActions } from "../cluster-manager";
import { Cluster } from "../../../main/cluster";
import { cssNames } from "../../utils";
import { MenuActions, MenuItem } from "../menu";
interface Props {
cluster: Cluster
@@ -12,28 +9,9 @@ interface Props {
}
export const MainLayoutHeader = observer(({ cluster, className }: Props) => {
const actions = ClusterActions(cluster);
const renderMenu = () => (
<MenuActions autoCloseOnSelect className="ClusterActionsMenu">
<MenuItem onClick={actions.showSettings}>
<span>Settings</span>
</MenuItem>
<MenuItem onClick={actions.disconnect}>
<span>Disconnect</span>
</MenuItem>
{
!cluster.isManaged && (
<MenuItem onClick={actions.remove}>
<span>Remove</span>
</MenuItem>
)
}
</MenuActions>);
return (
<header className={cssNames("flex gaps align-center justify-space-between", className)}>
<span className="cluster">{cluster.name}</span>
{renderMenu()}
</header>
);
});
+10 -7
View File
@@ -76,14 +76,17 @@ export class PageLayout extends React.Component<PageLayoutProps> {
{children}
</div>
<div className="toolsRegion">
<div className="fixedTools">
<div className="closeBtn" role="button" aria-label="Close" onClick={this.back}>
<Icon material="close"/>
{ this.props.provideBackButtonNavigation && (
<div className="fixedTools">
<div className="closeBtn" role="button" aria-label="Close" onClick={this.back}>
<Icon material="close"/>
</div>
<div className="esc" aria-hidden="true">
ESC
</div>
</div>
<div className="esc" aria-hidden="true">
ESC
</div>
</div>
)}
</div>
</div>
</div>
@@ -18,6 +18,7 @@ export interface MenuActionsProps extends Partial<MenuProps> {
removeConfirmationMessage?: React.ReactNode | (() => React.ReactNode);
updateAction?(): void;
removeAction?(): void;
onOpen?(): void;
}
@observer
@@ -70,6 +71,10 @@ export class MenuActions extends React.Component<MenuActionsProps> {
...(typeof triggerIcon === "object" ? triggerIcon : {}),
};
if (this.props.onOpen) {
iconProps.onClick = this.props.onOpen;
}
if (iconProps.tooltip && this.isOpen) {
delete iconProps.tooltip; // don't show tooltip for icon when menu is open
}
+16
View File
@@ -16,10 +16,14 @@ import { LensProtocolRouterRenderer, bindProtocolAddRouteHandlers } from "./prot
import { registerIpcHandlers } from "./ipc";
import { ipcRenderer } from "electron";
import { IpcRendererNavigationEvents } from "./navigation/events";
import { catalogEntityRegistry } from "./api/catalog-entity-registry";
import { commandRegistry } from "../extensions/registries";
import { reaction } from "mobx";
@observer
export class LensApp extends React.Component {
static async init() {
catalogEntityRegistry.init();
extensionLoader.loadOnClusterManagerRenderer();
LensProtocolRouterRenderer.getInstance<LensProtocolRouterRenderer>().init();
bindProtocolAddRouteHandlers();
@@ -34,6 +38,18 @@ export class LensApp extends React.Component {
ipcRenderer.send(IpcRendererNavigationEvents.LOADED);
}
componentDidMount() {
reaction(() => catalogEntityRegistry.items, (items) => {
if (!commandRegistry.activeEntity) {
return;
}
if (!items.includes(commandRegistry.activeEntity)) {
commandRegistry.activeEntity = null;
}
});
}
render() {
return (
<Router history={history}>
+3 -14
View File
@@ -1,13 +1,12 @@
import { addClusterURL } from "../components/+add-cluster";
import { clusterSettingsURL } from "../components/+cluster-settings";
import { extensionsURL } from "../components/+extensions";
import { landingURL } from "../components/+landing-page";
import { catalogURL } from "../components/+catalog";
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
@@ -16,18 +15,10 @@ export function bindProtocolAddRouteHandlers() {
navigate(preferencesURL({ fragment: highlight }));
})
.addInternalHandler("/", () => {
navigate(landingURL());
navigate(catalogURL());
})
.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 });
}
navigate(catalogURL());
})
.addInternalHandler("/cluster", () => {
navigate(addClusterURL());
@@ -36,7 +27,6 @@ export function bindProtocolAddRouteHandlers() {
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 });
@@ -46,7 +36,6 @@ export function bindProtocolAddRouteHandlers() {
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 });