mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge remote-tracking branch 'origin/master' into feature/tray
# Conflicts: # src/renderer/components/cluster-manager/cluster-view.tsx
This commit is contained in:
commit
1a0b5c1d79
65 files changed
+978
-643
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import fs from "fs";
|
||||
import mockFs from "mock-fs";
|
||||
import yaml from "js-yaml";
|
||||
import { Cluster } from "../main/cluster";
|
||||
import { ClusterStore } from "./cluster-store";
|
||||
import { workspaceStore } from "./workspace-store";
|
||||
import { Cluster } from "../../main/cluster";
|
||||
import { ClusterStore } from "../cluster-store";
|
||||
import { workspaceStore } from "../workspace-store";
|
||||
|
||||
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png")
|
||||
|
||||
@@ -12,7 +12,7 @@ console.log("") // fix bug
|
||||
let clusterStore: ClusterStore;
|
||||
|
||||
describe("empty config", () => {
|
||||
beforeAll(() => {
|
||||
beforeEach(() => {
|
||||
ClusterStore.resetInstance();
|
||||
const mockOpts = {
|
||||
'tmp': {
|
||||
@@ -24,109 +24,120 @@ describe("empty config", () => {
|
||||
return clusterStore.load();
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
})
|
||||
|
||||
it("adds new cluster to store", async () => {
|
||||
const cluster = new Cluster({
|
||||
id: "foo",
|
||||
contextName: "minikube",
|
||||
preferences: {
|
||||
terminalCWD: "/tmp",
|
||||
icon: "data:;base64,iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
|
||||
clusterName: "minikube"
|
||||
},
|
||||
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"),
|
||||
workspace: workspaceStore.currentWorkspaceId
|
||||
describe("with foo cluster added", () => {
|
||||
beforeEach(() => {
|
||||
clusterStore.addCluster(
|
||||
new Cluster({
|
||||
id: "foo",
|
||||
contextName: "minikube",
|
||||
preferences: {
|
||||
terminalCWD: "/tmp",
|
||||
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
|
||||
clusterName: "minikube"
|
||||
},
|
||||
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"),
|
||||
workspace: workspaceStore.currentWorkspaceId
|
||||
})
|
||||
);
|
||||
})
|
||||
|
||||
it("adds new cluster to store", async () => {
|
||||
const storedCluster = clusterStore.getById("foo");
|
||||
expect(storedCluster.id).toBe("foo");
|
||||
expect(storedCluster.preferences.terminalCWD).toBe("/tmp");
|
||||
expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5");
|
||||
})
|
||||
|
||||
it("adds cluster to default workspace", () => {
|
||||
const storedCluster = clusterStore.getById("foo");
|
||||
expect(storedCluster.workspace).toBe("default");
|
||||
})
|
||||
|
||||
it("removes cluster from store", async () => {
|
||||
await clusterStore.removeById("foo");
|
||||
expect(clusterStore.getById("foo")).toBeUndefined();
|
||||
})
|
||||
|
||||
it("sets active cluster", () => {
|
||||
clusterStore.setActive("foo");
|
||||
expect(clusterStore.activeCluster.id).toBe("foo");
|
||||
})
|
||||
})
|
||||
|
||||
describe("with prod and dev clusters added", () => {
|
||||
beforeEach(() => {
|
||||
clusterStore.addCluster(
|
||||
new Cluster({
|
||||
id: "prod",
|
||||
contextName: "prod",
|
||||
preferences: {
|
||||
clusterName: "prod"
|
||||
},
|
||||
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"),
|
||||
workspace: "workstation"
|
||||
}),
|
||||
new Cluster({
|
||||
id: "dev",
|
||||
contextName: "dev",
|
||||
preferences: {
|
||||
clusterName: "dev"
|
||||
},
|
||||
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"),
|
||||
workspace: "workstation"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("check if store can contain multiple clusters", () => {
|
||||
expect(clusterStore.hasClusters()).toBeTruthy();
|
||||
expect(clusterStore.clusters.size).toBe(2);
|
||||
});
|
||||
clusterStore.addCluster(cluster);
|
||||
const storedCluster = clusterStore.getById(cluster.id);
|
||||
expect(storedCluster.id).toBe(cluster.id);
|
||||
expect(storedCluster.preferences.terminalCWD).toBe(cluster.preferences.terminalCWD);
|
||||
expect(storedCluster.preferences.icon).toBe(cluster.preferences.icon);
|
||||
})
|
||||
|
||||
it("adds cluster to default workspace", () => {
|
||||
const storedCluster = clusterStore.getById("foo");
|
||||
expect(storedCluster.workspace).toBe("default");
|
||||
})
|
||||
it("gets clusters by workspaces", () => {
|
||||
const wsClusters = clusterStore.getByWorkspaceId("workstation");
|
||||
const defaultClusters = clusterStore.getByWorkspaceId("default");
|
||||
expect(defaultClusters.length).toBe(0);
|
||||
expect(wsClusters.length).toBe(2);
|
||||
expect(wsClusters[0].id).toBe("prod");
|
||||
expect(wsClusters[1].id).toBe("dev");
|
||||
})
|
||||
|
||||
it("check if store can contain multiple clusters", () => {
|
||||
const prodCluster = new Cluster({
|
||||
id: "prod",
|
||||
contextName: "prod",
|
||||
preferences: {
|
||||
clusterName: "prod"
|
||||
},
|
||||
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"),
|
||||
workspace: "workstation"
|
||||
});
|
||||
const devCluster = new Cluster({
|
||||
id: "dev",
|
||||
contextName: "dev",
|
||||
preferences: {
|
||||
clusterName: "dev"
|
||||
},
|
||||
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"),
|
||||
workspace: "workstation"
|
||||
});
|
||||
clusterStore.addCluster(prodCluster);
|
||||
clusterStore.addCluster(devCluster);
|
||||
expect(clusterStore.hasClusters()).toBeTruthy();
|
||||
expect(clusterStore.clusters.size).toBe(3);
|
||||
});
|
||||
it("check if cluster's kubeconfig file saved", () => {
|
||||
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
|
||||
})
|
||||
|
||||
it("gets clusters by workspaces", () => {
|
||||
const wsClusters = clusterStore.getByWorkspaceId("workstation");
|
||||
const defaultClusters = clusterStore.getByWorkspaceId("default");
|
||||
expect(defaultClusters.length).toBe(1);
|
||||
expect(wsClusters.length).toBe(2);
|
||||
expect(wsClusters[0].id).toBe("prod");
|
||||
expect(wsClusters[1].id).toBe("dev");
|
||||
})
|
||||
it("check if reorderring works for same from and to", () => {
|
||||
clusterStore.swapIconOrders("workstation", 1, 1)
|
||||
|
||||
it("sets active cluster", () => {
|
||||
clusterStore.setActive("foo");
|
||||
expect(clusterStore.activeCluster.id).toBe("foo");
|
||||
})
|
||||
const clusters = clusterStore.getByWorkspaceId("workstation");
|
||||
expect(clusters[0].id).toBe("prod")
|
||||
expect(clusters[0].preferences.iconOrder).toBe(0)
|
||||
expect(clusters[1].id).toBe("dev")
|
||||
expect(clusters[1].preferences.iconOrder).toBe(1)
|
||||
})
|
||||
|
||||
it("check if cluster's kubeconfig file saved", () => {
|
||||
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
|
||||
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
|
||||
})
|
||||
it("check if reorderring works for different from and to", () => {
|
||||
clusterStore.swapIconOrders("workstation", 0, 1)
|
||||
|
||||
it("check if reorderring works for same from and to", () => {
|
||||
clusterStore.swapIconOrders("workstation", 1, 1)
|
||||
const clusters = clusterStore.getByWorkspaceId("workstation");
|
||||
expect(clusters[0].id).toBe("dev")
|
||||
expect(clusters[0].preferences.iconOrder).toBe(0)
|
||||
expect(clusters[1].id).toBe("prod")
|
||||
expect(clusters[1].preferences.iconOrder).toBe(1)
|
||||
})
|
||||
|
||||
const clusters = clusterStore.getByWorkspaceId("workstation");
|
||||
expect(clusters[0].id).toBe("prod")
|
||||
expect(clusters[0].preferences.iconOrder).toBe(0)
|
||||
expect(clusters[1].id).toBe("dev")
|
||||
expect(clusters[1].preferences.iconOrder).toBe(1)
|
||||
});
|
||||
it("check if after icon reordering, changing workspaces still works", () => {
|
||||
clusterStore.swapIconOrders("workstation", 1, 1)
|
||||
clusterStore.getById("prod").workspace = "default"
|
||||
|
||||
it("check if reorderring works for different from and to", () => {
|
||||
clusterStore.swapIconOrders("workstation", 0, 1)
|
||||
|
||||
const clusters = clusterStore.getByWorkspaceId("workstation");
|
||||
expect(clusters[0].id).toBe("dev")
|
||||
expect(clusters[0].preferences.iconOrder).toBe(0)
|
||||
expect(clusters[1].id).toBe("prod")
|
||||
expect(clusters[1].preferences.iconOrder).toBe(1)
|
||||
});
|
||||
|
||||
it("check if after icon reordering, changing workspaces still works", () => {
|
||||
clusterStore.swapIconOrders("workstation", 1, 1)
|
||||
clusterStore.getById("prod").workspace = "default"
|
||||
|
||||
expect(clusterStore.getByWorkspaceId("workstation").length).toBe(1);
|
||||
expect(clusterStore.getByWorkspaceId("default").length).toBe(2);
|
||||
});
|
||||
|
||||
it("removes cluster from store", async () => {
|
||||
await clusterStore.removeById("foo");
|
||||
expect(clusterStore.getById("foo")).toBeUndefined();
|
||||
expect(clusterStore.getByWorkspaceId("workstation").length).toBe(1);
|
||||
expect(clusterStore.getByWorkspaceId("default").length).toBe(1);
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ jest.mock("electron", () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { UserStore } from "./user-store"
|
||||
import { UserStore } from "../user-store"
|
||||
import { SemVer } from "semver"
|
||||
import electron from "electron"
|
||||
|
||||
@@ -10,7 +10,7 @@ jest.mock("electron", () => {
|
||||
}
|
||||
})
|
||||
|
||||
import { WorkspaceStore } from "./workspace-store"
|
||||
import { WorkspaceStore } from "../workspace-store"
|
||||
|
||||
describe("workspace store tests", () => {
|
||||
describe("for an empty config", () => {
|
||||
@@ -14,6 +14,17 @@ export const clusterIpc = {
|
||||
},
|
||||
}),
|
||||
|
||||
setFrameId: createIpcChannel({
|
||||
channel: "cluster:set-frame-id",
|
||||
handle: (clusterId: ClusterId, frameId?: number) => {
|
||||
const cluster = clusterStore.getById(clusterId);
|
||||
if (cluster) {
|
||||
if (frameId) cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates
|
||||
return cluster.pushState();
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
refresh: createIpcChannel({
|
||||
channel: "cluster:refresh",
|
||||
handle: (clusterId: ClusterId) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { PrometheusLens } from "../main/prometheus/lens";
|
||||
import { PrometheusHelm } from "../main/prometheus/helm";
|
||||
import { PrometheusOperator } from "../main/prometheus/operator";
|
||||
import { PrometheusStacklight } from "../main/prometheus/stacklight";
|
||||
import { PrometheusProviderRegistry } from "../main/prometheus/provider-registry";
|
||||
|
||||
[PrometheusLens, PrometheusHelm, PrometheusOperator].forEach(providerClass => {
|
||||
[PrometheusLens, PrometheusHelm, PrometheusOperator, PrometheusStacklight].forEach(providerClass => {
|
||||
const provider = new providerClass()
|
||||
PrometheusProviderRegistry.registerProvider(provider.id, provider)
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { splitArray } from "./splitArray";
|
||||
import { splitArray } from "../splitArray";
|
||||
|
||||
describe("split array on element tests", () => {
|
||||
test("empty array", () => {
|
||||
@@ -16,7 +16,7 @@ describe("split array on element tests", () => {
|
||||
test("one elements, in array", () => {
|
||||
expect(splitArray([1], 1)).toStrictEqual([[], [], true]);
|
||||
});
|
||||
|
||||
|
||||
test("ten elements, in front array", () => {
|
||||
expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0)).toStrictEqual([[], [1, 2, 3, 4, 5, 6, 7, 8, 9], true]);
|
||||
});
|
||||
+6
-2
@@ -59,6 +59,7 @@ export class Cluster implements ClusterModel {
|
||||
@observable online = false;
|
||||
@observable accessible = false;
|
||||
@observable ready = false;
|
||||
@observable reconnecting = false;
|
||||
@observable disconnected = true;
|
||||
@observable failureReason: string;
|
||||
@observable nodes = 0;
|
||||
@@ -110,7 +111,7 @@ export class Cluster implements ClusterModel {
|
||||
|
||||
protected bindEvents() {
|
||||
logger.info(`[CLUSTER]: bind events`, this.getMeta());
|
||||
const refreshTimer = setInterval(() => this.online && this.refresh(), 30000); // every 30s
|
||||
const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s
|
||||
|
||||
this.eventDisposers.push(
|
||||
reaction(this.getState, this.pushState),
|
||||
@@ -183,6 +184,9 @@ export class Cluster implements ClusterModel {
|
||||
this.refreshEvents(),
|
||||
this.refreshAllowedResources(),
|
||||
]);
|
||||
if (!this.ready) {
|
||||
this.ready = true
|
||||
}
|
||||
}
|
||||
this.pushState();
|
||||
}
|
||||
@@ -233,7 +237,7 @@ export class Cluster implements ClusterModel {
|
||||
const apiUrl = this.kubeProxyUrl + path;
|
||||
return request(apiUrl, {
|
||||
json: true,
|
||||
timeout: 5000,
|
||||
timeout: 30000,
|
||||
...options,
|
||||
headers: {
|
||||
Host: `${this.id}.${new URL(this.kubeProxyUrl).host}`, // required in ClusterManager.getClusterForRequest()
|
||||
|
||||
@@ -283,6 +283,12 @@ export class Kubectl {
|
||||
bashScript += "fi\n"
|
||||
bashScript += `export PATH="${helmPath}:${kubectlPath}:$PATH"\n`
|
||||
bashScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
|
||||
|
||||
bashScript += "NO_PROXY=\",${NO_PROXY:-localhost},\"\n"
|
||||
bashScript += "NO_PROXY=\"${NO_PROXY//,localhost,/,}\"\n"
|
||||
bashScript += "NO_PROXY=\"${NO_PROXY//,127.0.0.1,/,}\"\n"
|
||||
bashScript += "NO_PROXY=\"localhost,127.0.0.1${NO_PROXY%,}\"\n"
|
||||
bashScript += "export NO_PROXY\n"
|
||||
bashScript += "unset tempkubeconfig\n"
|
||||
await fsPromises.writeFile(bashScriptPath, bashScript.toString(), { mode: 0o644 })
|
||||
|
||||
@@ -308,6 +314,11 @@ export class Kubectl {
|
||||
zshScript += "d=${d/#:/}\n"
|
||||
zshScript += "export PATH=\"$helmpath:$kubectlpath:${d/%:/}\"\n"
|
||||
zshScript += "export KUBECONFIG=\"$tempkubeconfig\"\n"
|
||||
zshScript += "NO_PROXY=\",${NO_PROXY:-localhost},\"\n"
|
||||
zshScript += "NO_PROXY=\"${NO_PROXY//,localhost,/,}\"\n"
|
||||
zshScript += "NO_PROXY=\"${NO_PROXY//,127.0.0.1,/,}\"\n"
|
||||
zshScript += "NO_PROXY=\"localhost,127.0.0.1${NO_PROXY%,}\"\n"
|
||||
zshScript += "export NO_PROXY\n"
|
||||
zshScript += "unset tempkubeconfig\n"
|
||||
zshScript += "unset OLD_ZDOTDIR\n"
|
||||
await fsPromises.writeFile(zshScriptPath, zshScript.toString(), { mode: 0o644 })
|
||||
|
||||
@@ -37,19 +37,19 @@ export class PrometheusOperator implements PrometheusProvider {
|
||||
memoryUsage: `
|
||||
sum(
|
||||
node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)
|
||||
) by (node)
|
||||
)
|
||||
`.replace(/_bytes/g, `_bytes * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}`),
|
||||
memoryRequests: `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"}) by (component)`,
|
||||
memoryLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="memory"}) by (component)`,
|
||||
memoryCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="memory"}) by (component)`,
|
||||
cpuUsage: `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])* on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}) by (node)`,
|
||||
cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
memoryRequests: `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"})`,
|
||||
memoryLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="memory"})`,
|
||||
memoryCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="memory"})`,
|
||||
cpuUsage: `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])* on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`,
|
||||
cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`,
|
||||
cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"})`,
|
||||
cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"})`,
|
||||
podUsage: `sum(kubelet_running_pod_count{node=~"${opts.nodes}"})`,
|
||||
podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`,
|
||||
fsSize: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}) by (node)`,
|
||||
fsUsage: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}) by (node)`
|
||||
podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"})`,
|
||||
fsSize: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`,
|
||||
fsUsage: `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`
|
||||
}
|
||||
case 'nodes':
|
||||
return {
|
||||
@@ -62,10 +62,10 @@ export class PrometheusOperator implements PrometheusProvider {
|
||||
}
|
||||
case 'pods':
|
||||
return {
|
||||
cpuUsage: `sum(rate(container_cpu_usage_seconds_total{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`,
|
||||
cpuUsage: `sum(rate(container_cpu_usage_seconds_total{container!="POD",container!="",image!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`,
|
||||
cpuRequests: `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
cpuLimits: `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
memoryUsage: `sum(container_memory_working_set_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
memoryUsage: `sum(container_memory_working_set_bytes{container!="POD",container!="",image!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
memoryRequests: `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
memoryLimits: `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
fsUsage: `sum(container_fs_usage_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { PrometheusProvider, PrometheusQueryOpts, PrometheusQuery, PrometheusService } from "./provider-registry";
|
||||
import { CoreV1Api } from "@kubernetes/client-node";
|
||||
import logger from "../logger"
|
||||
|
||||
export class PrometheusStacklight implements PrometheusProvider {
|
||||
id = "stacklight"
|
||||
name = "Stacklight"
|
||||
rateAccuracy = "1m"
|
||||
|
||||
public async getPrometheusService(client: CoreV1Api): Promise<PrometheusService> {
|
||||
try {
|
||||
const resp = await client.readNamespacedService("prometheus-server", "stacklight")
|
||||
const service = resp.body
|
||||
return {
|
||||
id: this.id,
|
||||
namespace: service.metadata.namespace,
|
||||
service: service.metadata.name,
|
||||
port: service.spec.ports[0].port
|
||||
}
|
||||
} catch(error) {
|
||||
logger.warn(`PrometheusLens: failed to list services: ${error.response.body.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
public getQueries(opts: PrometheusQueryOpts): PrometheusQuery {
|
||||
switch(opts.category) {
|
||||
case 'cluster':
|
||||
return {
|
||||
memoryUsage: `
|
||||
sum(
|
||||
node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)
|
||||
) by (kubernetes_name)
|
||||
`.replace(/_bytes/g, `_bytes{node=~"${opts.nodes}"}`),
|
||||
memoryRequests: `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"}) by (component)`,
|
||||
memoryLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="memory"}) by (component)`,
|
||||
memoryCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="memory"}) by (component)`,
|
||||
cpuUsage: `sum(rate(node_cpu_seconds_total{node=~"${opts.nodes}", mode=~"user|system"}[${this.rateAccuracy}]))`,
|
||||
cpuRequests:`sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
cpuLimits: `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
cpuCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`,
|
||||
podUsage: `sum(kubelet_running_pod_count{instance=~"${opts.nodes}"})`,
|
||||
podCapacity: `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`,
|
||||
fsSize: `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`,
|
||||
fsUsage: `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`
|
||||
}
|
||||
case 'nodes':
|
||||
return {
|
||||
memoryUsage: `sum (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (node)`,
|
||||
memoryCapacity: `sum(kube_node_status_capacity{resource="memory"}) by (node)`,
|
||||
cpuUsage: `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])) by(node)`,
|
||||
cpuCapacity: `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`,
|
||||
fsSize: `sum(node_filesystem_size_bytes{mountpoint="/"}) by (node)`,
|
||||
fsUsage: `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (node)`
|
||||
}
|
||||
case 'pods':
|
||||
return {
|
||||
cpuUsage: `sum(rate(container_cpu_usage_seconds_total{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`,
|
||||
cpuRequests: `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
cpuLimits: `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
memoryUsage: `sum(container_memory_working_set_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
memoryRequests: `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
memoryLimits: `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
fsUsage: `sum(container_fs_usage_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`,
|
||||
networkReceive: `sum(rate(container_network_receive_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`,
|
||||
networkTransmit: `sum(rate(container_network_transmit_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`
|
||||
}
|
||||
case 'pvc':
|
||||
return {
|
||||
diskUsage: `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${opts.pvc}"}) by (persistentvolumeclaim, namespace)`,
|
||||
diskCapacity: `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${opts.pvc}"}) by (persistentvolumeclaim, namespace)`
|
||||
}
|
||||
case 'ingress':
|
||||
const bytesSent = (ingress: string, statuses: string) =>
|
||||
`sum(rate(nginx_ingress_controller_bytes_sent_sum{ingress="${ingress}", status=~"${statuses}"}[${this.rateAccuracy}])) by (ingress)`
|
||||
return {
|
||||
bytesSentSuccess: bytesSent(opts.igress, "^2\\\\d*"),
|
||||
bytesSentFailure: bytesSent(opts.ingres, "^5\\\\d*"),
|
||||
requestDurationSeconds: `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${opts.ingress}"}[${this.rateAccuracy}])) by (ingress)`,
|
||||
responseDurationSeconds: `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${opts.ingress}"}[${this.rateAccuracy}])) by (ingress)`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,8 @@ export class ShellSession extends EventEmitter {
|
||||
if (this.preferences.httpsProxy) {
|
||||
env["HTTPS_PROXY"] = this.preferences.httpsProxy
|
||||
}
|
||||
const no_proxy = ["localhost", "127.0.0.1", env["NO_PROXY"]]
|
||||
env["NO_PROXY"] = no_proxy.filter(address => !!address).join()
|
||||
if (env.DEBUG) { // do not pass debug option to bash
|
||||
delete env["DEBUG"]
|
||||
}
|
||||
|
||||
+7
-8
@@ -1,11 +1,11 @@
|
||||
import { IKubeApiParsed, parseKubeApi } from "./kube-api-parse";
|
||||
import { IKubeApiParsed, parseKubeApi } from "../kube-api-parse";
|
||||
|
||||
interface KubeApi_Parse_Test {
|
||||
interface KubeApiParseTestData {
|
||||
url: string;
|
||||
expected: Required<IKubeApiParsed>;
|
||||
}
|
||||
|
||||
const tests: KubeApi_Parse_Test[] = [
|
||||
const tests: KubeApiParseTestData[] = [
|
||||
{
|
||||
url: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com",
|
||||
expected: {
|
||||
@@ -125,11 +125,10 @@ const tests: KubeApi_Parse_Test[] = [
|
||||
},
|
||||
];
|
||||
|
||||
describe.only("parseApi unit tests", () => {
|
||||
for (const i in tests) {
|
||||
const { url: tUrl, expected:tExpect} = tests[i];
|
||||
test(`test #${parseInt(i)+1}`, () => {
|
||||
expect(parseKubeApi(tUrl)).toStrictEqual(tExpect);
|
||||
describe("parseApi unit tests", () => {
|
||||
for (const { url, expected } of tests) {
|
||||
test(`testing "${url}"`, () => {
|
||||
expect(parseKubeApi(url)).toStrictEqual(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { App } from "./components/app";
|
||||
import { LensApp } from "./lens-app";
|
||||
|
||||
type AppComponent = React.ComponentType & {
|
||||
init?(): void;
|
||||
init?(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function bootstrap(App: AppComponent) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Input } from "../../input";
|
||||
import { observable, autorun } from "mobx";
|
||||
import { observer, disposeOnUnmount } from "mobx-react";
|
||||
import { SubTitle } from "../../layout/sub-title";
|
||||
import { isRequired } from "../../input/input.validators";
|
||||
import { isRequired } from "../../input/input_validators";
|
||||
|
||||
interface Props {
|
||||
cluster: Cluster;
|
||||
@@ -33,7 +33,7 @@ export class ClusterNameSetting extends React.Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<SubTitle title="Cluster Name"/>
|
||||
<SubTitle title="Cluster Name" />
|
||||
<p>Define cluster name.</p>
|
||||
<Input
|
||||
theme="round-black"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observable, autorun } from "mobx";
|
||||
import { observer, disposeOnUnmount } from "mobx-react";
|
||||
import { Cluster } from "../../../../main/cluster";
|
||||
import { Input } from "../../input";
|
||||
import { isUrl } from "../../input/input.validators";
|
||||
import { isUrl } from "../../input/input_validators";
|
||||
import { SubTitle } from "../../layout/sub-title";
|
||||
|
||||
interface Props {
|
||||
@@ -33,7 +33,7 @@ export class ClusterProxySetting extends React.Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<SubTitle title="HTTP Proxy"/>
|
||||
<SubTitle title="HTTP Proxy" />
|
||||
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
|
||||
<Input
|
||||
theme="round-black"
|
||||
|
||||
@@ -14,14 +14,15 @@ import { podsStore } from "../+workloads-pods/pods.store";
|
||||
import { clusterStore } from "./cluster.store";
|
||||
import { eventStore } from "../+events/event.store";
|
||||
import { isAllowedResource } from "../../../common/rbac";
|
||||
import { getHostedCluster } from "../../../common/cluster-store";
|
||||
|
||||
@observer
|
||||
export class Cluster extends React.Component {
|
||||
private dependentStores = [nodesStore, podsStore];
|
||||
|
||||
private watchers = [
|
||||
interval(60, () => clusterStore.getMetrics()),
|
||||
interval(20, () => eventStore.loadAll())
|
||||
interval(60, () => { getHostedCluster().available && clusterStore.getMetrics()}),
|
||||
interval(20, () => { getHostedCluster().available && eventStore.loadAll()})
|
||||
];
|
||||
|
||||
@computed get isLoaded() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { _i18n } from "../../i18n";
|
||||
import { Dialog, DialogProps } from "../dialog";
|
||||
import { Wizard, WizardStep } from "../wizard";
|
||||
import { Input } from "../input";
|
||||
import { systemName } from "../input/input.validators";
|
||||
import { systemName } from "../input/input_validators";
|
||||
import { IResourceQuotaValues, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
|
||||
import { Select } from "../select";
|
||||
import { Icon } from "../icon";
|
||||
@@ -73,7 +73,7 @@ export class AddQuotaDialog extends React.Component<Props> {
|
||||
const isCount = quota.startsWith("count/");
|
||||
const icon = isCompute ? "memory" : isStorage ? "storage" : isCount ? "looks_one" : "";
|
||||
return {
|
||||
label: icon ? <span className="nobr"><Icon material={icon}/> {quota}</span> : quota,
|
||||
label: icon ? <span className="nobr"><Icon material={icon} /> {quota}</span> : quota,
|
||||
value: quota,
|
||||
};
|
||||
});
|
||||
@@ -151,7 +151,7 @@ export class AddQuotaDialog extends React.Component<Props> {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SubTitle title={<Trans>Namespace</Trans>}/>
|
||||
<SubTitle title={<Trans>Namespace</Trans>} />
|
||||
<NamespaceSelect
|
||||
value={this.namespace}
|
||||
placeholder={_i18n._(t`Namespace`)}
|
||||
@@ -160,7 +160,7 @@ export class AddQuotaDialog extends React.Component<Props> {
|
||||
onChange={({ value }) => this.namespace = value}
|
||||
/>
|
||||
|
||||
<SubTitle title={<Trans>Values</Trans>}/>
|
||||
<SubTitle title={<Trans>Values</Trans>} />
|
||||
<div className="flex gaps align-center">
|
||||
<Select
|
||||
className="quota-select"
|
||||
@@ -191,7 +191,7 @@ export class AddQuotaDialog extends React.Component<Props> {
|
||||
<div key={quota} className="quota flex gaps inline align-center">
|
||||
<div className="name">{quota}</div>
|
||||
<div className="value">{value}</div>
|
||||
<Icon material="clear" onClick={() => this.quotas[quota] = ""}/>
|
||||
<Icon material="clear" onClick={() => this.quotas[quota] = ""} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { _i18n } from "../../i18n";
|
||||
import { Dialog, DialogProps } from "../dialog";
|
||||
import { Wizard, WizardStep } from "../wizard";
|
||||
import { Input } from "../input";
|
||||
import { systemName } from "../input/input.validators";
|
||||
import { systemName } from "../input/input_validators";
|
||||
import { Secret, secretsApi, SecretType } from "../../api/endpoints";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { NamespaceSelect } from "../+namespaces/namespace-select";
|
||||
@@ -184,7 +184,7 @@ export class AddSecretDialog extends React.Component<Props> {
|
||||
<Wizard header={header} done={this.close}>
|
||||
<WizardStep contentClass="flow column" nextLabel={<Trans>Create</Trans>} next={this.createSecret}>
|
||||
<div className="secret-name">
|
||||
<SubTitle title={<Trans>Secret name</Trans>}/>
|
||||
<SubTitle title={<Trans>Secret name</Trans>} />
|
||||
<Input
|
||||
autoFocus required
|
||||
placeholder={_i18n._(t`Name`)}
|
||||
@@ -194,7 +194,7 @@ export class AddSecretDialog extends React.Component<Props> {
|
||||
</div>
|
||||
<div className="flex auto gaps">
|
||||
<div className="secret-namespace">
|
||||
<SubTitle title={<Trans>Namespace</Trans>}/>
|
||||
<SubTitle title={<Trans>Namespace</Trans>} />
|
||||
<NamespaceSelect
|
||||
themeName="light"
|
||||
value={namespace}
|
||||
@@ -202,7 +202,7 @@ export class AddSecretDialog extends React.Component<Props> {
|
||||
/>
|
||||
</div>
|
||||
<div className="secret-type">
|
||||
<SubTitle title={<Trans>Secret type</Trans>}/>
|
||||
<SubTitle title={<Trans>Secret type</Trans>} />
|
||||
<Select
|
||||
themeName="light"
|
||||
options={this.types}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Wizard, WizardStep } from "../wizard";
|
||||
import { namespaceStore } from "./namespace.store";
|
||||
import { Namespace } from "../../api/endpoints";
|
||||
import { Input } from "../input";
|
||||
import { systemName } from "../input/input.validators";
|
||||
import { systemName } from "../input/input_validators";
|
||||
import { Notifications } from "../notifications";
|
||||
|
||||
interface Props extends DialogProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { isPath } from '../input/input.validators';
|
||||
import { isPath } from '../input/input_validators';
|
||||
import { Checkbox } from '../checkbox';
|
||||
import { Input } from '../input';
|
||||
import { SubTitle } from '../layout/sub-title';
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ import { Wizard, WizardStep } from "../wizard";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { serviceAccountsStore } from "./service-accounts.store";
|
||||
import { Input } from "../input";
|
||||
import { systemName } from "../input/input.validators";
|
||||
import { systemName } from "../input/input_validators";
|
||||
import { NamespaceSelect } from "../+namespaces/namespace-select";
|
||||
import { Notifications } from "../notifications";
|
||||
import { showDetails } from "../../navigation";
|
||||
@@ -62,14 +62,14 @@ export class CreateServiceAccountDialog extends React.Component<Props> {
|
||||
>
|
||||
<Wizard header={header} done={this.close}>
|
||||
<WizardStep nextLabel={<Trans>Create</Trans>} next={this.createAccount}>
|
||||
<SubTitle title={<Trans>Account Name</Trans>}/>
|
||||
<SubTitle title={<Trans>Account Name</Trans>} />
|
||||
<Input
|
||||
autoFocus required
|
||||
placeholder={_i18n._(t`Enter a name`)}
|
||||
validators={systemName}
|
||||
value={name} onChange={v => this.name = v.toLowerCase()}
|
||||
/>
|
||||
<SubTitle title={<Trans>Namespace</Trans>}/>
|
||||
<SubTitle title={<Trans>Namespace</Trans>} />
|
||||
<NamespaceSelect
|
||||
themeName="light"
|
||||
value={namespace}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { CronJob, cronJobApi, jobApi, Job } from "../../api/endpoints";
|
||||
import { Notifications } from "../notifications";
|
||||
import { cssNames } from "../../utils";
|
||||
import { Input } from "../input";
|
||||
import { systemName, maxLength } from "../input/input.validators";
|
||||
import { systemName, maxLength } from "../input/input_validators";
|
||||
|
||||
interface Props extends Partial<DialogProps> {
|
||||
}
|
||||
|
||||
@@ -113,7 +113,8 @@ const SecretKey = (props: SecretKeyProps) => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [secret, setSecret] = useState<Secret>()
|
||||
|
||||
const showKey = async () => {
|
||||
const showKey = async (evt: React.MouseEvent) => {
|
||||
evt.preventDefault()
|
||||
setLoading(true)
|
||||
const secret = await secretsStore.load({ name, namespace });
|
||||
setLoading(false)
|
||||
@@ -126,7 +127,7 @@ const SecretKey = (props: SecretKeyProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
secretKeyRef({name}.{key})
|
||||
secretKeyRef({name}.{key})
|
||||
<Icon
|
||||
className={cssNames("secret-button", { loading })}
|
||||
material="visibility"
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Icon } from "../icon";
|
||||
import { Input } from "../input";
|
||||
import { cssNames, prevDefault } from "../../utils";
|
||||
import { Button } from "../button";
|
||||
import { isRequired, Validator } from "../input/input.validators";
|
||||
import { isRequired, Validator } from "../input/input_validators";
|
||||
|
||||
@observer
|
||||
export class Workspaces extends React.Component {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
.theme-light & {
|
||||
border: 1px solid gainsboro;
|
||||
border-radius: $radius;
|
||||
|
||||
.ace_scrollbar {
|
||||
@include custom-scrollbar(dark);
|
||||
@@ -19,7 +18,6 @@
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
font-size: 90%;
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
// --Theme customization
|
||||
|
||||
@@ -44,8 +44,8 @@ export class App extends React.Component {
|
||||
const clusterId = getHostedClusterId();
|
||||
logger.info(`[APP]: Init dashboard, clusterId=${clusterId}, frameId=${frameId}`)
|
||||
await Terminal.preloadFonts()
|
||||
await clusterIpc.activate.invokeFromRenderer(clusterId, frameId);
|
||||
await getHostedCluster().whenReady; // cluster.refresh() is done at this point
|
||||
await clusterIpc.setFrameId.invokeFromRenderer(clusterId, frameId);
|
||||
await getHostedCluster().whenReady; // cluster.activate() is done at this point
|
||||
}
|
||||
|
||||
get startURL() {
|
||||
|
||||
@@ -18,13 +18,6 @@ export class Badge extends React.Component<Props> {
|
||||
{label}
|
||||
{children}
|
||||
</span>
|
||||
{ /**
|
||||
* This is a zero-width-space. It makes there be a word seperation
|
||||
* between adjacent Badge's because <span>'s are ignored for browers'
|
||||
* word detection algorithmns use for determining the extent of the
|
||||
* text to highlight on multi-click sequences.
|
||||
*/}
|
||||
​
|
||||
</>
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.outlined {
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
|
||||
&.active,
|
||||
&:focus {
|
||||
color: inherit;
|
||||
box-shadow: 0 0 0 1px inset;
|
||||
}
|
||||
}
|
||||
|
||||
&.big {
|
||||
font-size: 2.2 * $unit;
|
||||
border-radius: 50px;
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<any>, TooltipDecorator
|
||||
primary?: boolean;
|
||||
accent?: boolean;
|
||||
plain?: boolean;
|
||||
outlined?: boolean;
|
||||
hidden?: boolean;
|
||||
active?: boolean;
|
||||
big?: boolean;
|
||||
@@ -23,12 +24,12 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
|
||||
private button: HTMLButtonElement;
|
||||
|
||||
render() {
|
||||
const { className, waiting, label, primary, accent, plain, hidden, active, big, round, tooltip, children, ...props } = this.props;
|
||||
const { className, waiting, label, primary, accent, plain, hidden, active, big, round, outlined, tooltip, children, ...props } = this.props;
|
||||
const btnProps = props as Partial<ButtonProps>;
|
||||
if (hidden) return null;
|
||||
|
||||
btnProps.className = cssNames('Button', className, {
|
||||
waiting, primary, accent, plain, active, big, round,
|
||||
waiting, primary, accent, plain, active, big, round, outlined
|
||||
});
|
||||
|
||||
const btnContent: ReactNode = (
|
||||
|
||||
@@ -2,12 +2,12 @@ import "./cluster-view.scss"
|
||||
import React from "react";
|
||||
import { reaction } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
import { IClusterViewRouteParams } from "./cluster-view.route";
|
||||
import { ClusterStatus } from "./cluster-status";
|
||||
import { hasLoadedView } from "./lens-views";
|
||||
import { Cluster } from "../../../main/cluster";
|
||||
import { clusterStore } from "../../../common/cluster-store";
|
||||
import { RouteComponentProps } from "react-router";
|
||||
|
||||
interface Props extends RouteComponentProps<IClusterViewRouteParams> {
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export class ClusterView extends React.Component<Props> {
|
||||
|
||||
render() {
|
||||
const { cluster } = this;
|
||||
const showStatus = cluster && (!cluster.available || !hasLoadedView(cluster.id))
|
||||
const showStatus = cluster && (!cluster.available || !hasLoadedView(cluster.id) || !cluster.ready)
|
||||
return (
|
||||
<div className="ClusterView">
|
||||
{showStatus && (
|
||||
|
||||
@@ -39,6 +39,7 @@ export class CreateResource extends React.Component<Props> {
|
||||
|
||||
create = async () => {
|
||||
if (this.error) return;
|
||||
if (!this.data.trim()) return; // do not save when field is empty
|
||||
const resources = jsYaml.safeLoadAll(this.data)
|
||||
.filter(v => !!v) // skip empty documents if "---" pasted at the beginning or end
|
||||
const createdResources: string[] = [];
|
||||
@@ -54,12 +55,14 @@ export class CreateResource extends React.Component<Props> {
|
||||
errors.forEach(Notifications.error);
|
||||
if (!createdResources.length) throw errors[0];
|
||||
}
|
||||
return (
|
||||
const successMessage = (
|
||||
<p>
|
||||
<Plural value={createdResources.length} one="Resource" other="Resources"/>{" "}
|
||||
<Trans><b>{createdResources.join(", ")}</b> successfully created</Trans>
|
||||
</p>
|
||||
)
|
||||
Notifications.ok(successMessage)
|
||||
return successMessage
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -67,11 +70,6 @@ export class CreateResource extends React.Component<Props> {
|
||||
const { className } = this.props;
|
||||
return (
|
||||
<div className={cssNames("CreateResource flex column", className)}>
|
||||
<EditorPanel
|
||||
tabId={tabId}
|
||||
value={data}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<InfoPanel
|
||||
tabId={tabId}
|
||||
error={error}
|
||||
@@ -79,6 +77,11 @@ export class CreateResource extends React.Component<Props> {
|
||||
submitLabel={_i18n._(t`Create`)}
|
||||
showNotifications={false}
|
||||
/>
|
||||
<EditorPanel
|
||||
tabId={tabId}
|
||||
value={data}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
|
||||
> .resizer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,24 +61,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.resizer {
|
||||
$height: 12px;
|
||||
|
||||
position: absolute;
|
||||
top: -$height / 2;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
height: $height;
|
||||
cursor: row-resize;
|
||||
z-index: 10;
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.AceEditor {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ export class DockStore {
|
||||
];
|
||||
|
||||
protected storage = createStorage("dock", {}); // keep settings in localStorage
|
||||
public defaultTabId = this.initialTabs[0].id;
|
||||
public minHeight = 100;
|
||||
public readonly defaultTabId = this.initialTabs[0].id;
|
||||
public readonly minHeight = 100;
|
||||
|
||||
@observable isOpen = false;
|
||||
@observable fullSize = false;
|
||||
@@ -45,11 +45,12 @@ export class DockStore {
|
||||
}
|
||||
|
||||
get maxHeight() {
|
||||
const mainLayoutHeader = 40;
|
||||
const mainLayoutTabs = 33;
|
||||
const mainLayoutMargin = 16;
|
||||
const dockTabs = 33;
|
||||
return window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs;
|
||||
const mainLayoutHeader = 40
|
||||
const mainLayoutTabs = 33
|
||||
const mainLayoutMargin = 16
|
||||
const dockTabs = 33
|
||||
const preferedMax = window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs
|
||||
return Math.max(preferedMax, this.minHeight) // don't let max < min
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -65,7 +66,6 @@ export class DockStore {
|
||||
});
|
||||
|
||||
// adjust terminal height if window size changes
|
||||
this.checkMaxHeight();
|
||||
window.addEventListener("resize", throttle(this.checkMaxHeight, 250));
|
||||
}
|
||||
|
||||
@@ -171,20 +171,19 @@ export class DockStore {
|
||||
|
||||
@action
|
||||
selectTab(tabId: TabId) {
|
||||
const tab = this.getTabById(tabId);
|
||||
this.selectedTabId = tab ? tab.id : null;
|
||||
this.selectedTabId = this.getTabById(tabId)?.id ?? null;
|
||||
}
|
||||
|
||||
@action
|
||||
setHeight(height: number) {
|
||||
this.height = Math.max(0, Math.min(height, this.maxHeight));
|
||||
setHeight(height?: number) {
|
||||
this.height = Math.max(this.minHeight, Math.min(height || this.minHeight, this.maxHeight));
|
||||
}
|
||||
|
||||
@action
|
||||
reset() {
|
||||
this.selectedTabId = this.defaultTabId;
|
||||
this.tabs.replace(this.initialTabs);
|
||||
this.height = this.defaultHeight;
|
||||
this.setHeight(this.defaultHeight);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { Fragment } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { autobind, cssNames, prevDefault } from "../../utils";
|
||||
import { Draggable, DraggableState } from "../draggable";
|
||||
import { ResizingAnchor, ResizeDirection } from "../resizing-anchor";
|
||||
import { Icon } from "../icon";
|
||||
import { Tabs } from "../tabs/tabs";
|
||||
import { MenuItem } from "../menu";
|
||||
@@ -29,28 +29,8 @@ interface Props {
|
||||
|
||||
@observer
|
||||
export class Dock extends React.Component<Props> {
|
||||
onResizeStart = () => {
|
||||
const { isOpen, open, setHeight, minHeight } = dockStore;
|
||||
if (!isOpen) {
|
||||
open();
|
||||
setHeight(minHeight);
|
||||
}
|
||||
}
|
||||
|
||||
onResize = ({ offsetY }: DraggableState) => {
|
||||
const { isOpen, close, height, setHeight, minHeight, defaultHeight } = dockStore;
|
||||
const newHeight = height + offsetY;
|
||||
if (height > newHeight && newHeight < minHeight) {
|
||||
setHeight(defaultHeight);
|
||||
close();
|
||||
}
|
||||
else if (isOpen) {
|
||||
setHeight(newHeight);
|
||||
}
|
||||
}
|
||||
|
||||
onKeydown = (evt: React.KeyboardEvent<HTMLElement>) => {
|
||||
const { close, closeTab, selectedTab, fullSize, toggleFillSize } = dockStore;
|
||||
const { close, closeTab, selectedTab } = dockStore;
|
||||
if (!selectedTab) return;
|
||||
const { code, ctrlKey, shiftKey } = evt.nativeEvent;
|
||||
if (shiftKey && code === "Escape") {
|
||||
@@ -71,13 +51,13 @@ export class Dock extends React.Component<Props> {
|
||||
@autobind()
|
||||
renderTab(tab: IDockTab) {
|
||||
if (isTerminalTab(tab)) {
|
||||
return <TerminalTab value={tab}/>
|
||||
return <TerminalTab value={tab} />
|
||||
}
|
||||
if (isCreateResourceTab(tab) || isEditResourceTab(tab)) {
|
||||
return <DockTab value={tab} icon="edit"/>
|
||||
return <DockTab value={tab} icon="edit" />
|
||||
}
|
||||
if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) {
|
||||
return <DockTab value={tab} icon={<Icon svg="install"/>}/>
|
||||
return <DockTab value={tab} icon={<Icon svg="install" />} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +66,11 @@ export class Dock extends React.Component<Props> {
|
||||
if (!isOpen || !tab) return;
|
||||
return (
|
||||
<div className="tab-content" style={{ flexBasis: height }}>
|
||||
{isCreateResourceTab(tab) && <CreateResource tab={tab}/>}
|
||||
{isEditResourceTab(tab) && <EditResource tab={tab}/>}
|
||||
{isInstallChartTab(tab) && <InstallChart tab={tab}/>}
|
||||
{isUpgradeChartTab(tab) && <UpgradeChart tab={tab}/>}
|
||||
{isTerminalTab(tab) && <TerminalWindow tab={tab}/>}
|
||||
{isCreateResourceTab(tab) && <CreateResource tab={tab} />}
|
||||
{isEditResourceTab(tab) && <EditResource tab={tab} />}
|
||||
{isInstallChartTab(tab) && <InstallChart tab={tab} />}
|
||||
{isUpgradeChartTab(tab) && <UpgradeChart tab={tab} />}
|
||||
{isTerminalTab(tab) && <TerminalWindow tab={tab} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -104,11 +84,16 @@ export class Dock extends React.Component<Props> {
|
||||
onKeyDown={this.onKeydown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Draggable
|
||||
className={cssNames("resizer", { disabled: !hasTabs() })}
|
||||
horizontal={false}
|
||||
onStart={this.onResizeStart}
|
||||
onEnter={this.onResize}
|
||||
<ResizingAnchor
|
||||
disabled={!hasTabs()}
|
||||
getCurrentExtent={() => dockStore.height}
|
||||
minExtent={dockStore.minHeight}
|
||||
maxExtent={dockStore.maxHeight}
|
||||
direction={ResizeDirection.VERTICAL}
|
||||
onStart={dockStore.open}
|
||||
onMinExtentSubceed={dockStore.close}
|
||||
onMinExtentExceed={dockStore.open}
|
||||
onDrag={dockStore.setHeight}
|
||||
/>
|
||||
<div className="tabs-container flex align-center" onDoubleClick={prevDefault(toggle)}>
|
||||
<Tabs
|
||||
@@ -121,11 +106,11 @@ export class Dock extends React.Component<Props> {
|
||||
<div className="dock-menu box grow">
|
||||
<MenuActions usePortal triggerIcon={{ material: "add", className: "new-dock-tab", tooltip: <Trans>New tab</Trans> }} closeOnScroll={false}>
|
||||
<MenuItem className="create-terminal-tab" onClick={() => createTerminalTab()}>
|
||||
<Icon small svg="terminal" size={15}/>
|
||||
<Icon small svg="terminal" size={15} />
|
||||
<Trans>Terminal session</Trans>
|
||||
</MenuItem>
|
||||
<MenuItem className="create-resource-tab" onClick={() => createResourceTab()}>
|
||||
<Icon small material="create"/>
|
||||
<Icon small material="create" />
|
||||
<Trans>Create resource</Trans>
|
||||
</MenuItem>
|
||||
</MenuActions>
|
||||
@@ -133,7 +118,7 @@ export class Dock extends React.Component<Props> {
|
||||
{hasTabs() && (
|
||||
<>
|
||||
<Icon
|
||||
material={fullSize ? "fullscreen_exit": "fullscreen"}
|
||||
material={fullSize ? "fullscreen_exit" : "fullscreen"}
|
||||
tooltip={fullSize ? <Trans>Exit full size mode</Trans> : <Trans>Fit to window</Trans>}
|
||||
onClick={toggleFillSize}
|
||||
/>
|
||||
|
||||
@@ -90,11 +90,6 @@ export class EditResource extends React.Component<Props> {
|
||||
const { kind, getNs, getName } = resource;
|
||||
return (
|
||||
<div className={cssNames("EditResource flex column", this.props.className)}>
|
||||
<EditorPanel
|
||||
tabId={tabId}
|
||||
value={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<InfoPanel
|
||||
tabId={tabId}
|
||||
error={error}
|
||||
@@ -109,6 +104,11 @@ export class EditResource extends React.Component<Props> {
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<EditorPanel
|
||||
tabId={tabId}
|
||||
value={draft}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@include hidden-scrollbar;
|
||||
|
||||
background: $dockInfoBackground;
|
||||
border-top: 1px solid $dockInfoBorderColor;
|
||||
border-bottom: 1px solid $dockInfoBorderColor;
|
||||
padding: $padding $padding * 2;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -12,37 +12,12 @@
|
||||
|
||||
> .controls {
|
||||
white-space: nowrap;
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
flex: 1 1;
|
||||
|
||||
&:not(:empty) + .info {
|
||||
border: 1px solid $borderColor;
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
min-height: 25px;
|
||||
padding-left: $padding;
|
||||
padding-right: $padding;
|
||||
}
|
||||
}
|
||||
|
||||
> .info {
|
||||
@include hidden-scrollbar;
|
||||
|
||||
min-width: 40px; // min-space for icon
|
||||
flex: 1 1;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
> div {
|
||||
padding-right: $padding;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.Icon {
|
||||
margin: 0;
|
||||
margin-right: $padding;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,35 +38,29 @@ export class InfoPanel extends Component<Props> {
|
||||
showNotifications: true,
|
||||
}
|
||||
|
||||
@observable.ref result: ReactNode;
|
||||
@observable error = "";
|
||||
@observable waiting = false;
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
reaction(() => this.props.tabId, () => {
|
||||
this.result = ""
|
||||
this.error = ""
|
||||
this.waiting = false
|
||||
})
|
||||
])
|
||||
}
|
||||
|
||||
@computed get errorInfo() {
|
||||
return this.error || this.props.error;
|
||||
return this.props.error;
|
||||
}
|
||||
|
||||
submit = async () => {
|
||||
const { showNotifications } = this.props;
|
||||
this.result = "";
|
||||
this.error = "";
|
||||
this.waiting = true;
|
||||
try {
|
||||
this.result = await this.props.submit()
|
||||
if (showNotifications) Notifications.ok(this.result);
|
||||
const result = await this.props.submit();
|
||||
if (showNotifications) Notifications.ok(result);
|
||||
} catch (error) {
|
||||
this.error = error.toString();
|
||||
if (showNotifications) Notifications.error(this.error);
|
||||
if (showNotifications) Notifications.error(error.toString());
|
||||
} finally {
|
||||
this.waiting = false
|
||||
}
|
||||
@@ -81,27 +75,15 @@ export class InfoPanel extends Component<Props> {
|
||||
dockStore.closeTab(this.props.tabId);
|
||||
}
|
||||
|
||||
renderInfo() {
|
||||
if (!this.props.showInlineInfo) {
|
||||
renderErrorIcon() {
|
||||
if (!this.props.showInlineInfo || !this.errorInfo) {
|
||||
return;
|
||||
}
|
||||
const { result, errorInfo } = this;
|
||||
return (
|
||||
<>
|
||||
{result && (
|
||||
<div className="success flex align-center">
|
||||
<Icon material="done" />
|
||||
<span>{result}</span>
|
||||
</div>
|
||||
)}
|
||||
{errorInfo && (
|
||||
<div className="error flex align-center">
|
||||
<Icon material="error_outline" />
|
||||
<span>{errorInfo}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
<div className="error">
|
||||
<Icon material="error_outline" tooltip={this.errorInfo}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -114,11 +96,13 @@ export class InfoPanel extends Component<Props> {
|
||||
{controls}
|
||||
</div>
|
||||
<div className="info flex gaps align-center">
|
||||
{waiting ? <><Spinner /> {submittingMessage}</> : this.renderInfo()}
|
||||
{waiting ? <><Spinner /> {submittingMessage}</> : this.renderErrorIcon()}
|
||||
</div>
|
||||
<Button plain label={<Trans>Cancel</Trans>} onClick={close} />
|
||||
<Button
|
||||
primary active
|
||||
active
|
||||
outlined={showSubmitClose}
|
||||
primary={!showSubmitClose}// one button always should be primary (blue)
|
||||
label={submitLabel}
|
||||
onClick={submit}
|
||||
disabled={isDisabled}
|
||||
|
||||
@@ -174,11 +174,6 @@ export class InstallChart extends Component<Props> {
|
||||
|
||||
return (
|
||||
<div className="InstallChart flex column">
|
||||
<EditorPanel
|
||||
tabId={tabId}
|
||||
value={values}
|
||||
onChange={this.onValuesChange}
|
||||
/>
|
||||
<InfoPanel
|
||||
tabId={tabId}
|
||||
controls={panelControls}
|
||||
@@ -188,6 +183,11 @@ export class InstallChart extends Component<Props> {
|
||||
submittingMessage={_i18n._(t`Installing...`)}
|
||||
showSubmitClose={false}
|
||||
/>
|
||||
<EditorPanel
|
||||
tabId={tabId}
|
||||
value={values}
|
||||
onChange={this.onValuesChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,8 @@ export class Terminal {
|
||||
}
|
||||
|
||||
fit = () => {
|
||||
// Since this function is debounced we need to read this value as late as possible
|
||||
if (!this.isActive) return;
|
||||
this.fitAddon.fit();
|
||||
const { cols, rows } = this.xterm;
|
||||
this.api.sendTerminalSize(cols, rows);
|
||||
@@ -150,7 +152,6 @@ export class Terminal {
|
||||
}
|
||||
|
||||
onResize = () => {
|
||||
if (!this.isActive) return;
|
||||
this.fitLazy();
|
||||
this.focus();
|
||||
}
|
||||
@@ -176,8 +177,8 @@ export class Terminal {
|
||||
if (this.xterm.hasSelection()) return false;
|
||||
break;
|
||||
|
||||
// Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim
|
||||
// https://github.com/kontena/lens-app/issues/156#issuecomment-534906480
|
||||
// Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim
|
||||
// https://github.com/kontena/lens-app/issues/156#issuecomment-534906480
|
||||
case "KeyW":
|
||||
evt.preventDefault();
|
||||
break;
|
||||
|
||||
@@ -106,18 +106,13 @@ export class UpgradeChart extends React.Component<Props> {
|
||||
themeName="outlined"
|
||||
value={version}
|
||||
options={versions}
|
||||
formatOptionLabel={this.formatVersionLabel}
|
||||
formatOptionLabel={this.formatVersionLabel}
|
||||
onChange={({ value }: SelectOption) => this.version = value}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className={cssNames("UpgradeChart flex column", className)}>
|
||||
<EditorPanel
|
||||
tabId={tabId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<InfoPanel
|
||||
tabId={tabId}
|
||||
error={error}
|
||||
@@ -126,6 +121,11 @@ export class UpgradeChart extends React.Component<Props> {
|
||||
submittingMessage={_i18n._(t`Updating..`)}
|
||||
controls={controlsAndInfo}
|
||||
/>
|
||||
<EditorPanel
|
||||
tabId={tabId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
body.dragging {
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import "./draggable.scss";
|
||||
import React from "react";
|
||||
import { cssNames, IClassName, noop } from "../../utils";
|
||||
import throttle from "lodash/throttle";
|
||||
|
||||
export interface DraggableEventHandler {
|
||||
(state: DraggableState): void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
className?: IClassName;
|
||||
vertical?: boolean;
|
||||
horizontal?: boolean;
|
||||
onStart?: DraggableEventHandler;
|
||||
onEnter?: DraggableEventHandler;
|
||||
onEnd?: DraggableEventHandler;
|
||||
}
|
||||
|
||||
export interface DraggableState {
|
||||
inited?: boolean;
|
||||
changed?: boolean;
|
||||
initX?: number;
|
||||
initY?: number;
|
||||
pageX?: number;
|
||||
pageY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
const initState: DraggableState = {
|
||||
inited: false,
|
||||
changed: false,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
|
||||
export class Draggable extends React.PureComponent<Props, DraggableState> {
|
||||
public state = initState;
|
||||
|
||||
static IS_DRAGGING = "dragging"
|
||||
|
||||
static defaultProps: Props = {
|
||||
vertical: true,
|
||||
horizontal: true,
|
||||
onStart: noop,
|
||||
onEnter: noop,
|
||||
onEnd: noop,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
document.addEventListener("mousemove", this.onDrag);
|
||||
document.addEventListener("mouseup", this.onDragEnd);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("mousemove", this.onDrag);
|
||||
document.removeEventListener("mouseup", this.onDragEnd);
|
||||
}
|
||||
|
||||
onDragInit = (evt: React.MouseEvent<any>) => {
|
||||
document.body.classList.add(Draggable.IS_DRAGGING);
|
||||
const { pageX, pageY } = evt;
|
||||
this.setState({
|
||||
inited: true,
|
||||
initX: pageX,
|
||||
initY: pageY,
|
||||
pageX: pageX,
|
||||
pageY: pageY,
|
||||
})
|
||||
}
|
||||
|
||||
onDrag = throttle((evt: MouseEvent) => {
|
||||
const { vertical, horizontal, onEnter, onStart } = this.props;
|
||||
const { inited, pageX, pageY } = this.state;
|
||||
const offsetX = pageX - evt.pageX;
|
||||
const offsetY = pageY - evt.pageY;
|
||||
let changed = false;
|
||||
if (horizontal && offsetX !== 0) changed = true;
|
||||
if (vertical && offsetY !== 0) changed = true;
|
||||
if (inited && changed) {
|
||||
const start = !this.state.changed;
|
||||
const state = Object.assign({}, this.state, {
|
||||
changed: true,
|
||||
pageX: evt.pageX,
|
||||
pageY: evt.pageY,
|
||||
offsetX: offsetX,
|
||||
offsetY: offsetY,
|
||||
});
|
||||
if (start) onStart(state);
|
||||
this.setState(state, () => onEnter(state));
|
||||
}
|
||||
}, 100)
|
||||
|
||||
onDragEnd = (evt: MouseEvent) => {
|
||||
const { pageX, pageY } = evt;
|
||||
const { inited, changed, initX, initY } = this.state;
|
||||
if (inited) {
|
||||
document.body.classList.remove(Draggable.IS_DRAGGING);
|
||||
this.setState(initState, () => {
|
||||
if (!changed) return;
|
||||
const state = Object.assign({}, this.state, {
|
||||
offsetX: initX - pageX,
|
||||
offsetY: initY - pageY,
|
||||
});
|
||||
this.props.onEnd(state);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, children } = this.props;
|
||||
return (
|
||||
<div className={cssNames("Draggable", className)} onMouseDown={this.onDragInit}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './draggable'
|
||||
@@ -48,6 +48,11 @@
|
||||
float: left;
|
||||
margin: $spacing;
|
||||
|
||||
&:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { isEmail, systemName } from "./input.validators";
|
||||
import { isEmail, systemName } from "../input_validators";
|
||||
|
||||
describe("input validation tests", () => {
|
||||
describe("isEmail tests", () => {
|
||||
@@ -3,7 +3,7 @@ import "./input.scss";
|
||||
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
import { autobind, cssNames, debouncePromise } from "../../utils";
|
||||
import { Icon } from "../icon";
|
||||
import { conditionalValidators, Validator } from "./input.validators";
|
||||
import { conditionalValidators, Validator } from "./input_validators";
|
||||
import isString from "lodash/isString"
|
||||
import isFunction from "lodash/isFunction"
|
||||
import isBoolean from "lodash/isBoolean"
|
||||
@@ -288,9 +288,9 @@ export class Input extends React.Component<InputProps, State> {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="input-area flex gaps align-center">
|
||||
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
||||
{multiLine ? <textarea {...inputProps as any}/> : <input {...inputProps as any}/>}
|
||||
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
||||
{isString(iconLeft) ? <Icon material={iconLeft} /> : iconLeft}
|
||||
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
||||
{isString(iconRight) ? <Icon material={iconRight} /> : iconRight}
|
||||
</label>
|
||||
<div className="input-info flex gaps">
|
||||
{!valid && dirty && (
|
||||
|
||||
File renamed without changes.
@@ -1,6 +1,4 @@
|
||||
.MainLayout {
|
||||
--sidebar-max-size: 200px;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"aside header"
|
||||
@@ -35,7 +33,7 @@
|
||||
transition: width 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.pinned {
|
||||
width: var(--sidebar-max-size);
|
||||
width: var(--sidebar-width);
|
||||
}
|
||||
|
||||
&:not(.pinned) {
|
||||
@@ -45,7 +43,7 @@
|
||||
overflow: hidden;
|
||||
|
||||
&.accessible:hover {
|
||||
width: var(--sidebar-max-size);
|
||||
width: var(--sidebar-width);
|
||||
transition-delay: 750ms;
|
||||
box-shadow: 3px 3px 16px rgba(0, 0, 0, 0.35);
|
||||
z-index: $zIndex-sidebar-hover;
|
||||
|
||||
@@ -3,11 +3,12 @@ import "./main-layout.scss";
|
||||
import React from "react";
|
||||
import { observable, reaction } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { createStorage, cssNames } from "../../utils";
|
||||
import { autobind, createStorage, cssNames } from "../../utils";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { ErrorBoundary } from "../error-boundary";
|
||||
import { Dock } from "../dock";
|
||||
import { getHostedCluster } from "../../../common/cluster-store";
|
||||
import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor";
|
||||
|
||||
interface Props {
|
||||
className?: any;
|
||||
@@ -18,22 +19,42 @@ interface Props {
|
||||
|
||||
@observer
|
||||
export class MainLayout extends React.Component<Props> {
|
||||
public storage = createStorage("main_layout", { pinnedSidebar: true });
|
||||
public storage = createStorage("main_layout", {
|
||||
pinnedSidebar: true,
|
||||
sidebarWidth: 200,
|
||||
});
|
||||
|
||||
@observable isPinned = this.storage.get().pinnedSidebar;
|
||||
@observable isAccessible = true;
|
||||
@observable sidebarWidth = this.storage.get().sidebarWidth
|
||||
|
||||
@disposeOnUnmount syncPinnedStateWithStorage = reaction(
|
||||
() => this.isPinned,
|
||||
(isPinned) => this.storage.merge({ pinnedSidebar: isPinned })
|
||||
);
|
||||
|
||||
@disposeOnUnmount syncWidthStateWithStorage = reaction(
|
||||
() => this.sidebarWidth,
|
||||
(sidebarWidth) => this.storage.merge({ sidebarWidth })
|
||||
);
|
||||
|
||||
toggleSidebar = () => {
|
||||
this.isPinned = !this.isPinned;
|
||||
this.isAccessible = false;
|
||||
setTimeout(() => (this.isAccessible = true), 250);
|
||||
};
|
||||
|
||||
getSidebarSize = () => {
|
||||
return {
|
||||
"--sidebar-width": `${this.sidebarWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
@autobind()
|
||||
adjustWidth(newWidth: number): void {
|
||||
this.sidebarWidth = newWidth
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, headerClass, footer, footerClass, children } = this.props;
|
||||
const cluster = getHostedCluster();
|
||||
@@ -41,20 +62,31 @@ export class MainLayout extends React.Component<Props> {
|
||||
return null; // fix: skip render when removing active (visible) cluster
|
||||
}
|
||||
return (
|
||||
<div className={cssNames("MainLayout", className)}>
|
||||
<div className={cssNames("MainLayout", className)} style={this.getSidebarSize() as any}>
|
||||
<header className={cssNames("flex gaps align-center", headerClass)}>
|
||||
<span className="cluster">{cluster.preferences.clusterName || cluster.contextName}</span>
|
||||
</header>
|
||||
|
||||
<aside className={cssNames("flex column", { pinned: this.isPinned, accessible: this.isAccessible })}>
|
||||
<Sidebar className="box grow" isPinned={this.isPinned} toggle={this.toggleSidebar} />
|
||||
<ResizingAnchor
|
||||
direction={ResizeDirection.HORIZONTAL}
|
||||
placement={ResizeSide.TRAILING}
|
||||
growthDirection={ResizeGrowthDirection.LEFT_TO_RIGHT}
|
||||
getCurrentExtent={() => this.sidebarWidth}
|
||||
onDrag={this.adjustWidth}
|
||||
onDoubleClick={this.toggleSidebar}
|
||||
disabled={!this.isPinned}
|
||||
minExtent={120}
|
||||
maxExtent={400}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</main>
|
||||
|
||||
<footer className={footerClass}>{footer === undefined ? <Dock /> : footer}</footer>
|
||||
<footer className={footerClass}>{footer ?? <Dock />}</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './resizing-anchor'
|
||||
@@ -0,0 +1,46 @@
|
||||
body.resizing {
|
||||
user-select: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.ResizingAnchor {
|
||||
$dimension: 12px;
|
||||
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
|
||||
&.disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
left: 0;
|
||||
right: 0;
|
||||
cursor: row-resize;
|
||||
height: $dimension;
|
||||
|
||||
&.leading {
|
||||
top: -$dimension / 2;
|
||||
}
|
||||
|
||||
&.trailing {
|
||||
bottom: -$dimension / 2;
|
||||
}
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
cursor: col-resize;
|
||||
width: $dimension;
|
||||
|
||||
&.leading {
|
||||
left: -$dimension / 2;
|
||||
}
|
||||
|
||||
&.trailing {
|
||||
right: -$dimension / 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import "./resizing-anchor.scss";
|
||||
import React from "react";
|
||||
import { action, observable } from "mobx";
|
||||
import _ from "lodash"
|
||||
import { findDOMNode } from "react-dom";
|
||||
import { cssNames, noop } from "../../utils";
|
||||
|
||||
export enum ResizeDirection {
|
||||
HORIZONTAL = "horizontal",
|
||||
VERTICAL = "vertical",
|
||||
}
|
||||
|
||||
/**
|
||||
* ResizeSide is for customizing where the area should be rendered.
|
||||
* That location is determined in conjunction with the `ResizeDirection` using the following table:
|
||||
*
|
||||
* +----------+------------+----------+
|
||||
* | | HORIZONTAL | VERTICAL |
|
||||
* +----------+------------+----------+
|
||||
* | LEADING | left | top |
|
||||
* +----------+------------+----------+
|
||||
* | TRAILING | right | bottom |
|
||||
* +----------+------------+----------+
|
||||
*/
|
||||
export enum ResizeSide {
|
||||
LEADING = "leading",
|
||||
TRAILING = "trailing",
|
||||
}
|
||||
|
||||
/**
|
||||
* ResizeGrowthDirection determines how the anchor interprets the drag.
|
||||
*
|
||||
* Because the origin of the screen is top left a drag from bottom to top
|
||||
* results in a negative directional delta. However, if the component being
|
||||
* dragged grows in the opposite direction, this needs to be compensated for.
|
||||
*/
|
||||
export enum ResizeGrowthDirection {
|
||||
TOP_TO_BOTTOM = 1,
|
||||
BOTTOM_TO_TOP = -1,
|
||||
LEFT_TO_RIGHT = 1,
|
||||
RIGHT_TO_LEFT = -1,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
direction: ResizeDirection;
|
||||
|
||||
/**
|
||||
* getCurrentExtent should return the current prominent dimension in the
|
||||
* given resizing direction. Width for HORIZONTAL and height for VERTICAL
|
||||
*/
|
||||
getCurrentExtent: () => number;
|
||||
|
||||
disabled?: boolean;
|
||||
placement?: ResizeSide;
|
||||
growthDirection?: ResizeGrowthDirection;
|
||||
|
||||
// Ability to restrict which mouse buttons are allowed to resize this component
|
||||
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
|
||||
onlyButtons?: number;
|
||||
|
||||
// onStart is called when the ResizeAnchor is first clicked (mouse down)
|
||||
onStart?: () => void;
|
||||
|
||||
// onEnd is called when the ResizeAnchor is released (mouse up)
|
||||
onEnd?: () => void;
|
||||
|
||||
/**
|
||||
* onDrag is called whenever there is a mousemove event. All calls will be
|
||||
* bounded by matching `onStart` and `onEnd` calls.
|
||||
*/
|
||||
onDrag?: (newExtent: number) => void;
|
||||
|
||||
// onDoubleClick is called when the the ResizeAnchor is double clicked
|
||||
onDoubleClick?: () => void;
|
||||
|
||||
/**
|
||||
* The following two extents represent the max and min values set to `onDrag`
|
||||
*/
|
||||
maxExtent?: number;
|
||||
minExtent?: number;
|
||||
|
||||
/**
|
||||
* The following events are triggerred with respect to the above values.
|
||||
* - The "__Exceed" call will be made when the unbounded extent goes from
|
||||
* < the above to >= the above
|
||||
* - The "__Subceed" call is similar but is triggered when the unbounded
|
||||
* extent goes from >= the above to < the above.
|
||||
*/
|
||||
onMaxExtentExceed?: () => void;
|
||||
onMaxExtentSubceed?: () => void;
|
||||
onMinExtentSubceed?: () => void;
|
||||
onMinExtentExceed?: () => void;
|
||||
}
|
||||
|
||||
interface Position {
|
||||
readonly pageX: number;
|
||||
readonly pageY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the direction delta, but ignore drags leading up to a moved item
|
||||
* 1. `->|` => return `false`
|
||||
* 2. `<-|` => return `directed length (M, P2)` (negative)
|
||||
* 3. `-|>` => return `directed length (M, P2)` (positive)
|
||||
* 4. `<|-` => return `directed length (M, P2)` (negative)
|
||||
* 5. `|->` => return `directed length (M, P2)` (positive)
|
||||
* 6. `|<-` => return `false`
|
||||
* @param P1 the starting position on the number line
|
||||
* @param P2 the ending position on the number line
|
||||
* @param M a third point that determines if the delta is meaningful
|
||||
* @returns the directional difference between including appropriate sign.
|
||||
*/
|
||||
function directionDelta(P1: number, P2: number, M: number): number | false {
|
||||
const delta = Math.abs(M - P2)
|
||||
|
||||
if (P1 < M) {
|
||||
if (P2 >= M) {
|
||||
// case 3
|
||||
return delta
|
||||
}
|
||||
|
||||
if (P2 < P1) {
|
||||
// case 2
|
||||
return -delta
|
||||
}
|
||||
|
||||
// case 1
|
||||
return false
|
||||
}
|
||||
|
||||
if (P2 < M) {
|
||||
// case 4
|
||||
return -delta
|
||||
}
|
||||
|
||||
if (P1 < P2) {
|
||||
// case 5
|
||||
return delta
|
||||
}
|
||||
|
||||
// case 6
|
||||
return false
|
||||
}
|
||||
|
||||
export class ResizingAnchor extends React.PureComponent<Props> {
|
||||
@observable lastMouseEvent?: MouseEvent
|
||||
@observable.ref ref?: React.RefObject<HTMLDivElement>;
|
||||
|
||||
static defaultProps = {
|
||||
onStart: noop,
|
||||
onDrag: noop,
|
||||
onEnd: noop,
|
||||
onMaxExtentExceed: noop,
|
||||
onMinExtentExceed: noop,
|
||||
onMinExtentSubceed: noop,
|
||||
onMaxExtentSubceed: noop,
|
||||
onDoubleClick: noop,
|
||||
disabled: false,
|
||||
growthDirection: ResizeGrowthDirection.BOTTOM_TO_TOP,
|
||||
maxExtent: Number.POSITIVE_INFINITY,
|
||||
minExtent: 0,
|
||||
placement: ResizeSide.LEADING,
|
||||
}
|
||||
static IS_RESIZING = "resizing"
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
if (props.maxExtent < props.minExtent) {
|
||||
throw new Error("maxExtent must be >= minExtent")
|
||||
}
|
||||
|
||||
this.ref = React.createRef<HTMLDivElement>()
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("mousemove", this.onDrag)
|
||||
document.removeEventListener("mouseup", this.onDragEnd)
|
||||
}
|
||||
|
||||
@action
|
||||
onDragInit = (event: React.MouseEvent) => {
|
||||
const { onStart, onlyButtons } = this.props
|
||||
|
||||
if (typeof onlyButtons === "number" && onlyButtons !== event.buttons) {
|
||||
return
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", this.onDrag)
|
||||
document.addEventListener("mouseup", this.onDragEnd)
|
||||
document.body.classList.add(ResizingAnchor.IS_RESIZING)
|
||||
|
||||
this.lastMouseEvent = undefined
|
||||
onStart()
|
||||
}
|
||||
|
||||
calculateDelta(from: Position, to: Position): number | false {
|
||||
const node = this.ref.current
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
|
||||
const boundingBox = node.getBoundingClientRect()
|
||||
|
||||
if (this.props.direction === ResizeDirection.HORIZONTAL) {
|
||||
const barX = Math.round(boundingBox.x + (boundingBox.width / 2))
|
||||
return directionDelta(from.pageX, to.pageX, barX)
|
||||
} else { // direction === ResizeDirection.VERTICAL
|
||||
const barY = Math.round(boundingBox.y + (boundingBox.height / 2))
|
||||
return directionDelta(from.pageY, to.pageY, barY)
|
||||
}
|
||||
}
|
||||
|
||||
onDrag = _.throttle((event: MouseEvent) => {
|
||||
/**
|
||||
* Some notes to help understand the following:
|
||||
* - A browser's origin point is in the top left of the screen
|
||||
* - X increases going from left to right
|
||||
* - Y increases going from top to bottom
|
||||
* - Since the resize bar should always be a rectangle, use its centre
|
||||
* line (in the resizing direction) as the line for determining if
|
||||
* the bar has "jumped around"
|
||||
*
|
||||
* Desire:
|
||||
* - Always ignore movement in the non-resizing direction
|
||||
* - Figure out how much the user has "dragged" the resize bar
|
||||
* - If the resize bar has jumped around, compensate by ignoring movement
|
||||
* in the resizing direction if it is moving "towards" the resize bar's
|
||||
* new location.
|
||||
*/
|
||||
|
||||
if (!this.lastMouseEvent) {
|
||||
this.lastMouseEvent = event
|
||||
return
|
||||
}
|
||||
|
||||
const { maxExtent, minExtent, getCurrentExtent, growthDirection } = this.props
|
||||
const { onDrag, onMaxExtentExceed, onMinExtentSubceed, onMaxExtentSubceed, onMinExtentExceed } = this.props
|
||||
const delta = this.calculateDelta(this.lastMouseEvent, event)
|
||||
|
||||
// always update the last mouse event
|
||||
this.lastMouseEvent = event
|
||||
|
||||
if (delta === false) {
|
||||
return
|
||||
}
|
||||
|
||||
const previousExtent = getCurrentExtent()
|
||||
const unboundedExtent = previousExtent + (delta * growthDirection)
|
||||
const boundedExtent = Math.round(Math.max(minExtent, Math.min(maxExtent, unboundedExtent)))
|
||||
onDrag(boundedExtent)
|
||||
|
||||
if (previousExtent <= minExtent && minExtent <= unboundedExtent) {
|
||||
onMinExtentExceed()
|
||||
} else if (previousExtent >= minExtent && minExtent >= unboundedExtent) {
|
||||
onMinExtentSubceed()
|
||||
}
|
||||
if (previousExtent <= maxExtent && maxExtent <= unboundedExtent) {
|
||||
onMaxExtentExceed()
|
||||
} else if (previousExtent >= maxExtent && maxExtent >= unboundedExtent) {
|
||||
onMaxExtentSubceed()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
@action
|
||||
onDragEnd = (_event: MouseEvent) => {
|
||||
this.props.onEnd()
|
||||
document.removeEventListener("mousemove", this.onDrag)
|
||||
document.removeEventListener("mouseup", this.onDragEnd)
|
||||
document.body.classList.remove(ResizingAnchor.IS_RESIZING)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { disabled, direction, placement, onDoubleClick } = this.props
|
||||
return <div
|
||||
ref={this.ref}
|
||||
className={cssNames("ResizingAnchor", direction, placement, { disabled })}
|
||||
onMouseDown={this.onDragInit}
|
||||
onDoubleClick={onDoubleClick}
|
||||
/>
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export function withTooltip<T extends React.ComponentType<any>>(Target: T): T {
|
||||
const tooltipProps: TooltipProps = {
|
||||
targetId: tooltipId,
|
||||
tooltipOnParentHover: tooltipOverrideDisabled,
|
||||
formatters: { narrow: true },
|
||||
...(isReactNode(tooltip) ? { children: tooltip } : tooltip),
|
||||
};
|
||||
targetProps.id = tooltipId;
|
||||
|
||||
@@ -18,7 +18,11 @@ if (ipcRenderer) {
|
||||
}
|
||||
|
||||
export function navigate(location: LocationDescriptor) {
|
||||
const currentLocation = navigation.getPath();
|
||||
navigation.push(location);
|
||||
if (currentLocation === navigation.getPath()) {
|
||||
navigation.goBack(); // prevent sequences of same url in history
|
||||
}
|
||||
}
|
||||
|
||||
export interface IURLParams<P = {}, Q = {}> {
|
||||
|
||||
@@ -58,8 +58,8 @@
|
||||
"colorVague": "#36393e",
|
||||
"colorTerminated": "#4c5053",
|
||||
"dockHeadBackground": "#2e3136",
|
||||
"dockInfoBackground": "#111111",
|
||||
"dockInfoBorderColor": "#4c5053",
|
||||
"dockInfoBackground": "#1e2125",
|
||||
"dockInfoBorderColor": "#303136",
|
||||
"terminalBackground": "#000000",
|
||||
"terminalForeground": "#ffffff",
|
||||
"terminalCursor": "#ffffff",
|
||||
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
import { formatDuration } from "./formatDuration";
|
||||
import { formatDuration } from "../formatDuration";
|
||||
|
||||
const second = 1000;
|
||||
const minute = 60 * second;
|
||||
@@ -9,34 +9,34 @@ const week = 7 * day;
|
||||
describe("human format durations", () => {
|
||||
test("long formatted durations less than 24 hours long shouldn't have a 'd' component", () => {
|
||||
const res = formatDuration(19 * 60 * 60 * 1000, false);
|
||||
|
||||
|
||||
expect(res).not.toContain("d");
|
||||
expect(res).toBe("19h");
|
||||
});
|
||||
|
||||
test("long formatted durations more than a week have correct day count", () => {
|
||||
const res = formatDuration(2 * week + 2 * day, false);
|
||||
|
||||
|
||||
expect(res).toBe("2w 2d");
|
||||
});
|
||||
|
||||
test("durations > 1/2 week shouldn't show 1w has passed", () => {
|
||||
const res = formatDuration(5 * 24 * 60 * 60 * 1000, false);
|
||||
|
||||
|
||||
expect(res).not.toContain("w");
|
||||
expect(res).toBe("5d");
|
||||
});
|
||||
|
||||
test("durations shouldn't include zero magnitude parts", () => {
|
||||
const res = formatDuration(6 * day + 2 * minute, false);
|
||||
|
||||
|
||||
expect(res).not.toContain("h");
|
||||
expect(res).toBe("6d 2m");
|
||||
});
|
||||
|
||||
test("seconds are ignored unless they are significant (< 1m)", () => {
|
||||
const insignificant = formatDuration(1 * hour + 2 * minute + 31 * second, false);
|
||||
|
||||
|
||||
expect(insignificant).not.toContain("s");
|
||||
expect(insignificant).toBe("1h 2m");
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { metricUnitsToNumber } from "./metricUnitsToNumber";
|
||||
import { metricUnitsToNumber } from "../metricUnitsToNumber";
|
||||
|
||||
describe("metricUnitsToNumber tests", () => {
|
||||
test("plain number", () => {
|
||||
Reference in new issue
Block a user