mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Catalog & Hotbar - initial groundwork (#2418)
Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 "Enter" to confirm or "Escape" 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 "Enter" to confirm or "Escape" 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 />)
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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,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}>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user