mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Initial command palette feature (#1957)
* wip: command palette Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * register shortcut to global menu Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * introduce openCommandDialog & closeCommandDialog Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix ipc broadcast to frames from renderer Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * tweak Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * add more commands Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * cleanup Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * ipc fix Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * add integration tests Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * ipc fix Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * implement workspace edit Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * workspace edit fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * make tests green Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fixes from code review Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * cleanup ipc Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * cleanup CommandRegistry Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * ipc fix Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fix ClusterManager cluster auto-init Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * ensure cluster view is active before sending a command Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * switch to last active cluster when workspace change Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * tweak integration tests Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * run integration tests serially Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * cleanup Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * fixes based on code review Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * cleanup Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * cleanup more Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * cleanup Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * add workspace fixes Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com> * cleanup Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
parent
9e1487685c
commit
b5e7be7591
@ -1,12 +1,5 @@
|
||||
/*
|
||||
Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE
|
||||
namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the
|
||||
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
|
||||
cluster and vice versa.
|
||||
*/
|
||||
import { Application } from "spectron";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { spawnSync } from "child_process";
|
||||
import { listHelmRepositories } from "../helpers/utils";
|
||||
import { fail } from "assert";
|
||||
|
||||
@ -15,62 +8,10 @@ jest.setTimeout(60000);
|
||||
|
||||
// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below)
|
||||
describe("Lens integration tests", () => {
|
||||
const TEST_NAMESPACE = "integration-tests";
|
||||
const BACKSPACE = "\uE003";
|
||||
let app: Application;
|
||||
const appStart = async () => {
|
||||
app = utils.setup();
|
||||
await app.start();
|
||||
// Wait for splash screen to be closed
|
||||
while (await app.client.getWindowCount() > 1);
|
||||
await app.client.windowByIndex(0);
|
||||
await app.client.waitUntilWindowLoaded();
|
||||
};
|
||||
const clickWhatsNew = async (app: Application) => {
|
||||
await app.client.waitUntilTextExists("h1", "What's new?");
|
||||
await app.client.click("button.primary");
|
||||
await app.client.waitUntilTextExists("h1", "Welcome");
|
||||
};
|
||||
const minikubeReady = (): boolean => {
|
||||
// determine if minikube is running
|
||||
{
|
||||
const { status } = spawnSync("minikube status", { shell: true });
|
||||
|
||||
if (status !== 0) {
|
||||
console.warn("minikube not running");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove TEST_NAMESPACE if it already exists
|
||||
{
|
||||
const { status } = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true });
|
||||
|
||||
if (status === 0) {
|
||||
console.warn(`Removing existing ${TEST_NAMESPACE} namespace`);
|
||||
|
||||
const { status, stdout, stderr } = spawnSync(
|
||||
`minikube kubectl -- delete namespace ${TEST_NAMESPACE}`,
|
||||
{ shell: true },
|
||||
);
|
||||
|
||||
if (status !== 0) {
|
||||
console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${stderr.toString()}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(stdout.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
const ready = minikubeReady();
|
||||
|
||||
describe("app start", () => {
|
||||
beforeAll(appStart, 20000);
|
||||
beforeAll(async () => app = await utils.appStart(), 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app?.isRunning()) {
|
||||
@ -79,7 +20,7 @@ describe("Lens integration tests", () => {
|
||||
});
|
||||
|
||||
it('shows "whats new"', async () => {
|
||||
await clickWhatsNew(app);
|
||||
await utils.clickWhatsNew(app);
|
||||
});
|
||||
|
||||
it('shows "add cluster"', async () => {
|
||||
@ -113,495 +54,4 @@ describe("Lens integration tests", () => {
|
||||
await app.client.keys("Meta");
|
||||
});
|
||||
});
|
||||
|
||||
utils.describeIf(ready)("workspaces", () => {
|
||||
beforeAll(appStart, 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
it("creates new workspace", async () => {
|
||||
await clickWhatsNew(app);
|
||||
await app.client.click("#current-workspace .Icon");
|
||||
await app.client.click('a[href="/workspaces"]');
|
||||
await app.client.click(".Workspaces button.Button");
|
||||
await app.client.keys("test-workspace");
|
||||
await app.client.click(".Workspaces .Input.description input");
|
||||
await app.client.keys("test description");
|
||||
await app.client.click(".Workspaces .workspace.editing .Icon");
|
||||
await app.client.waitUntilTextExists(".workspace .name a", "test-workspace");
|
||||
});
|
||||
|
||||
it("adds cluster in default workspace", async () => {
|
||||
await addMinikubeCluster(app);
|
||||
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
||||
await app.client.waitForExist(`iframe[name="minikube"]`);
|
||||
await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active");
|
||||
});
|
||||
|
||||
it("adds cluster in test-workspace", async () => {
|
||||
await app.client.click("#current-workspace .Icon");
|
||||
await app.client.waitForVisible('.WorkspaceMenu li[title="test description"]');
|
||||
await app.client.click('.WorkspaceMenu li[title="test description"]');
|
||||
await addMinikubeCluster(app);
|
||||
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
||||
await app.client.waitForExist(`iframe[name="minikube"]`);
|
||||
});
|
||||
|
||||
it("checks if default workspace has active cluster", async () => {
|
||||
await app.client.click("#current-workspace .Icon");
|
||||
await app.client.waitForVisible(".WorkspaceMenu > li:first-of-type");
|
||||
await app.client.click(".WorkspaceMenu > li:first-of-type");
|
||||
await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active");
|
||||
});
|
||||
});
|
||||
|
||||
const addMinikubeCluster = async (app: Application) => {
|
||||
await app.client.click("div.add-cluster");
|
||||
await app.client.waitUntilTextExists("div", "Select kubeconfig file");
|
||||
await app.client.click("div.Select__control"); // show the context drop-down list
|
||||
await app.client.waitUntilTextExists("div", "minikube");
|
||||
|
||||
if (!await app.client.$("button.primary").isEnabled()) {
|
||||
await app.client.click("div.minikube"); // select minikube context
|
||||
} // else the only context, which must be 'minikube', is automatically selected
|
||||
await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button)
|
||||
await app.client.click("button.primary"); // add minikube cluster
|
||||
};
|
||||
const waitForMinikubeDashboard = async (app: Application) => {
|
||||
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
||||
await app.client.waitForExist(`iframe[name="minikube"]`);
|
||||
await app.client.frame("minikube");
|
||||
await app.client.waitUntilTextExists("span.link-text", "Cluster");
|
||||
};
|
||||
|
||||
utils.describeIf(ready)("cluster tests", () => {
|
||||
let clusterAdded = false;
|
||||
const addCluster = async () => {
|
||||
await clickWhatsNew(app);
|
||||
await addMinikubeCluster(app);
|
||||
await waitForMinikubeDashboard(app);
|
||||
await app.client.click('a[href="/nodes"]');
|
||||
await app.client.waitUntilTextExists("div.TableCell", "Ready");
|
||||
};
|
||||
|
||||
describe("cluster add", () => {
|
||||
beforeAll(appStart, 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows to add a cluster", async () => {
|
||||
await addCluster();
|
||||
clusterAdded = true;
|
||||
});
|
||||
});
|
||||
|
||||
const appStartAddCluster = async () => {
|
||||
if (clusterAdded) {
|
||||
await appStart();
|
||||
await addCluster();
|
||||
}
|
||||
};
|
||||
|
||||
describe("cluster pages", () => {
|
||||
|
||||
beforeAll(appStartAddCluster, 40000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
const tests: {
|
||||
drawer?: string
|
||||
drawerId?: string
|
||||
pages: {
|
||||
name: string,
|
||||
href: string,
|
||||
expectedSelector: string,
|
||||
expectedText: string
|
||||
}[]
|
||||
}[] = [{
|
||||
drawer: "",
|
||||
drawerId: "",
|
||||
pages: [{
|
||||
name: "Cluster",
|
||||
href: "cluster",
|
||||
expectedSelector: "div.ClusterOverview div.label",
|
||||
expectedText: "Master"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "",
|
||||
drawerId: "",
|
||||
pages: [{
|
||||
name: "Nodes",
|
||||
href: "nodes",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Nodes"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Workloads",
|
||||
drawerId: "workloads",
|
||||
pages: [{
|
||||
name: "Overview",
|
||||
href: "workloads",
|
||||
expectedSelector: "h5.box",
|
||||
expectedText: "Overview"
|
||||
},
|
||||
{
|
||||
name: "Pods",
|
||||
href: "pods",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Pods"
|
||||
},
|
||||
{
|
||||
name: "Deployments",
|
||||
href: "deployments",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Deployments"
|
||||
},
|
||||
{
|
||||
name: "DaemonSets",
|
||||
href: "daemonsets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Daemon Sets"
|
||||
},
|
||||
{
|
||||
name: "StatefulSets",
|
||||
href: "statefulsets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Stateful Sets"
|
||||
},
|
||||
{
|
||||
name: "ReplicaSets",
|
||||
href: "replicasets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Replica Sets"
|
||||
},
|
||||
{
|
||||
name: "Jobs",
|
||||
href: "jobs",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Jobs"
|
||||
},
|
||||
{
|
||||
name: "CronJobs",
|
||||
href: "cronjobs",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Cron Jobs"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Configuration",
|
||||
drawerId: "config",
|
||||
pages: [{
|
||||
name: "ConfigMaps",
|
||||
href: "configmaps",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Config Maps"
|
||||
},
|
||||
{
|
||||
name: "Secrets",
|
||||
href: "secrets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Secrets"
|
||||
},
|
||||
{
|
||||
name: "Resource Quotas",
|
||||
href: "resourcequotas",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Resource Quotas"
|
||||
},
|
||||
{
|
||||
name: "Limit Ranges",
|
||||
href: "limitranges",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Limit Ranges"
|
||||
},
|
||||
{
|
||||
name: "HPA",
|
||||
href: "hpa",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Horizontal Pod Autoscalers"
|
||||
},
|
||||
{
|
||||
name: "Pod Disruption Budgets",
|
||||
href: "poddisruptionbudgets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Pod Disruption Budgets"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Network",
|
||||
drawerId: "networks",
|
||||
pages: [{
|
||||
name: "Services",
|
||||
href: "services",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Services"
|
||||
},
|
||||
{
|
||||
name: "Endpoints",
|
||||
href: "endpoints",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Endpoints"
|
||||
},
|
||||
{
|
||||
name: "Ingresses",
|
||||
href: "ingresses",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Ingresses"
|
||||
},
|
||||
{
|
||||
name: "Network Policies",
|
||||
href: "network-policies",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Network Policies"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Storage",
|
||||
drawerId: "storage",
|
||||
pages: [{
|
||||
name: "Persistent Volume Claims",
|
||||
href: "persistent-volume-claims",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Persistent Volume Claims"
|
||||
},
|
||||
{
|
||||
name: "Persistent Volumes",
|
||||
href: "persistent-volumes",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Persistent Volumes"
|
||||
},
|
||||
{
|
||||
name: "Storage Classes",
|
||||
href: "storage-classes",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Storage Classes"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "",
|
||||
drawerId: "",
|
||||
pages: [{
|
||||
name: "Namespaces",
|
||||
href: "namespaces",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Namespaces"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "",
|
||||
drawerId: "",
|
||||
pages: [{
|
||||
name: "Events",
|
||||
href: "events",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Events"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Apps",
|
||||
drawerId: "apps",
|
||||
pages: [{
|
||||
name: "Charts",
|
||||
href: "apps/charts",
|
||||
expectedSelector: "div.HelmCharts input",
|
||||
expectedText: ""
|
||||
},
|
||||
{
|
||||
name: "Releases",
|
||||
href: "apps/releases",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Releases"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Access Control",
|
||||
drawerId: "users",
|
||||
pages: [{
|
||||
name: "Service Accounts",
|
||||
href: "service-accounts",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Service Accounts"
|
||||
},
|
||||
{
|
||||
name: "Role Bindings",
|
||||
href: "role-bindings",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Role Bindings"
|
||||
},
|
||||
{
|
||||
name: "Roles",
|
||||
href: "roles",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Roles"
|
||||
},
|
||||
{
|
||||
name: "Pod Security Policies",
|
||||
href: "pod-security-policies",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Pod Security Policies"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Custom Resources",
|
||||
drawerId: "custom-resources",
|
||||
pages: [{
|
||||
name: "Definitions",
|
||||
href: "crd/definitions",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Custom Resources"
|
||||
}]
|
||||
}];
|
||||
|
||||
tests.forEach(({ drawer = "", drawerId = "", pages }) => {
|
||||
if (drawer !== "") {
|
||||
it(`shows ${drawer} drawer`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
|
||||
await app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name);
|
||||
});
|
||||
}
|
||||
pages.forEach(({ name, href, expectedSelector, expectedText }) => {
|
||||
it(`shows ${drawer}->${name} page`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click(`a[href^="/${href}"]`);
|
||||
await app.client.waitUntilTextExists(expectedSelector, expectedText);
|
||||
});
|
||||
});
|
||||
|
||||
if (drawer !== "") {
|
||||
// hide the drawer
|
||||
it(`hides ${drawer} drawer`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
|
||||
await expect(app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewing pod logs", () => {
|
||||
beforeEach(appStartAddCluster, 40000);
|
||||
|
||||
afterEach(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
it(`shows a logs for a pod`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
// Go to Pods page
|
||||
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
|
||||
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
|
||||
await app.client.click('a[href^="/pods"]');
|
||||
await app.client.click(".NamespaceSelect");
|
||||
await app.client.keys("kube-system");
|
||||
await app.client.keys("Enter");// "\uE007"
|
||||
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver");
|
||||
let podMenuItemEnabled = false;
|
||||
|
||||
// Wait until extensions are enabled on renderer
|
||||
while (!podMenuItemEnabled) {
|
||||
const logs = await app.client.getRenderProcessLogs();
|
||||
|
||||
podMenuItemEnabled = !!logs.find(entry => entry.message.includes("[EXTENSION]: enabled lens-pod-menu@"));
|
||||
|
||||
if (!podMenuItemEnabled) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500)); // Give some extra time to prepare extensions
|
||||
// Open logs tab in dock
|
||||
await app.client.click(".list .TableRow:first-child");
|
||||
await app.client.waitForVisible(".Drawer");
|
||||
await app.client.click(".drawer-title .Menu li:nth-child(2)");
|
||||
// Check if controls are available
|
||||
await app.client.waitForVisible(".LogList .VirtualList");
|
||||
await app.client.waitForVisible(".LogResourceSelector");
|
||||
//await app.client.waitForVisible(".LogSearch .SearchInput");
|
||||
await app.client.waitForVisible(".LogSearch .SearchInput input");
|
||||
// Search for semicolon
|
||||
await app.client.keys(":");
|
||||
await app.client.waitForVisible(".LogList .list span.active");
|
||||
// Click through controls
|
||||
await app.client.click(".LogControls .show-timestamps");
|
||||
await app.client.click(".LogControls .show-previous");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cluster operations", () => {
|
||||
beforeEach(appStartAddCluster, 40000);
|
||||
|
||||
afterEach(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows default namespace", async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click('a[href="/namespaces"]');
|
||||
await app.client.waitUntilTextExists("div.TableCell", "default");
|
||||
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
|
||||
});
|
||||
|
||||
it(`creates ${TEST_NAMESPACE} namespace`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click('a[href="/namespaces"]');
|
||||
await app.client.waitUntilTextExists("div.TableCell", "default");
|
||||
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
|
||||
await app.client.click("button.add-button");
|
||||
await app.client.waitUntilTextExists("div.AddNamespaceDialog", "Create Namespace");
|
||||
await app.client.keys(`${TEST_NAMESPACE}\n`);
|
||||
await app.client.waitForExist(`.name=${TEST_NAMESPACE}`);
|
||||
});
|
||||
|
||||
it(`creates a pod in ${TEST_NAMESPACE} namespace`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
|
||||
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
|
||||
await app.client.click('a[href^="/pods"]');
|
||||
|
||||
await app.client.click(".NamespaceSelect");
|
||||
await app.client.keys(TEST_NAMESPACE);
|
||||
await app.client.keys("Enter");// "\uE007"
|
||||
await app.client.click(".Icon.new-dock-tab");
|
||||
await app.client.waitUntilTextExists("li.MenuItem.create-resource-tab", "Create resource");
|
||||
await app.client.click("li.MenuItem.create-resource-tab");
|
||||
await app.client.waitForVisible(".CreateResource div.ace_content");
|
||||
// Write pod manifest to editor
|
||||
await app.client.keys("apiVersion: v1\n");
|
||||
await app.client.keys("kind: Pod\n");
|
||||
await app.client.keys("metadata:\n");
|
||||
await app.client.keys(" name: nginx-create-pod-test\n");
|
||||
await app.client.keys(`namespace: ${TEST_NAMESPACE}\n`);
|
||||
await app.client.keys(`${BACKSPACE}spec:\n`);
|
||||
await app.client.keys(" containers:\n");
|
||||
await app.client.keys("- name: nginx-create-pod-test\n");
|
||||
await app.client.keys(" image: nginx:alpine\n");
|
||||
// Create deployment
|
||||
await app.client.waitForEnabled("button.Button=Create & Close");
|
||||
await app.client.click("button.Button=Create & Close");
|
||||
// Wait until first bits of pod appears on dashboard
|
||||
await app.client.waitForExist(".name=nginx-create-pod-test");
|
||||
// Open pod details
|
||||
await app.client.click(".name=nginx-create-pod-test");
|
||||
await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
450
integration/__tests__/cluster-pages.tests.ts
Normal file
450
integration/__tests__/cluster-pages.tests.ts
Normal file
@ -0,0 +1,450 @@
|
||||
/*
|
||||
Cluster tests are run if there is a pre-existing minikube cluster. Before running cluster tests the TEST_NAMESPACE
|
||||
namespace is removed, if it exists, from the minikube cluster. Resources are created as part of the cluster tests in the
|
||||
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
|
||||
cluster and vice versa.
|
||||
*/
|
||||
import { Application } from "spectron";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { addMinikubeCluster, minikubeReady, waitForMinikubeDashboard } from "../helpers/minikube";
|
||||
import { exec } from "child_process";
|
||||
import * as util from "util";
|
||||
|
||||
export const promiseExec = util.promisify(exec);
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below)
|
||||
describe("Lens cluster pages", () => {
|
||||
const TEST_NAMESPACE = "integration-tests";
|
||||
const BACKSPACE = "\uE003";
|
||||
let app: Application;
|
||||
const ready = minikubeReady(TEST_NAMESPACE);
|
||||
|
||||
utils.describeIf(ready)("test common pages", () => {
|
||||
let clusterAdded = false;
|
||||
const addCluster = async () => {
|
||||
await utils.clickWhatsNew(app);
|
||||
await addMinikubeCluster(app);
|
||||
await waitForMinikubeDashboard(app);
|
||||
await app.client.click('a[href="/nodes"]');
|
||||
await app.client.waitUntilTextExists("div.TableCell", "Ready");
|
||||
};
|
||||
|
||||
describe("cluster add", () => {
|
||||
beforeAll(async () => app = await utils.appStart(), 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows to add a cluster", async () => {
|
||||
await addCluster();
|
||||
clusterAdded = true;
|
||||
});
|
||||
});
|
||||
|
||||
const appStartAddCluster = async () => {
|
||||
if (clusterAdded) {
|
||||
app = await utils.appStart();
|
||||
await addCluster();
|
||||
}
|
||||
};
|
||||
|
||||
describe("cluster pages", () => {
|
||||
|
||||
beforeAll(appStartAddCluster, 40000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
const tests: {
|
||||
drawer?: string
|
||||
drawerId?: string
|
||||
pages: {
|
||||
name: string,
|
||||
href: string,
|
||||
expectedSelector: string,
|
||||
expectedText: string
|
||||
}[]
|
||||
}[] = [{
|
||||
drawer: "",
|
||||
drawerId: "",
|
||||
pages: [{
|
||||
name: "Cluster",
|
||||
href: "cluster",
|
||||
expectedSelector: "div.ClusterOverview div.label",
|
||||
expectedText: "Master"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "",
|
||||
drawerId: "",
|
||||
pages: [{
|
||||
name: "Nodes",
|
||||
href: "nodes",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Nodes"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Workloads",
|
||||
drawerId: "workloads",
|
||||
pages: [{
|
||||
name: "Overview",
|
||||
href: "workloads",
|
||||
expectedSelector: "h5.box",
|
||||
expectedText: "Overview"
|
||||
},
|
||||
{
|
||||
name: "Pods",
|
||||
href: "pods",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Pods"
|
||||
},
|
||||
{
|
||||
name: "Deployments",
|
||||
href: "deployments",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Deployments"
|
||||
},
|
||||
{
|
||||
name: "DaemonSets",
|
||||
href: "daemonsets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Daemon Sets"
|
||||
},
|
||||
{
|
||||
name: "StatefulSets",
|
||||
href: "statefulsets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Stateful Sets"
|
||||
},
|
||||
{
|
||||
name: "ReplicaSets",
|
||||
href: "replicasets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Replica Sets"
|
||||
},
|
||||
{
|
||||
name: "Jobs",
|
||||
href: "jobs",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Jobs"
|
||||
},
|
||||
{
|
||||
name: "CronJobs",
|
||||
href: "cronjobs",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Cron Jobs"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Configuration",
|
||||
drawerId: "config",
|
||||
pages: [{
|
||||
name: "ConfigMaps",
|
||||
href: "configmaps",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Config Maps"
|
||||
},
|
||||
{
|
||||
name: "Secrets",
|
||||
href: "secrets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Secrets"
|
||||
},
|
||||
{
|
||||
name: "Resource Quotas",
|
||||
href: "resourcequotas",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Resource Quotas"
|
||||
},
|
||||
{
|
||||
name: "Limit Ranges",
|
||||
href: "limitranges",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Limit Ranges"
|
||||
},
|
||||
{
|
||||
name: "HPA",
|
||||
href: "hpa",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Horizontal Pod Autoscalers"
|
||||
},
|
||||
{
|
||||
name: "Pod Disruption Budgets",
|
||||
href: "poddisruptionbudgets",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Pod Disruption Budgets"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Network",
|
||||
drawerId: "networks",
|
||||
pages: [{
|
||||
name: "Services",
|
||||
href: "services",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Services"
|
||||
},
|
||||
{
|
||||
name: "Endpoints",
|
||||
href: "endpoints",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Endpoints"
|
||||
},
|
||||
{
|
||||
name: "Ingresses",
|
||||
href: "ingresses",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Ingresses"
|
||||
},
|
||||
{
|
||||
name: "Network Policies",
|
||||
href: "network-policies",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Network Policies"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Storage",
|
||||
drawerId: "storage",
|
||||
pages: [{
|
||||
name: "Persistent Volume Claims",
|
||||
href: "persistent-volume-claims",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Persistent Volume Claims"
|
||||
},
|
||||
{
|
||||
name: "Persistent Volumes",
|
||||
href: "persistent-volumes",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Persistent Volumes"
|
||||
},
|
||||
{
|
||||
name: "Storage Classes",
|
||||
href: "storage-classes",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Storage Classes"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "",
|
||||
drawerId: "",
|
||||
pages: [{
|
||||
name: "Namespaces",
|
||||
href: "namespaces",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Namespaces"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "",
|
||||
drawerId: "",
|
||||
pages: [{
|
||||
name: "Events",
|
||||
href: "events",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Events"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Apps",
|
||||
drawerId: "apps",
|
||||
pages: [{
|
||||
name: "Charts",
|
||||
href: "apps/charts",
|
||||
expectedSelector: "div.HelmCharts input",
|
||||
expectedText: ""
|
||||
},
|
||||
{
|
||||
name: "Releases",
|
||||
href: "apps/releases",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Releases"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Access Control",
|
||||
drawerId: "users",
|
||||
pages: [{
|
||||
name: "Service Accounts",
|
||||
href: "service-accounts",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Service Accounts"
|
||||
},
|
||||
{
|
||||
name: "Role Bindings",
|
||||
href: "role-bindings",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Role Bindings"
|
||||
},
|
||||
{
|
||||
name: "Roles",
|
||||
href: "roles",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Roles"
|
||||
},
|
||||
{
|
||||
name: "Pod Security Policies",
|
||||
href: "pod-security-policies",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Pod Security Policies"
|
||||
}]
|
||||
},
|
||||
{
|
||||
drawer: "Custom Resources",
|
||||
drawerId: "custom-resources",
|
||||
pages: [{
|
||||
name: "Definitions",
|
||||
href: "crd/definitions",
|
||||
expectedSelector: "h5.title",
|
||||
expectedText: "Custom Resources"
|
||||
}]
|
||||
}];
|
||||
|
||||
tests.forEach(({ drawer = "", drawerId = "", pages }) => {
|
||||
if (drawer !== "") {
|
||||
it(`shows ${drawer} drawer`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
|
||||
await app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name);
|
||||
});
|
||||
}
|
||||
pages.forEach(({ name, href, expectedSelector, expectedText }) => {
|
||||
it(`shows ${drawer}->${name} page`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click(`a[href^="/${href}"]`);
|
||||
await app.client.waitUntilTextExists(expectedSelector, expectedText);
|
||||
});
|
||||
});
|
||||
|
||||
if (drawer !== "") {
|
||||
// hide the drawer
|
||||
it(`hides ${drawer} drawer`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
|
||||
await expect(app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewing pod logs", () => {
|
||||
beforeEach(appStartAddCluster, 40000);
|
||||
|
||||
afterEach(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
it(`shows a logs for a pod`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
// Go to Pods page
|
||||
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
|
||||
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
|
||||
await app.client.click('a[href^="/pods"]');
|
||||
await app.client.click(".NamespaceSelect");
|
||||
await app.client.keys("kube-system");
|
||||
await app.client.keys("Enter");// "\uE007"
|
||||
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver");
|
||||
let podMenuItemEnabled = false;
|
||||
|
||||
// Wait until extensions are enabled on renderer
|
||||
while (!podMenuItemEnabled) {
|
||||
const logs = await app.client.getRenderProcessLogs();
|
||||
|
||||
podMenuItemEnabled = !!logs.find(entry => entry.message.includes("[EXTENSION]: enabled lens-pod-menu@"));
|
||||
|
||||
if (!podMenuItemEnabled) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500)); // Give some extra time to prepare extensions
|
||||
// Open logs tab in dock
|
||||
await app.client.click(".list .TableRow:first-child");
|
||||
await app.client.waitForVisible(".Drawer");
|
||||
await app.client.click(".drawer-title .Menu li:nth-child(2)");
|
||||
// Check if controls are available
|
||||
await app.client.waitForVisible(".LogList .VirtualList");
|
||||
await app.client.waitForVisible(".LogResourceSelector");
|
||||
//await app.client.waitForVisible(".LogSearch .SearchInput");
|
||||
await app.client.waitForVisible(".LogSearch .SearchInput input");
|
||||
// Search for semicolon
|
||||
await app.client.keys(":");
|
||||
await app.client.waitForVisible(".LogList .list span.active");
|
||||
// Click through controls
|
||||
await app.client.click(".LogControls .show-timestamps");
|
||||
await app.client.click(".LogControls .show-previous");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cluster operations", () => {
|
||||
beforeEach(appStartAddCluster, 40000);
|
||||
|
||||
afterEach(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows default namespace", async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click('a[href="/namespaces"]');
|
||||
await app.client.waitUntilTextExists("div.TableCell", "default");
|
||||
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
|
||||
});
|
||||
|
||||
it(`creates ${TEST_NAMESPACE} namespace`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click('a[href="/namespaces"]');
|
||||
await app.client.waitUntilTextExists("div.TableCell", "default");
|
||||
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
|
||||
await app.client.click("button.add-button");
|
||||
await app.client.waitUntilTextExists("div.AddNamespaceDialog", "Create Namespace");
|
||||
await app.client.keys(`${TEST_NAMESPACE}\n`);
|
||||
await app.client.waitForExist(`.name=${TEST_NAMESPACE}`);
|
||||
});
|
||||
|
||||
it(`creates a pod in ${TEST_NAMESPACE} namespace`, async () => {
|
||||
expect(clusterAdded).toBe(true);
|
||||
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
|
||||
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
|
||||
await app.client.click('a[href^="/pods"]');
|
||||
|
||||
await app.client.click(".NamespaceSelect");
|
||||
await app.client.keys(TEST_NAMESPACE);
|
||||
await app.client.keys("Enter");// "\uE007"
|
||||
await app.client.click(".Icon.new-dock-tab");
|
||||
await app.client.waitUntilTextExists("li.MenuItem.create-resource-tab", "Create resource");
|
||||
await app.client.click("li.MenuItem.create-resource-tab");
|
||||
await app.client.waitForVisible(".CreateResource div.ace_content");
|
||||
// Write pod manifest to editor
|
||||
await app.client.keys("apiVersion: v1\n");
|
||||
await app.client.keys("kind: Pod\n");
|
||||
await app.client.keys("metadata:\n");
|
||||
await app.client.keys(" name: nginx-create-pod-test\n");
|
||||
await app.client.keys(`namespace: ${TEST_NAMESPACE}\n`);
|
||||
await app.client.keys(`${BACKSPACE}spec:\n`);
|
||||
await app.client.keys(" containers:\n");
|
||||
await app.client.keys("- name: nginx-create-pod-test\n");
|
||||
await app.client.keys(" image: nginx:alpine\n");
|
||||
// Create deployment
|
||||
await app.client.waitForEnabled("button.Button=Create & Close");
|
||||
await app.client.click("button.Button=Create & Close");
|
||||
// Wait until first bits of pod appears on dashboard
|
||||
await app.client.waitForExist(".name=nginx-create-pod-test");
|
||||
// Open pod details
|
||||
await app.client.click(".name=nginx-create-pod-test");
|
||||
await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
25
integration/__tests__/command-palette.tests.ts
Normal file
25
integration/__tests__/command-palette.tests.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Application } from "spectron";
|
||||
import * as utils from "../helpers/utils";
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
describe("Lens command palette", () => {
|
||||
let app: Application;
|
||||
|
||||
describe("menu", () => {
|
||||
beforeAll(async () => app = await utils.appStart(), 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app?.isRunning()) {
|
||||
await utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens command dialog from menu", async () => {
|
||||
await utils.clickWhatsNew(app);
|
||||
await app.electron.ipcRenderer.send("test-menu-item-click", "View", "Command Palette...");
|
||||
await app.client.waitUntilTextExists(".Select__option", "Preferences: Open");
|
||||
await app.client.keys("Escape");
|
||||
});
|
||||
});
|
||||
});
|
||||
75
integration/__tests__/workspace.tests.ts
Normal file
75
integration/__tests__/workspace.tests.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { Application } from "spectron";
|
||||
import * as utils from "../helpers/utils";
|
||||
import { addMinikubeCluster, minikubeReady } from "../helpers/minikube";
|
||||
import { exec } from "child_process";
|
||||
import * as util from "util";
|
||||
|
||||
export const promiseExec = util.promisify(exec);
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
describe("Lens integration tests", () => {
|
||||
let app: Application;
|
||||
const ready = minikubeReady("workspace-int-tests");
|
||||
|
||||
utils.describeIf(ready)("workspaces", () => {
|
||||
beforeAll(async () => {
|
||||
app = await utils.appStart();
|
||||
await utils.clickWhatsNew(app);
|
||||
}, 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return utils.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
const switchToWorkspace = async (name: string) => {
|
||||
await app.client.click("[data-test-id=current-workspace]");
|
||||
await app.client.keys(name);
|
||||
await app.client.keys("Enter");
|
||||
await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name);
|
||||
};
|
||||
|
||||
const createWorkspace = async (name: string) => {
|
||||
await app.client.click("[data-test-id=current-workspace]");
|
||||
await app.client.keys("add workspace");
|
||||
await app.client.keys("Enter");
|
||||
await app.client.keys(name);
|
||||
await app.client.keys("Enter");
|
||||
await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name);
|
||||
};
|
||||
|
||||
it("creates new workspace", async () => {
|
||||
const name = "test-workspace";
|
||||
|
||||
await createWorkspace(name);
|
||||
await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", name);
|
||||
});
|
||||
|
||||
it("edits current workspaces", async () => {
|
||||
await createWorkspace("to-be-edited");
|
||||
await app.client.click("[data-test-id=current-workspace]");
|
||||
await app.client.keys("edit current workspace");
|
||||
await app.client.keys("Enter");
|
||||
await app.client.keys("edited-workspace");
|
||||
await app.client.keys("Enter");
|
||||
await app.client.waitUntilTextExists("[data-test-id=current-workspace-name]", "edited-workspace");
|
||||
});
|
||||
|
||||
it("adds cluster in default workspace", async () => {
|
||||
await switchToWorkspace("default");
|
||||
await addMinikubeCluster(app);
|
||||
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
||||
await app.client.waitForExist(`iframe[name="minikube"]`);
|
||||
await app.client.waitForVisible(".ClustersMenu .ClusterIcon.active");
|
||||
});
|
||||
|
||||
it("adds cluster in test-workspace", async () => {
|
||||
await switchToWorkspace("test-workspace");
|
||||
await addMinikubeCluster(app);
|
||||
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
||||
await app.client.waitForExist(`iframe[name="minikube"]`);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
integration/helpers/minikube.ts
Normal file
59
integration/helpers/minikube.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { spawnSync } from "child_process";
|
||||
import { Application } from "spectron";
|
||||
|
||||
export function minikubeReady(testNamespace: string): boolean {
|
||||
// determine if minikube is running
|
||||
{
|
||||
const { status } = spawnSync("minikube status", { shell: true });
|
||||
|
||||
if (status !== 0) {
|
||||
console.warn("minikube not running");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove TEST_NAMESPACE if it already exists
|
||||
{
|
||||
const { status } = spawnSync(`minikube kubectl -- get namespace ${testNamespace}`, { shell: true });
|
||||
|
||||
if (status === 0) {
|
||||
console.warn(`Removing existing ${testNamespace} namespace`);
|
||||
|
||||
const { status, stdout, stderr } = spawnSync(
|
||||
`minikube kubectl -- delete namespace ${testNamespace}`,
|
||||
{ shell: true },
|
||||
);
|
||||
|
||||
if (status !== 0) {
|
||||
console.warn(`Error removing ${testNamespace} namespace: ${stderr.toString()}`);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(stdout.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function addMinikubeCluster(app: Application) {
|
||||
await app.client.click("div.add-cluster");
|
||||
await app.client.waitUntilTextExists("div", "Select kubeconfig file");
|
||||
await app.client.click("div.Select__control"); // show the context drop-down list
|
||||
await app.client.waitUntilTextExists("div", "minikube");
|
||||
|
||||
if (!await app.client.$("button.primary").isEnabled()) {
|
||||
await app.client.click("div.minikube"); // select minikube context
|
||||
} // else the only context, which must be 'minikube', is automatically selected
|
||||
await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button)
|
||||
await app.client.click("button.primary"); // add minikube cluster
|
||||
}
|
||||
|
||||
export async function waitForMinikubeDashboard(app: Application) {
|
||||
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
|
||||
await app.client.waitForExist(`iframe[name="minikube"]`);
|
||||
await app.client.frame("minikube");
|
||||
await app.client.waitUntilTextExists("span.link-text", "Cluster");
|
||||
}
|
||||
@ -28,12 +28,29 @@ export function setup(): Application {
|
||||
});
|
||||
}
|
||||
|
||||
type HelmRepository = {
|
||||
name: string;
|
||||
url: string;
|
||||
export const keys = {
|
||||
backspace: "\uE003"
|
||||
};
|
||||
|
||||
export async function appStart() {
|
||||
const app = setup();
|
||||
|
||||
await app.start();
|
||||
// Wait for splash screen to be closed
|
||||
while (await app.client.getWindowCount() > 1);
|
||||
await app.client.windowByIndex(0);
|
||||
await app.client.waitUntilWindowLoaded();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
export async function clickWhatsNew(app: Application) {
|
||||
await app.client.waitUntilTextExists("h1", "What's new?");
|
||||
await app.client.click("button.primary");
|
||||
await app.client.waitUntilTextExists("h1", "Welcome");
|
||||
}
|
||||
|
||||
type AsyncPidGetter = () => Promise<number>;
|
||||
export const promiseExec = util.promisify(exec);
|
||||
|
||||
export async function tearDown(app: Application) {
|
||||
const pid = await (app.mainProcess.pid as any as AsyncPidGetter)();
|
||||
@ -47,6 +64,13 @@ export async function tearDown(app: Application) {
|
||||
}
|
||||
}
|
||||
|
||||
export const promiseExec = util.promisify(exec);
|
||||
|
||||
type HelmRepository = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function listHelmRepositories(retries = 0): Promise<HelmRepository[]>{
|
||||
if (retries < 5) {
|
||||
try {
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
"build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens",
|
||||
"build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens",
|
||||
"test": "jest --env=jsdom src $@",
|
||||
"integration": "jest --coverage integration $@",
|
||||
"integration": "jest --runInBand integration",
|
||||
"dist": "yarn run compile && electron-builder --publish onTag",
|
||||
"dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32",
|
||||
"dist:dir": "yarn run dist --dir -c.compression=store -c.mac.identity=null",
|
||||
|
||||
@ -3,9 +3,12 @@
|
||||
// https://www.electronjs.org/docs/api/ipc-renderer
|
||||
|
||||
import { ipcMain, ipcRenderer, webContents, remote } from "electron";
|
||||
import { toJS } from "mobx";
|
||||
import logger from "../main/logger";
|
||||
import { ClusterFrameInfo, clusterFrameMap } from "./cluster-frames";
|
||||
|
||||
const subFramesChannel = "ipc:get-sub-frames";
|
||||
|
||||
export function handleRequest(channel: string, listener: (...args: any[]) => any) {
|
||||
ipcMain.handle(channel, listener);
|
||||
}
|
||||
@ -14,38 +17,39 @@ export async function requestMain(channel: string, ...args: any[]) {
|
||||
return ipcRenderer.invoke(channel, ...args);
|
||||
}
|
||||
|
||||
async function getSubFrames(): Promise<ClusterFrameInfo[]> {
|
||||
const subFrames: ClusterFrameInfo[] = [];
|
||||
|
||||
clusterFrameMap.forEach(frameInfo => {
|
||||
subFrames.push(frameInfo);
|
||||
});
|
||||
|
||||
return subFrames;
|
||||
function getSubFrames(): ClusterFrameInfo[] {
|
||||
return toJS(Array.from(clusterFrameMap.values()), { recurseEverything: true });
|
||||
}
|
||||
|
||||
export function broadcastMessage(channel: string, ...args: any[]) {
|
||||
export async function broadcastMessage(channel: string, ...args: any[]) {
|
||||
const views = (webContents || remote?.webContents)?.getAllWebContents();
|
||||
|
||||
if (!views) return;
|
||||
|
||||
views.forEach(webContent => {
|
||||
const type = webContent.getType();
|
||||
|
||||
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
|
||||
webContent.send(channel, ...args);
|
||||
getSubFrames().then((frames) => {
|
||||
frames.map((frameInfo) => {
|
||||
webContent.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
|
||||
});
|
||||
}).catch((e) => e);
|
||||
});
|
||||
|
||||
if (ipcRenderer) {
|
||||
ipcRenderer.send(channel, ...args);
|
||||
} else {
|
||||
ipcMain.emit(channel, ...args);
|
||||
}
|
||||
|
||||
for (const view of views) {
|
||||
const type = view.getType();
|
||||
|
||||
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${view.id}`, { args });
|
||||
view.send(channel, ...args);
|
||||
|
||||
try {
|
||||
const subFrames: ClusterFrameInfo[] = ipcRenderer
|
||||
? await requestMain(subFramesChannel)
|
||||
: getSubFrames();
|
||||
|
||||
for (const frameInfo of subFrames) {
|
||||
view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[IPC]: failed to send IPC message", { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeToBroadcast(channel: string, listener: (...args: any[]) => any) {
|
||||
@ -73,3 +77,9 @@ export function unsubscribeAllFromBroadcast(channel: string) {
|
||||
ipcMain.removeAllListeners(channel);
|
||||
}
|
||||
}
|
||||
|
||||
export function bindBroadcastHandlers() {
|
||||
handleRequest(subFramesChannel, () => {
|
||||
return getSubFrames();
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { AppPreferenceRegistration, ClusterFeatureRegistration, ClusterPage
|
||||
import type { Cluster } from "../main/cluster";
|
||||
import { LensExtension } from "./lens-extension";
|
||||
import { getExtensionPageUrl } from "./registries/page-registry";
|
||||
import { CommandRegistration } from "./registries/command-registry";
|
||||
|
||||
export class LensRendererExtension extends LensExtension {
|
||||
globalPages: PageRegistration[] = [];
|
||||
@ -14,6 +15,7 @@ export class LensRendererExtension extends LensExtension {
|
||||
statusBarItems: StatusBarRegistration[] = [];
|
||||
kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
|
||||
kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];
|
||||
commands: CommandRegistration[] = [];
|
||||
|
||||
async navigate<P extends object>(pageId?: string, params?: P) {
|
||||
const { navigate } = await import("../renderer/navigation");
|
||||
|
||||
37
src/extensions/registries/command-registry.ts
Normal file
37
src/extensions/registries/command-registry.ts
Normal file
@ -0,0 +1,37 @@
|
||||
// Extensions API -> Commands
|
||||
|
||||
import type { Cluster } from "../../main/cluster";
|
||||
import type { Workspace } from "../../common/workspace-store";
|
||||
import { BaseRegistry } from "./base-registry";
|
||||
import { action } from "mobx";
|
||||
import { LensExtension } from "../lens-extension";
|
||||
|
||||
export type CommandContext = {
|
||||
cluster?: Cluster;
|
||||
workspace?: Workspace;
|
||||
};
|
||||
|
||||
export interface CommandRegistration {
|
||||
id: string;
|
||||
title: string;
|
||||
scope: "cluster" | "global";
|
||||
action: (context: CommandContext) => void;
|
||||
isActive?: (context: CommandContext) => boolean;
|
||||
}
|
||||
|
||||
export class CommandRegistry extends BaseRegistry<CommandRegistration> {
|
||||
@action
|
||||
add(items: CommandRegistration | CommandRegistration[], extension?: LensExtension) {
|
||||
const itemArray = [items].flat();
|
||||
|
||||
const newIds = itemArray.map((item) => item.id);
|
||||
const currentIds = this.getItems().map((item) => item.id);
|
||||
|
||||
const filteredIds = newIds.filter((id) => !currentIds.includes(id));
|
||||
const filteredItems = itemArray.filter((item) => filteredIds.includes(item.id));
|
||||
|
||||
return super.add(filteredItems, extension);
|
||||
}
|
||||
}
|
||||
|
||||
export const commandRegistry = new CommandRegistry();
|
||||
@ -13,6 +13,9 @@ export * from "../../renderer/components/select";
|
||||
export * from "../../renderer/components/slider";
|
||||
export * from "../../renderer/components/input/input";
|
||||
|
||||
// command-overlay
|
||||
export { CommandOverlay } from "../../renderer/components/command-palette";
|
||||
|
||||
// other components
|
||||
export * from "../../renderer/components/icon";
|
||||
export * from "../../renderer/components/tooltip";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import "../common/cluster-ipc";
|
||||
import type http from "http";
|
||||
import { ipcMain } from "electron";
|
||||
import { autorun } from "mobx";
|
||||
import { autorun, reaction } from "mobx";
|
||||
import { clusterStore, getClusterIdFromHost } from "../common/cluster-store";
|
||||
import { Cluster } from "./cluster";
|
||||
import logger from "./logger";
|
||||
@ -12,14 +12,14 @@ export class ClusterManager extends Singleton {
|
||||
constructor(public readonly port: number) {
|
||||
super();
|
||||
// auto-init clusters
|
||||
autorun(() => {
|
||||
clusterStore.enabledClustersList.forEach(cluster => {
|
||||
reaction(() => clusterStore.enabledClustersList, (clusters) => {
|
||||
clusters.forEach((cluster) => {
|
||||
if (!cluster.initialized && !cluster.initializing) {
|
||||
logger.info(`[CLUSTER-MANAGER]: init cluster`, cluster.getMeta());
|
||||
cluster.init(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, { fireImmediately: true });
|
||||
|
||||
// auto-stop removed clusters
|
||||
autorun(() => {
|
||||
|
||||
@ -26,6 +26,7 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension-
|
||||
import type { LensExtensionId } from "../extensions/lens-extension";
|
||||
import { installDeveloperTools } from "./developer-tools";
|
||||
import { filesystemProvisionerStore } from "./extension-filesystem";
|
||||
import { bindBroadcastHandlers } from "../common/ipc";
|
||||
|
||||
const workingDir = path.join(app.getPath("appData"), appName);
|
||||
let proxyPort: number;
|
||||
@ -63,6 +64,8 @@ app.on("ready", async () => {
|
||||
logger.info(`🚀 Starting Lens from "${workingDir}"`);
|
||||
await shellSync();
|
||||
|
||||
bindBroadcastHandlers();
|
||||
|
||||
powerMonitor.on("shutdown", () => {
|
||||
app.exit();
|
||||
});
|
||||
|
||||
@ -10,6 +10,7 @@ import { extensionsURL } from "../renderer/components/+extensions/extensions.rou
|
||||
import { menuRegistry } from "../extensions/registries/menu-registry";
|
||||
import logger from "./logger";
|
||||
import { exitApp } from "./exit-app";
|
||||
import { broadcastMessage } from "../common/ipc";
|
||||
|
||||
export type MenuTopId = "mac" | "file" | "edit" | "view" | "help";
|
||||
|
||||
@ -173,6 +174,14 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
const viewMenu: MenuItemConstructorOptions = {
|
||||
label: "View",
|
||||
submenu: [
|
||||
{
|
||||
label: "Command Palette...",
|
||||
accelerator: "Shift+CmdOrCtrl+P",
|
||||
click() {
|
||||
broadcastMessage("command-palette:open");
|
||||
}
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Back",
|
||||
accelerator: "CmdOrCtrl+[",
|
||||
|
||||
18
src/renderer/components/+apps/apps.command.ts
Normal file
18
src/renderer/components/+apps/apps.command.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { navigate } from "../../navigation";
|
||||
import { commandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { helmChartsURL } from "../+apps-helm-charts";
|
||||
import { releaseURL } from "../+apps-releases";
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewHelmCharts",
|
||||
title: "Cluster: View Helm Charts",
|
||||
scope: "cluster",
|
||||
action: () => navigate(helmChartsURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewHelmReleases",
|
||||
title: "Cluster: View Helm Releases",
|
||||
scope: "cluster",
|
||||
action: () => navigate(releaseURL())
|
||||
});
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./apps";
|
||||
export * from "./apps.route";
|
||||
export * from "./apps.command";
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
import { navigate } from "../../navigation";
|
||||
import { commandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { clusterSettingsURL } from "./cluster-settings.route";
|
||||
import { clusterStore } from "../../../common/cluster-store";
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewCurrentClusterSettings",
|
||||
title: "Cluster: View Settings",
|
||||
scope: "global",
|
||||
action: () => navigate(clusterSettingsURL({
|
||||
params: {
|
||||
clusterId: clusterStore.active.id
|
||||
}
|
||||
})),
|
||||
isActive: (context) => !!context.cluster
|
||||
});
|
||||
@ -1,7 +1,5 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { workspacesURL } from "../../+workspaces";
|
||||
import { workspaceStore } from "../../../../common/workspace-store";
|
||||
import { Cluster } from "../../../../main/cluster";
|
||||
import { Select } from "../../../components/select";
|
||||
@ -18,10 +16,7 @@ export class ClusterWorkspaceSetting extends React.Component<Props> {
|
||||
<>
|
||||
<SubTitle title="Cluster Workspace"/>
|
||||
<p>
|
||||
Define cluster{" "}
|
||||
<Link to={workspacesURL()}>
|
||||
workspace
|
||||
</Link>.
|
||||
Define cluster workspace.
|
||||
</p>
|
||||
<Select
|
||||
value={this.props.cluster.workspace}
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./cluster-settings.route";
|
||||
export * from "./cluster-settings";
|
||||
export * from "./cluster-settings.command";
|
||||
|
||||
50
src/renderer/components/+config/config.command.ts
Normal file
50
src/renderer/components/+config/config.command.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { navigate } from "../../navigation";
|
||||
import { commandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { configMapsURL } from "../+config-maps";
|
||||
import { secretsURL } from "../+config-secrets";
|
||||
import { resourceQuotaURL } from "../+config-resource-quotas";
|
||||
import { limitRangeURL } from "../+config-limit-ranges";
|
||||
import { hpaURL } from "../+config-autoscalers";
|
||||
import { pdbURL } from "../+config-pod-disruption-budgets";
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewConfigMaps",
|
||||
title: "Cluster: View ConfigMaps",
|
||||
scope: "cluster",
|
||||
action: () => navigate(configMapsURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewSecrets",
|
||||
title: "Cluster: View Secrets",
|
||||
scope: "cluster",
|
||||
action: () => navigate(secretsURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewResourceQuotas",
|
||||
title: "Cluster: View ResourceQuotas",
|
||||
scope: "cluster",
|
||||
action: () => navigate(resourceQuotaURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewLimitRanges",
|
||||
title: "Cluster: View LimitRanges",
|
||||
scope: "cluster",
|
||||
action: () => navigate(limitRangeURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewHorizontalPodAutoscalers",
|
||||
title: "Cluster: View HorizontalPodAutoscalers (HPA)",
|
||||
scope: "cluster",
|
||||
action: () => navigate(hpaURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewPodDisruptionBudget",
|
||||
title: "Cluster: View PodDisruptionBudgets",
|
||||
scope: "cluster",
|
||||
action: () => navigate(pdbURL())
|
||||
});
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./config.route";
|
||||
export * from "./config";
|
||||
export * from "./config.command";
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./network.route";
|
||||
export * from "./network";
|
||||
export * from "./network.command";
|
||||
|
||||
34
src/renderer/components/+network/network.command.ts
Normal file
34
src/renderer/components/+network/network.command.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { navigate } from "../../navigation";
|
||||
import { commandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { servicesURL } from "../+network-services";
|
||||
import { endpointURL } from "../+network-endpoints";
|
||||
import { ingressURL } from "../+network-ingresses";
|
||||
import { networkPoliciesURL } from "../+network-policies";
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewServices",
|
||||
title: "Cluster: View Services",
|
||||
scope: "cluster",
|
||||
action: () => navigate(servicesURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewEndpoints",
|
||||
title: "Cluster: View Endpoints",
|
||||
scope: "cluster",
|
||||
action: () => navigate(endpointURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewIngresses",
|
||||
title: "Cluster: View Ingresses",
|
||||
scope: "cluster",
|
||||
action: () => navigate(ingressURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewNetworkPolicies",
|
||||
title: "Cluster: View NetworkPolicies",
|
||||
scope: "cluster",
|
||||
action: () => navigate(networkPoliciesURL())
|
||||
});
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./nodes";
|
||||
export * from "./nodes.route";
|
||||
export * from "./node-details";
|
||||
export * from "./node.command";
|
||||
|
||||
10
src/renderer/components/+nodes/node.command.ts
Normal file
10
src/renderer/components/+nodes/node.command.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { navigate } from "../../navigation";
|
||||
import { commandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { nodesURL } from "./nodes.route";
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewNodes",
|
||||
title: "Cluster: View Nodes",
|
||||
scope: "cluster",
|
||||
action: () => navigate(nodesURL())
|
||||
});
|
||||
@ -1,8 +1,17 @@
|
||||
import type { RouteProps } from "react-router";
|
||||
import { commandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { buildURL } from "../../../common/utils/buildUrl";
|
||||
import { navigate } from "../../navigation";
|
||||
|
||||
export const preferencesRoute: RouteProps = {
|
||||
path: "/preferences"
|
||||
};
|
||||
|
||||
export const preferencesURL = buildURL(preferencesRoute.path);
|
||||
|
||||
commandRegistry.add({
|
||||
id: "app.showPreferences",
|
||||
title: "Preferences: Open",
|
||||
scope: "global",
|
||||
action: () => navigate(preferencesURL())
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./workloads.route";
|
||||
export * from "./workloads";
|
||||
export * from "./workloads.stores";
|
||||
export * from "./workloads.command";
|
||||
|
||||
45
src/renderer/components/+workloads/workloads.command.ts
Normal file
45
src/renderer/components/+workloads/workloads.command.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { navigate } from "../../navigation";
|
||||
import { commandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { cronJobsURL, daemonSetsURL, deploymentsURL, jobsURL, podsURL, statefulSetsURL } from "./workloads.route";
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewPods",
|
||||
title: "Cluster: View Pods",
|
||||
scope: "cluster",
|
||||
action: () => navigate(podsURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewDeployments",
|
||||
title: "Cluster: View Deployments",
|
||||
scope: "cluster",
|
||||
action: () => navigate(deploymentsURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewDaemonSets",
|
||||
title: "Cluster: View DaemonSets",
|
||||
scope: "cluster",
|
||||
action: () => navigate(daemonSetsURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewStatefulSets",
|
||||
title: "Cluster: View StatefulSets",
|
||||
scope: "cluster",
|
||||
action: () => navigate(statefulSetsURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewJobs",
|
||||
title: "Cluster: View Jobs",
|
||||
scope: "cluster",
|
||||
action: () => navigate(jobsURL())
|
||||
});
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.viewCronJobs",
|
||||
title: "Cluster: View CronJobs",
|
||||
scope: "cluster",
|
||||
action: () => navigate(cronJobsURL())
|
||||
});
|
||||
64
src/renderer/components/+workspaces/add-workspace.tsx
Normal file
64
src/renderer/components/+workspaces/add-workspace.tsx
Normal file
@ -0,0 +1,64 @@
|
||||
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 />)
|
||||
});
|
||||
82
src/renderer/components/+workspaces/edit-workspace.tsx
Normal file
82
src/renderer/components/+workspaces/edit-workspace.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
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,2 +1 @@
|
||||
export * from "./workspaces.route";
|
||||
export * from "./workspaces";
|
||||
|
||||
68
src/renderer/components/+workspaces/remove-workspace.tsx
Normal file
68
src/renderer/components/+workspaces/remove-workspace.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
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,7 +0,0 @@
|
||||
.WorkspaceMenu {
|
||||
border-radius: $radius;
|
||||
|
||||
.workspaces-title {
|
||||
padding: $padding;
|
||||
}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
import "./workspace-menu.scss";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { workspacesURL } from "./workspaces.route";
|
||||
import { Menu, MenuItem, MenuProps } from "../menu";
|
||||
import { Icon } from "../icon";
|
||||
import { observable } from "mobx";
|
||||
import { WorkspaceId, workspaceStore } from "../../../common/workspace-store";
|
||||
import { cssNames } from "../../utils";
|
||||
import { navigate } from "../../navigation";
|
||||
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
|
||||
import { landingURL } from "../+landing-page";
|
||||
|
||||
interface Props extends Partial<MenuProps> {
|
||||
}
|
||||
|
||||
@observer
|
||||
export class WorkspaceMenu extends React.Component<Props> {
|
||||
@observable menuVisible = false;
|
||||
|
||||
activateWorkspace = (id: WorkspaceId) => {
|
||||
const clusterId = workspaceStore.getById(id).lastActiveClusterId;
|
||||
|
||||
workspaceStore.setActive(id);
|
||||
|
||||
if (clusterId) {
|
||||
navigate(clusterViewURL({ params: { clusterId } }));
|
||||
} else {
|
||||
navigate(landingURL());
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className, ...menuProps } = this.props;
|
||||
const { enabledWorkspacesList, currentWorkspace } = workspaceStore;
|
||||
|
||||
return (
|
||||
<Menu
|
||||
{...menuProps}
|
||||
usePortal
|
||||
className={cssNames("WorkspaceMenu", className)}
|
||||
isOpen={this.menuVisible}
|
||||
open={() => this.menuVisible = true}
|
||||
close={() => this.menuVisible = false}
|
||||
>
|
||||
<Link className="workspaces-title" to={workspacesURL()}>
|
||||
Workspaces
|
||||
</Link>
|
||||
{enabledWorkspacesList.map(({ id: workspaceId, name, description }) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={workspaceId}
|
||||
title={description}
|
||||
active={workspaceId === currentWorkspace.id}
|
||||
onClick={() => this.activateWorkspace(workspaceId)}
|
||||
>
|
||||
<Icon small material="layers"/>
|
||||
<span className="workspace">{name}</span>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
import type { RouteProps } from "react-router";
|
||||
import { buildURL } from "../../../common/utils/buildUrl";
|
||||
|
||||
export const workspacesRoute: RouteProps = {
|
||||
path: "/workspaces"
|
||||
};
|
||||
|
||||
export const workspacesURL = buildURL(workspacesRoute.path);
|
||||
@ -1,14 +0,0 @@
|
||||
.Workspaces {
|
||||
.workspace {
|
||||
--flex-gap: #{$padding};
|
||||
padding: $padding / 2;
|
||||
|
||||
&.default {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
> .description {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,216 +1,89 @@
|
||||
import "./workspaces.scss";
|
||||
import React, { Fragment } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { computed, observable, toJS } from "mobx";
|
||||
import { WizardLayout } from "../layout/wizard-layout";
|
||||
import { Workspace, WorkspaceId, workspaceStore } from "../../../common/workspace-store";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { ConfirmDialog } from "../confirm-dialog";
|
||||
import { Icon } from "../icon";
|
||||
import { Input } from "../input";
|
||||
import { cssNames, prevDefault } from "../../utils";
|
||||
import { Button } from "../button";
|
||||
import { isRequired, InputValidator } from "../input/input_validators";
|
||||
import { clusterStore } from "../../../common/cluster-store";
|
||||
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 Workspaces extends React.Component {
|
||||
@observable editingWorkspaces = observable.map<WorkspaceId, Workspace>();
|
||||
export class ChooseWorkspace extends React.Component {
|
||||
private static addActionId = "__add__";
|
||||
private static removeActionId = "__remove__";
|
||||
private static editActionId = "__edit__";
|
||||
|
||||
@computed get workspaces(): Workspace[] {
|
||||
const currentWorkspaces: Map<WorkspaceId, Workspace> = new Map();
|
||||
|
||||
workspaceStore.enabledWorkspacesList.forEach((w) => {
|
||||
currentWorkspaces.set(w.id, w);
|
||||
@computed get options() {
|
||||
const options = workspaceStore.enabledWorkspacesList.map((workspace) => {
|
||||
return { value: workspace.id, label: workspace.name };
|
||||
});
|
||||
const allWorkspaces = new Map([
|
||||
...currentWorkspaces,
|
||||
...this.editingWorkspaces,
|
||||
]);
|
||||
|
||||
return Array.from(allWorkspaces.values());
|
||||
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 ..." });
|
||||
}
|
||||
}
|
||||
|
||||
renderInfo() {
|
||||
return (
|
||||
<Fragment>
|
||||
<h2>What is a Workspace?</h2>
|
||||
<p className="info">
|
||||
Workspaces are used to organize number of clusters into logical groups.
|
||||
</p>
|
||||
<p>
|
||||
A single workspaces contains a list of clusters and their full configuration.
|
||||
</p>
|
||||
</Fragment>
|
||||
);
|
||||
return options;
|
||||
}
|
||||
|
||||
saveWorkspace = (id: WorkspaceId) => {
|
||||
const workspace = new Workspace(this.editingWorkspaces.get(id));
|
||||
|
||||
if (workspaceStore.getById(id)) {
|
||||
workspaceStore.updateWorkspace(workspace);
|
||||
this.clearEditing(id);
|
||||
onChange(id: string) {
|
||||
if (id === ChooseWorkspace.addActionId) {
|
||||
CommandOverlay.open(<AddWorkspace />);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaceStore.addWorkspace(workspace)) {
|
||||
this.clearEditing(id);
|
||||
if (id === ChooseWorkspace.removeActionId) {
|
||||
CommandOverlay.open(<RemoveWorkspace />);
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
addWorkspace = () => {
|
||||
const workspaceId = uuid();
|
||||
if (id === ChooseWorkspace.editActionId) {
|
||||
CommandOverlay.open(<EditWorkspace />);
|
||||
|
||||
this.editingWorkspaces.set(workspaceId, new Workspace({
|
||||
id: workspaceId,
|
||||
name: "",
|
||||
description: ""
|
||||
}));
|
||||
};
|
||||
|
||||
editWorkspace = (id: WorkspaceId) => {
|
||||
const workspace = workspaceStore.getById(id);
|
||||
|
||||
this.editingWorkspaces.set(id, toJS(workspace));
|
||||
};
|
||||
|
||||
activateWorkspace = (id: WorkspaceId) => {
|
||||
const clusterId = workspaceStore.getById(id).lastActiveClusterId;
|
||||
return;
|
||||
}
|
||||
|
||||
workspaceStore.setActive(id);
|
||||
clusterStore.setActive(clusterId);
|
||||
};
|
||||
const clusterId = workspaceStore.getById(id).lastActiveClusterId;
|
||||
|
||||
clearEditing = (id: WorkspaceId) => {
|
||||
this.editingWorkspaces.delete(id);
|
||||
};
|
||||
|
||||
removeWorkspace = (id: WorkspaceId) => {
|
||||
const workspace = workspaceStore.getById(id);
|
||||
|
||||
ConfirmDialog.open({
|
||||
okButtonProps: {
|
||||
label: `Remove Workspace`,
|
||||
primary: false,
|
||||
accent: true,
|
||||
},
|
||||
ok: () => {
|
||||
this.clearEditing(id);
|
||||
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>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
onInputKeypress = (evt: React.KeyboardEvent<any>, workspaceId: WorkspaceId) => {
|
||||
if (evt.key == "Enter") {
|
||||
// Trigget input validation
|
||||
evt.currentTarget.blur();
|
||||
evt.currentTarget.focus();
|
||||
this.saveWorkspace(workspaceId);
|
||||
if (clusterId) {
|
||||
navigate(clusterViewURL({ params: { clusterId } }));
|
||||
} else {
|
||||
navigate(landingURL());
|
||||
}
|
||||
|
||||
CommandOverlay.close();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<WizardLayout className="Workspaces" infoPanel={this.renderInfo()}>
|
||||
<h2>
|
||||
Workspaces
|
||||
</h2>
|
||||
<div className="items flex column gaps">
|
||||
{this.workspaces.map(({ id: workspaceId, name, description, ownerRef }) => {
|
||||
const isActive = workspaceStore.currentWorkspaceId === workspaceId;
|
||||
const isDefault = workspaceStore.isDefault(workspaceId);
|
||||
const isEditing = this.editingWorkspaces.has(workspaceId);
|
||||
const editingWorkspace = this.editingWorkspaces.get(workspaceId);
|
||||
const managed = !!ownerRef;
|
||||
const className = cssNames("workspace flex gaps align-center", {
|
||||
active: isActive,
|
||||
editing: isEditing,
|
||||
default: isDefault,
|
||||
});
|
||||
const existenceValidator: InputValidator = {
|
||||
message: () => `Workspace '${name}' already exists`,
|
||||
validate: value => !workspaceStore.getByName(value.trim())
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={workspaceId} className={cssNames(className)}>
|
||||
{!isEditing && (
|
||||
<Fragment>
|
||||
<span className="name flex gaps align-center">
|
||||
<a href="#" onClick={prevDefault(() => this.activateWorkspace(workspaceId))}>{name}</a>
|
||||
{isActive && <span> (current)</span>}
|
||||
</span>
|
||||
<span className="description">{description}</span>
|
||||
{!isDefault && !managed && (
|
||||
<Fragment>
|
||||
<Icon
|
||||
material="edit"
|
||||
tooltip="Edit"
|
||||
onClick={() => this.editWorkspace(workspaceId)}
|
||||
/>
|
||||
<Icon
|
||||
material="delete"
|
||||
tooltip="Delete"
|
||||
onClick={() => this.removeWorkspace(workspaceId)}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
{isEditing && (
|
||||
<Fragment>
|
||||
<Input
|
||||
className="name"
|
||||
placeholder={`Name`}
|
||||
value={editingWorkspace.name}
|
||||
onChange={v => editingWorkspace.name = v}
|
||||
onKeyPress={(e) => this.onInputKeypress(e, workspaceId)}
|
||||
validators={[isRequired, existenceValidator]}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
className="description"
|
||||
placeholder={`Description`}
|
||||
value={editingWorkspace.description}
|
||||
onChange={v => editingWorkspace.description = v}
|
||||
onKeyPress={(e) => this.onInputKeypress(e, workspaceId)}
|
||||
/>
|
||||
<Icon
|
||||
material="save"
|
||||
tooltip="Save"
|
||||
onClick={() => this.saveWorkspace(workspaceId)}
|
||||
/>
|
||||
<Icon
|
||||
material="cancel"
|
||||
tooltip="Cancel"
|
||||
onClick={() => this.clearEditing(workspaceId)}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
primary
|
||||
className="box left"
|
||||
label="Add Workspace"
|
||||
onClick={this.addWorkspace}
|
||||
/>
|
||||
</WizardLayout>
|
||||
<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 />)
|
||||
});
|
||||
|
||||
@ -47,6 +47,7 @@ import { nodesStore } from "./+nodes/nodes.store";
|
||||
import { podsStore } from "./+workloads-pods/pods.store";
|
||||
import { kubeWatchApi } from "../api/kube-watch-api";
|
||||
import { ReplicaSetScaleDialog } from "./+workloads-replicasets/replicaset-scale-dialog";
|
||||
import { CommandContainer } from "./command-palette/command-container";
|
||||
|
||||
@observer
|
||||
export class App extends React.Component {
|
||||
@ -188,6 +189,8 @@ export class App extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const cluster = getHostedCluster();
|
||||
|
||||
return (
|
||||
<Router history={history}>
|
||||
<ErrorBoundary>
|
||||
@ -219,6 +222,7 @@ export class App extends React.Component {
|
||||
<StatefulSetScaleDialog/>
|
||||
<ReplicaSetScaleDialog/>
|
||||
<CronJobTriggerDialog/>
|
||||
<CommandContainer cluster={cluster} />
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
);
|
||||
|
||||
@ -3,9 +3,10 @@ import "./bottom-bar.scss";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Icon } from "../icon";
|
||||
import { WorkspaceMenu } from "../+workspaces/workspace-menu";
|
||||
import { workspaceStore } from "../../../common/workspace-store";
|
||||
import { statusBarRegistry } from "../../../extensions/registries";
|
||||
import { CommandOverlay } from "../command-palette/command-container";
|
||||
import { ChooseWorkspace } from "../+workspaces";
|
||||
|
||||
@observer
|
||||
export class BottomBar extends React.Component {
|
||||
@ -16,13 +17,10 @@ export class BottomBar extends React.Component {
|
||||
|
||||
return (
|
||||
<div className="BottomBar flex gaps">
|
||||
<div id="current-workspace" className="flex gaps align-center">
|
||||
<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">{currentWorkspace.name}</span>
|
||||
<span className="workspace-name" data-test-id="current-workspace-name">{currentWorkspace.name}</span>
|
||||
</div>
|
||||
<WorkspaceMenu
|
||||
htmlFor="current-workspace"
|
||||
/>
|
||||
<div className="extensions box grow flex gaps justify-flex-end">
|
||||
{Array.isArray(items) && items.map(({ item }, index) => {
|
||||
if (!item) return;
|
||||
|
||||
@ -8,7 +8,6 @@ import { ClustersMenu } from "./clusters-menu";
|
||||
import { BottomBar } from "./bottom-bar";
|
||||
import { LandingPage, landingRoute, landingURL } from "../+landing-page";
|
||||
import { Preferences, preferencesRoute } from "../+preferences";
|
||||
import { Workspaces, workspacesRoute } from "../+workspaces";
|
||||
import { AddCluster, addClusterRoute } from "../+add-cluster";
|
||||
import { ClusterView } from "./cluster-view";
|
||||
import { ClusterSettings, clusterSettingsRoute } from "../+cluster-settings";
|
||||
@ -67,7 +66,6 @@ export class ClusterManager extends React.Component {
|
||||
<Route component={LandingPage} {...landingRoute} />
|
||||
<Route component={Preferences} {...preferencesRoute} />
|
||||
<Route component={Extensions} {...extensionsRoute} />
|
||||
<Route component={Workspaces} {...workspacesRoute} />
|
||||
<Route component={AddCluster} {...addClusterRoute} />
|
||||
<Route component={ClusterView} {...clusterViewRoute} />
|
||||
<Route component={ClusterSettings} {...clusterSettingsRoute} />
|
||||
|
||||
@ -22,6 +22,10 @@ import { ConfirmDialog } from "../confirm-dialog";
|
||||
import { clusterViewURL } from "./cluster-view.route";
|
||||
import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries";
|
||||
import { clusterDisconnectHandler } from "../../../common/cluster-ipc";
|
||||
import { commandRegistry } from "../../../extensions/registries/command-registry";
|
||||
import { CommandOverlay } from "../command-palette/command-container";
|
||||
import { computed } from "mobx";
|
||||
import { Select } from "../select";
|
||||
|
||||
interface Props {
|
||||
className?: IClassName;
|
||||
@ -178,3 +182,41 @@ export class ClustersMenu extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@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 />)
|
||||
});
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
#command-container {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
width: 40%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 10px;
|
||||
background-color: var(--dockInfoBackground);
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
|
||||
import "./command-container.scss";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { Dialog } from "../dialog";
|
||||
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";
|
||||
import { Cluster } from "../../../main/cluster";
|
||||
|
||||
export type CommandDialogEvent = {
|
||||
component: React.ReactElement
|
||||
};
|
||||
|
||||
const commandDialogBus = new EventEmitter<[CommandDialogEvent]>();
|
||||
|
||||
export class CommandOverlay {
|
||||
static open(component: React.ReactElement) {
|
||||
commandDialogBus.emit({ component });
|
||||
}
|
||||
|
||||
static close() {
|
||||
commandDialogBus.emit({ component: null });
|
||||
}
|
||||
}
|
||||
|
||||
@observer
|
||||
export class CommandContainer extends React.Component<{cluster?: Cluster}> {
|
||||
@observable.ref commandComponent: React.ReactElement;
|
||||
|
||||
private escHandler(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
this.closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
private closeDialog() {
|
||||
this.commandComponent = null;
|
||||
}
|
||||
|
||||
private findCommandById(commandId: string) {
|
||||
return commandRegistry.getItems().find((command) => command.id === commandId);
|
||||
}
|
||||
|
||||
private runCommand(command: CommandRegistration) {
|
||||
command.action({
|
||||
cluster: clusterStore.active,
|
||||
workspace: workspaceStore.currentWorkspace
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.cluster) {
|
||||
subscribeToBroadcast(`command-palette:run-action:${this.props.cluster.id}`, (event, commandId: string) => {
|
||||
const command = this.findCommandById(commandId);
|
||||
|
||||
if (command) {
|
||||
this.runCommand(command);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
subscribeToBroadcast("command-palette:open", () => {
|
||||
CommandOverlay.open(<CommandDialog />);
|
||||
});
|
||||
}
|
||||
window.addEventListener("keyup", (e) => this.escHandler(e), true);
|
||||
commandDialogBus.addListener((event) => {
|
||||
this.commandComponent = event.component;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Dialog isOpen={!!this.commandComponent} animated={false} onClose={() => this.commandComponent = null}>
|
||||
<div id="command-container">
|
||||
{this.commandComponent}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
88
src/renderer/components/command-palette/command-dialog.tsx
Normal file
88
src/renderer/components/command-palette/command-dialog.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
|
||||
import { Select } from "../select";
|
||||
import { computed, observable, toJS } from "mobx";
|
||||
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";
|
||||
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
|
||||
|
||||
@observer
|
||||
export class CommandDialog extends React.Component {
|
||||
@observable menuIsOpen = true;
|
||||
|
||||
@computed get options() {
|
||||
const context = {
|
||||
cluster: clusterStore.active,
|
||||
workspace: workspaceStore.currentWorkspace
|
||||
};
|
||||
|
||||
return commandRegistry.getItems().filter((command) => {
|
||||
if (command.scope === "cluster" && !clusterStore.active) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!command.isActive) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return command.isActive(context);
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
|
||||
return false;
|
||||
}
|
||||
}).map((command) => {
|
||||
return { value: command.id, label: command.title };
|
||||
}).sort((a, b) => a.label > b.label ? 1 : -1);
|
||||
}
|
||||
|
||||
private onChange(value: string) {
|
||||
const command = commandRegistry.getItems().find((cmd) => cmd.id === value);
|
||||
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = toJS(command.action);
|
||||
|
||||
try {
|
||||
CommandOverlay.close();
|
||||
|
||||
if (command.scope === "global") {
|
||||
action({
|
||||
cluster: clusterStore.active,
|
||||
workspace: workspaceStore.currentWorkspace
|
||||
});
|
||||
} else if(clusterStore.active) {
|
||||
navigate(clusterViewURL({
|
||||
params: {
|
||||
clusterId: clusterStore.active.id
|
||||
}
|
||||
}));
|
||||
broadcastMessage(`command-palette:run-action:${clusterStore.active.id}`, command.id);
|
||||
}
|
||||
} catch(error) {
|
||||
console.error("[COMMAND-DIALOG] failed to execute command", command.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Select
|
||||
onChange={(v) => this.onChange(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={this.menuIsOpen}
|
||||
options={this.options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
data-test-id="command-palette-search"
|
||||
placeholder="" />
|
||||
);
|
||||
}
|
||||
}
|
||||
2
src/renderer/components/command-palette/index.ts
Normal file
2
src/renderer/components/command-palette/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./command-container";
|
||||
export * from "./command-dialog";
|
||||
@ -22,6 +22,7 @@ import { TerminalWindow } from "./terminal-window";
|
||||
import { createTerminalTab, isTerminalTab } from "./terminal.store";
|
||||
import { UpgradeChart } from "./upgrade-chart";
|
||||
import { isUpgradeChartTab } from "./upgrade-chart.store";
|
||||
import { commandRegistry } from "../../../extensions/registries/command-registry";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@ -131,3 +132,11 @@ export class Dock extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
commandRegistry.add({
|
||||
id: "cluster.openTerminal",
|
||||
title: "Cluster: Open terminal",
|
||||
scope: "cluster",
|
||||
action: () => createTerminalTab(),
|
||||
isActive: (context) => !!context.cluster
|
||||
});
|
||||
|
||||
@ -242,7 +242,7 @@ export class Input extends React.Component<InputProps, State> {
|
||||
|
||||
switch (evt.key) {
|
||||
case "Enter":
|
||||
if (this.props.onSubmit && !modified && !evt.repeat) {
|
||||
if (this.props.onSubmit && !modified && !evt.repeat && this.isValid) {
|
||||
this.props.onSubmit(this.getValue());
|
||||
}
|
||||
break;
|
||||
|
||||
@ -11,6 +11,7 @@ import { Notifications } from "./components/notifications";
|
||||
import { ConfirmDialog } from "./components/confirm-dialog";
|
||||
import { extensionLoader } from "../extensions/extension-loader";
|
||||
import { broadcastMessage } from "../common/ipc";
|
||||
import { CommandContainer } from "./components/command-palette/command-container";
|
||||
|
||||
@observer
|
||||
export class LensApp extends React.Component {
|
||||
@ -36,6 +37,7 @@ export class LensApp extends React.Component {
|
||||
</ErrorBoundary>
|
||||
<Notifications/>
|
||||
<ConfirmDialog/>
|
||||
<CommandContainer />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user