diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts
index 36173c0e9d..64ca60380d 100644
--- a/integration/__tests__/cluster-pages.tests.ts
+++ b/integration/__tests__/cluster-pages.tests.ts
@@ -331,6 +331,14 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
await cleanup();
}, 10*60*1000);
+ it("shows cluster context menu in sidebar", async () => {
+ await frame.click(`[data-testid="sidebar-cluster-dropdown"]`);
+ await frame.waitForSelector(`.Menu >> text="Add to Hotbar"`);
+ await frame.waitForSelector(`.Menu >> text="Settings"`);
+ await frame.waitForSelector(`.Menu >> text="Disconnect"`);
+ await frame.waitForSelector(`.Menu >> text="Delete"`);
+ });
+
it("should navigate around common cluster pages", async () => {
for (const test of commonPageTests) {
if (isTopPageTest(test)) {
@@ -362,6 +370,8 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
}
}, 10*60*1000);
+
+
it("show logs and highlight the log search entries", async () => {
await frame.click(`a[href="/workloads"]`);
await frame.click(`a[href="/pods"]`);
diff --git a/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx
new file mode 100644
index 0000000000..195368408d
--- /dev/null
+++ b/src/renderer/components/layout/__tests__/sidebar-cluster.test.tsx
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2021 OpenLens Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ * the Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+import React from "react";
+import "@testing-library/jest-dom/extend-expect";
+import { render, fireEvent } from "@testing-library/react";
+import { SidebarCluster } from "../sidebar-cluster";
+import { KubernetesCluster } from "../../../../common/catalog-entities";
+
+jest.mock("../../../../common/hotbar-store", () => ({
+ HotbarStore: {
+ getInstance: () => ({
+ isAddedToActive: jest.fn(),
+ }),
+ },
+}));
+
+const clusterEntity = new KubernetesCluster({
+ metadata: {
+ uid: "test-uid",
+ name: "test-cluster",
+ source: "local",
+ labels: {},
+ },
+ spec: {
+ kubeconfigPath: "",
+ kubeconfigContext: "",
+ },
+ status: {
+ phase: "connected",
+ },
+});
+
+describe("", () => {
+ it("renders w/o errors", () => {
+ const { container } = render();
+
+ expect(container).toBeInstanceOf(HTMLElement);
+ });
+
+ it("renders cluster avatar and name", () => {
+ const { getByText } = render();
+
+ expect(getByText("tc")).toBeInTheDocument();
+ expect(getByText("test-cluster")).toBeInTheDocument();
+ });
+
+ it("renders cluster menu", async () => {
+ const { getByTestId, getByText } = render();
+ const link = getByTestId("sidebar-cluster-dropdown");
+
+ fireEvent.click(link);
+ expect(await getByText("Add to Hotbar")).toBeInTheDocument();
+ });
+});
+
diff --git a/src/renderer/components/layout/sidebar-cluster.module.css b/src/renderer/components/layout/sidebar-cluster.module.css
new file mode 100644
index 0000000000..37a9aed9fa
--- /dev/null
+++ b/src/renderer/components/layout/sidebar-cluster.module.css
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) 2021 OpenLens Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ * the Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+.SidebarCluster {
+ display: flex;
+ align-items: center;
+ padding: 1.25rem;
+ cursor: pointer;
+
+ &:focus-visible {
+ background: var(--blue);
+ color: white;
+ }
+
+ &:hover {
+ background: var(--sidebarLogoBackground);
+ }
+}
+
+.clusterName {
+ font-weight: bold;
+ overflow: hidden;
+ word-break: break-word;
+ color: var(--textColorAccent);
+ display: -webkit-box;
+ /* Simulate text-overflow:ellipsis styles but for multiple text lines */
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+}
+
+.menu {
+ width: 185px;
+ margin-top: -10px;
+}
+
+.avatar {
+ font-weight: 500;
+ margin-right: 1.25rem;
+}
\ No newline at end of file
diff --git a/src/renderer/components/layout/sidebar-cluster.tsx b/src/renderer/components/layout/sidebar-cluster.tsx
new file mode 100644
index 0000000000..8b47ac122f
--- /dev/null
+++ b/src/renderer/components/layout/sidebar-cluster.tsx
@@ -0,0 +1,140 @@
+/**
+ * Copyright (c) 2021 OpenLens Authors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ * the Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+import styles from "./sidebar-cluster.module.css";
+import { observable } from "mobx";
+import React, { useState } from "react";
+import { HotbarStore } from "../../../common/hotbar-store";
+import { broadcastMessage } from "../../../common/ipc";
+import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
+import { IpcRendererNavigationEvents } from "../../navigation/events";
+import { Avatar } from "../avatar/avatar";
+import { Icon } from "../icon";
+import { navigate } from "../../navigation";
+import { Menu, MenuItem } from "../menu";
+import { ConfirmDialog } from "../confirm-dialog";
+
+const contextMenu: CatalogEntityContextMenuContext = observable({
+ menuItems: [],
+ navigate: (url: string, forceMainFrame = true) => {
+ if (forceMainFrame) {
+ broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url);
+ } else {
+ navigate(url);
+ }
+ },
+});
+
+function onMenuItemClick(menuItem: CatalogEntityContextMenu) {
+ if (menuItem.confirm) {
+ ConfirmDialog.open({
+ okButtonProps: {
+ primary: false,
+ accent: true,
+ },
+ ok: () => {
+ menuItem.onClick();
+ },
+ message: menuItem.confirm.message,
+ });
+ } else {
+ menuItem.onClick();
+ }
+}
+
+export function SidebarCluster({ clusterEntity }: { clusterEntity: CatalogEntity }) {
+ const [opened, setOpened] = useState(false);
+
+ if (!clusterEntity) {
+ return null;
+ }
+
+ const onMenuOpen = () => {
+ const hotbarStore = HotbarStore.getInstance();
+ const isAddedToActive = HotbarStore.getInstance().isAddedToActive(clusterEntity);
+ const title = isAddedToActive
+ ? "Remove from Hotbar"
+ : "Add to Hotbar";
+ const onClick = isAddedToActive
+ ? () => hotbarStore.removeFromHotbar(metadata.uid)
+ : () => hotbarStore.addToHotbar(clusterEntity);
+
+ contextMenu.menuItems = [{ title, onClick }];
+ clusterEntity.onContextMenuOpen(contextMenu);
+
+ toggle();
+ };
+
+ const onKeyDown = (evt: React.KeyboardEvent) => {
+ if (evt.code == "Space") {
+ toggle();
+ }
+ };
+
+ const toggle = () => {
+ setOpened(!opened);
+ };
+
+ const { metadata, spec } = clusterEntity;
+ const id = `cluster-${metadata.uid}`;
+
+ return (
+