1
0
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/+add-cluster/add-cluster.tsx
#	src/renderer/components/+cluster-settings/cluster-settings.tsx
This commit is contained in:
Roman 2020-10-12 12:14:40 +03:00
commit be6f0180e1
32 changed files with 1222 additions and 684 deletions

View File

@ -1,6 +1,6 @@
/*
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
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.
*/
@ -8,8 +8,8 @@ import { Application } from "spectron"
import * as util from "../helpers/utils"
import { spawnSync } from "child_process"
const describeif = (condition : boolean) => condition ? describe : describe.skip
const itif = (condition : boolean) => condition ? it : it.skip
const describeif = (condition: boolean) => condition ? describe : describe.skip
const itif = (condition: boolean) => condition ? it : it.skip
jest.setTimeout(60000)
@ -29,7 +29,7 @@ describe("Lens integration tests", () => {
}
const clickWhatsNew = async (app: Application) => {
await app.client.waitUntilTextExists("h1", "What's new")
await app.client.waitUntilTextExists("h1", "What's new?")
await app.client.click("button.primary")
await app.client.waitUntilTextExists("h1", "Welcome")
}
@ -140,7 +140,7 @@ describe("Lens integration tests", () => {
await addCluster()
}
}
describe("cluster pages", () => {
beforeAll(appStartAddCluster, 40000)
@ -150,8 +150,8 @@ describe("Lens integration tests", () => {
return util.tearDown(app)
}
})
const tests : {
const tests: {
drawer?: string
drawerId?: string
pages: {
@ -160,232 +160,230 @@ describe("Lens integration tests", () => {
expectedSelector: string,
expectedText: string
}[]
}[] = [
{
drawer: "",
drawerId: "",
pages: [ {
name: "Cluster",
href: "cluster",
expectedSelector: "div.ClusterNoMetrics p",
expectedText: "Metrics are not available due"
}]
}[] = [{
drawer: "",
drawerId: "",
pages: [{
name: "Cluster",
href: "cluster",
expectedSelector: "div.ClusterNoMetrics p",
expectedText: "Metrics are not available due"
}]
},
{
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"
},
{
drawer: "",
drawerId: "",
pages: [ {
name: "Nodes",
href: "nodes",
expectedSelector: "h5.title",
expectedText: "Nodes"
}]
name: "Pods",
href: "pods",
expectedSelector: "h5.title",
expectedText: "Pods"
},
{
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: "Jobs",
href: "jobs",
expectedSelector: "h5.title",
expectedText: "Jobs"
},
{
name: "CronJobs",
href: "cronjobs",
expectedSelector: "h5.title",
expectedText: "Cron Jobs"
} ]
name: "Deployments",
href: "deployments",
expectedSelector: "h5.title",
expectedText: "Deployments"
},
{
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: "HPA",
href: "hpa",
expectedSelector: "h5.title",
expectedText: "Horizontal Pod Autoscalers"
},
{
name: "Pod Disruption Budgets",
href: "poddisruptionbudgets",
expectedSelector: "h5.title",
expectedText: "Pod Disruption Budgets"
} ]
name: "DaemonSets",
href: "daemonsets",
expectedSelector: "h5.title",
expectedText: "Daemon Sets"
},
{
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"
} ]
name: "StatefulSets",
href: "statefulsets",
expectedSelector: "h5.title",
expectedText: "Stateful Sets"
},
{
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"
} ]
name: "Jobs",
href: "jobs",
expectedSelector: "h5.title",
expectedText: "Jobs"
},
{
drawer: "",
drawerId: "",
pages: [ {
name: "Namespaces",
href: "namespaces",
expectedSelector: "h5.title",
expectedText: "Namespaces"
}]
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"
},
{
drawer: "",
drawerId: "",
pages: [ {
name: "Events",
href: "events",
expectedSelector: "h5.title",
expectedText: "Events"
}]
name: "Secrets",
href: "secrets",
expectedSelector: "h5.title",
expectedText: "Secrets"
},
{
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"
} ]
name: "Resource Quotas",
href: "resourcequotas",
expectedSelector: "h5.title",
expectedText: "Resource Quotas"
},
{
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"
} ]
name: "HPA",
href: "hpa",
expectedSelector: "h5.title",
expectedText: "Horizontal Pod Autoscalers"
},
{
drawer: "Custom Resources",
drawerId: "custom-resources",
pages: [ {
name: "Definitions",
href: "crd/definitions",
expectedSelector: "h5.title",
expectedText: "Custom Resources"
} ]
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 () => {
@ -393,8 +391,8 @@ describe("Lens integration tests", () => {
await app.client.click(`.sidebar-nav #${drawerId} span.link-text`)
await app.client.waitUntilTextExists(`a[href="/${pages[0].href}"]`, pages[0].name)
})
}
pages.forEach(({name, href, expectedSelector, expectedText}) => {
}
pages.forEach(({ name, href, expectedSelector, expectedText }) => {
it(`shows ${drawer}->${name} page`, async () => {
expect(clusterAdded).toBe(true)
await app.client.click(`a[href="/${href}"]`)
@ -409,7 +407,7 @@ describe("Lens integration tests", () => {
await expect(app.client.waitUntilTextExists(`a[href="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow()
})
}
})
})
})
describe("cluster operations", () => {
@ -420,7 +418,7 @@ describe("Lens integration tests", () => {
return util.tearDown(app)
}
})
it('shows default namespace', async () => {
expect(clusterAdded).toBe(true)
await app.client.click('a[href="/namespaces"]')
@ -468,6 +466,6 @@ describe("Lens integration tests", () => {
await app.client.click(".name=nginx-create-pod-test")
await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test")
})
})
})
})
})
})

View File

@ -12,7 +12,7 @@ export function setup(): Application {
args: [],
path: AppPaths[process.platform],
startTimeout: 30000,
waitTimeout: 30000,
waitTimeout: 60000,
chromeDriverArgs: ['remote-debugging-port=9222'],
env: {
CICD: "true"

View File

@ -181,6 +181,7 @@
"@types/tar": "^4.0.3",
"array-move": "^3.0.0",
"chalk": "^4.1.0",
"command-exists": "1.2.9",
"conf": "^7.0.1",
"crypto-js": "^4.0.0",
"electron-updater": "^4.3.1",
@ -298,6 +299,7 @@
"identity-obj-proxy": "^3.0.0",
"include-media": "^1.4.9",
"jest": "^26.0.1",
"jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.1",
"material-design-icons": "^3.0.1",
"mini-css-extract-plugin": "^0.9.0",

View File

@ -5,11 +5,10 @@ import { tracker } from "./tracker";
export const clusterIpc = {
activate: createIpcChannel({
channel: "cluster:activate",
handle: (clusterId: ClusterId, frameId?: number) => {
handle: (clusterId: ClusterId, force = false) => {
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.activate();
return cluster.activate(force);
}
},
}),

View File

@ -0,0 +1,12 @@
export class ExecValidationNotFoundError extends Error {
constructor(execPath: string, isAbsolute: boolean) {
super(`User Exec command "${execPath}" not found on host.`);
let message = `User Exec command "${execPath}" not found on host.`;
if (!isAbsolute) {
message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`;
}
this.message = message;
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}

View File

@ -4,6 +4,8 @@ import path from "path"
import os from "os"
import yaml from "js-yaml"
import logger from "../main/logger";
import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors";
export const kubeConfigDefaultPath = path.join(os.homedir(), '.kube', 'config');
@ -140,3 +142,28 @@ export function getNodeWarningConditions(node: V1Node) {
c.status.toLowerCase() === "true" && c.type !== "Ready" && c.type !== "HostUpgrades"
)
}
/**
* Validates kubeconfig supplied in the add clusters screen. At present this will just validate
* the User struct, specifically the command passed to the exec substructure.
*/
export function validateKubeConfig (config: KubeConfig) {
// we only receive a single context, cluster & user object here so lets validate them as this
// will be called when we add a new cluster to Lens
logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`);
// Validate the User Object
const user = config.getCurrentUser();
if (user.exec) {
const execCommand = user.exec["command"];
// check if the command is absolute or not
const isAbsolute = path.isAbsolute(execCommand);
// validate the exec struct in the user object, start with the command field
logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`);
if (!commandExists.sync(execCommand)) {
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`);
throw new ExecValidationNotFoundError(execCommand, isAbsolute);
}
}
}

View File

@ -0,0 +1,165 @@
const logger = {
silly: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
info: jest.fn(),
error: jest.fn(),
crit: jest.fn(),
};
jest.mock("winston", () => ({
format: {
colorize: jest.fn(),
combine: jest.fn(),
simple: jest.fn(),
label: jest.fn(),
timestamp: jest.fn(),
printf: jest.fn()
},
createLogger: jest.fn().mockReturnValue(logger),
transports: {
Console: jest.fn(),
File: jest.fn(),
}
}))
jest.mock("../../common/ipc")
jest.mock("../context-handler")
jest.mock("request")
jest.mock("request-promise-native")
import { Console } from "console";
import mockFs from "mock-fs";
import { workspaceStore } from "../../common/workspace-store";
import { Cluster } from "../cluster"
import { ContextHandler } from "../context-handler";
import { getFreePort } from "../port";
import { V1ResourceAttributes } from "@kubernetes/client-node";
import { apiResources } from "../../common/rbac";
import request from "request-promise-native"
const mockedRequest = request as jest.MockedFunction<typeof request>
console = new Console(process.stdout, process.stderr) // fix mockFS
describe("create clusters", () => {
beforeEach(() => {
jest.clearAllMocks()
})
beforeEach(() => {
const mockOpts = {
"minikube-config.yml": JSON.stringify({
apiVersion: "v1",
clusters: [{
name: "minikube",
cluster: {
server: "https://192.168.64.3:8443",
},
}],
contexts: [{
context: {
cluster: "minikube",
user: "minikube",
},
name: "minikube",
}],
users: [{
name: "minikube",
}],
kind: "Config",
preferences: {},
})
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("should be able to create a cluster from a cluster model and apiURL should be decoded", () => {
const c = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
})
expect(c.apiUrl).toBe("https://192.168.64.3:8443")
})
it("init should not throw if everything is in order", async () => {
const c = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
})
await c.init(await getFreePort())
expect(logger.info).toBeCalledWith(expect.stringContaining("init success"), {
id: "foo",
apiUrl: "https://192.168.64.3:8443",
context: "minikube",
})
})
it("activating cluster should try to connect to cluster and do a refresh", async () => {
const port = await getFreePort()
jest.spyOn(ContextHandler.prototype, "ensureServer");
const mockListNSs = jest.fn()
const mockKC = {
makeApiClient() {
return {
listNamespace: mockListNSs,
}
}
}
jest.spyOn(Cluster.prototype, "canI")
.mockImplementationOnce((attr: V1ResourceAttributes): Promise<boolean> => {
expect(attr.namespace).toBe("default")
expect(attr.resource).toBe("pods")
expect(attr.verb).toBe("list")
return Promise.resolve(true)
})
.mockImplementation((attr: V1ResourceAttributes): Promise<boolean> => {
expect(attr.namespace).toBe("default")
expect(attr.verb).toBe("list")
return Promise.resolve(true)
})
jest.spyOn(Cluster.prototype, "getProxyKubeconfig").mockReturnValue(mockKC as any)
mockListNSs.mockImplementationOnce(() => ({
body: {
items: [{
metadata: {
name: "default",
}
}]
}
}))
mockedRequest.mockImplementationOnce(((uri: any, _options: any) => {
expect(uri).toBe(`http://localhost:${port}/api-kube/version`)
return Promise.resolve({ gitVersion: "1.2.3" })
}) as any)
const c = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
})
await c.init(port)
await c.activate()
expect(ContextHandler.prototype.ensureServer).toBeCalled()
expect(mockedRequest).toBeCalled()
expect(c.accessible).toBe(true)
expect(c.allowedNamespaces.length).toBe(1)
expect(c.allowedResources.length).toBe(apiResources.length)
jest.resetAllMocks()
})
})

View File

@ -0,0 +1,130 @@
const logger = {
silly: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
info: jest.fn(),
error: jest.fn(),
crit: jest.fn(),
};
jest.mock("winston", () => ({
format: {
colorize: jest.fn(),
combine: jest.fn(),
simple: jest.fn(),
label: jest.fn(),
timestamp: jest.fn(),
printf: jest.fn()
},
createLogger: jest.fn().mockReturnValue(logger),
transports: {
Console: jest.fn(),
File: jest.fn(),
}
}))
jest.mock("../../common/ipc")
jest.mock("child_process")
jest.mock("tcp-port-used")
import { Cluster } from "../cluster"
import { KubeAuthProxy } from "../kube-auth-proxy"
import { getFreePort } from "../port"
import { broadcastIpc } from "../../common/ipc"
import { ChildProcess, spawn, SpawnOptions } from "child_process"
import { Kubectl } from "../kubectl"
import { mock, MockProxy } from 'jest-mock-extended';
import { waitUntilUsed } from 'tcp-port-used';
import { Readable } from "stream"
const mockBroadcastIpc = broadcastIpc as jest.MockedFunction<typeof broadcastIpc>
const mockSpawn = spawn as jest.MockedFunction<typeof spawn>
const mockWaitUntilUsed = waitUntilUsed as jest.MockedFunction<typeof waitUntilUsed>
describe("kube auth proxy tests", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("calling exit multiple times shouldn't throw", async () => {
const port = await getFreePort()
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
kap.exit()
kap.exit()
kap.exit()
})
describe("spawn tests", () => {
let port: number
let mockedCP: MockProxy<ChildProcess>
let listeners: Record<string, (...args: any[]) => void>
beforeEach(async () => {
port = await getFreePort()
mockedCP = mock<ChildProcess>()
listeners = {}
jest.spyOn(Kubectl.prototype, "checkBinary").mockReturnValueOnce(Promise.resolve(true))
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false))
mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => {
listeners[event] = listener
return mockedCP
})
mockedCP.stderr = mock<Readable>()
mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners[`stderr/${event}`] = listener
return mockedCP.stderr
})
mockedCP.stdout = mock<Readable>()
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners[`stdout/${event}`] = listener
return mockedCP.stdout
})
mockSpawn.mockImplementationOnce((command: string, args: readonly string[], options: SpawnOptions): ChildProcess => {
expect(command).toBe(Kubectl.bundledKubectlPath)
return mockedCP
})
mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve())
})
it("should call spawn and broadcast errors", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
listeners["error"]({ message: "foobarbat" })
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "foobarbat", error: true }] })
})
it("should call spawn and broadcast exit", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
listeners["exit"](0)
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "proxy exited with code: 0", error: false }] })
})
it("should call spawn and broadcast errors from stderr", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
listeners["stderr/data"]("an error")
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "an error", error: true }] })
})
it("should call spawn and broadcast stdout serving info", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
listeners["stdout/data"]("Starting to serve on")
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "Authentication proxy started\n" }] })
})
it("should call spawn and broadcast stdout other info", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
listeners["stdout/data"]("some info")
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "some info" }] })
})
})
})

View File

@ -0,0 +1,112 @@
const logger = {
silly: jest.fn(),
debug: jest.fn(),
log: jest.fn(),
info: jest.fn(),
error: jest.fn(),
crit: jest.fn(),
};
jest.mock("winston", () => ({
format: {
colorize: jest.fn(),
combine: jest.fn(),
simple: jest.fn(),
label: jest.fn(),
timestamp: jest.fn(),
printf: jest.fn()
},
createLogger: jest.fn().mockReturnValue(logger),
transports: {
Console: jest.fn(),
File: jest.fn(),
}
}))
import { KubeconfigManager } from "../kubeconfig-manager"
import mockFs from "mock-fs"
import { Cluster } from "../cluster";
import { workspaceStore } from "../../common/workspace-store";
import { ContextHandler } from "../context-handler";
import { getFreePort } from "../port";
import fse from "fs-extra"
import { loadYaml } from "@kubernetes/client-node";
import { Console } from "console";
console = new Console(process.stdout, process.stderr) // fix mockFS
describe("kubeconfig manager tests", () => {
beforeEach(() => {
jest.clearAllMocks()
})
beforeEach(() => {
const mockOpts = {
"minikube-config.yml": JSON.stringify({
apiVersion: "v1",
clusters: [{
name: "minikube",
cluster: {
server: "https://192.168.64.3:8443",
},
}],
contexts: [{
context: {
cluster: "minikube",
user: "minikube",
},
name: "minikube",
}],
users: [{
name: "minikube",
}],
kind: "Config",
preferences: {},
})
}
mockFs(mockOpts)
})
afterEach(() => {
mockFs.restore()
})
it("should create 'temp' kube config with proxy", async () => {
const cluster = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
})
const contextHandler = new ContextHandler(cluster)
const port = await getFreePort()
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port)
expect(logger.error).not.toBeCalled()
expect(kubeConfManager.getPath()).toBe("tmp/kubeconfig-foo")
const file = await fse.readFile(kubeConfManager.getPath())
const yml = loadYaml<any>(file.toString())
expect(yml["current-context"]).toBe("minikube")
expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`)
expect(yml["users"][0]["name"]).toBe("proxy")
})
it("should remove 'temp' kube config on unlink and remove reference from inside class", async () => {
const cluster = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
})
const contextHandler = new ContextHandler(cluster)
const port = await getFreePort()
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port)
const configPath = kubeConfManager.getPath()
expect(await fse.pathExists(configPath)).toBe(true)
await kubeConfManager.unlink()
expect(await fse.pathExists(configPath)).toBe(false)
await kubeConfManager.unlink() // doesn't throw
expect(kubeConfManager.getPath()).toBeUndefined()
})
})

View File

@ -46,6 +46,7 @@ export class Cluster implements ClusterModel {
public contextHandler: ContextHandler;
protected kubeconfigManager: KubeconfigManager;
protected eventDisposers: Function[] = [];
protected activated = false;
whenInitialized = when(() => this.initialized);
whenReady = when(() => this.ready);
@ -93,7 +94,7 @@ export class Cluster implements ClusterModel {
async init(port: number) {
try {
this.contextHandler = new ContextHandler(this);
this.kubeconfigManager = new KubeconfigManager(this, this.contextHandler, port);
this.kubeconfigManager = await KubeconfigManager.create(this, this.contextHandler, port);
this.kubeProxyUrl = `http://localhost:${port}${apiKubePrefix}`;
this.initialized = true;
logger.info(`[CLUSTER]: "${this.contextName}" init success`, {
@ -126,7 +127,10 @@ export class Cluster implements ClusterModel {
}
@action
async activate() {
async activate(force = false ) {
if (this.activated && !force) {
return this.pushState();
}
logger.info(`[CLUSTER]: activate`, this.getMeta());
await this.whenInitialized;
if (!this.eventDisposers.length) {
@ -142,6 +146,7 @@ export class Cluster implements ClusterModel {
this.kubeCtl = new Kubectl(this.version)
this.kubeCtl.ensureKubectl() // download kubectl in background, so it's not blocking dashboard
}
this.activated = true
return this.pushState();
}
@ -162,6 +167,7 @@ export class Cluster implements ClusterModel {
this.online = false;
this.accessible = false;
this.ready = false;
this.activated = false;
this.pushState();
}
@ -184,9 +190,7 @@ export class Cluster implements ClusterModel {
this.refreshEvents(),
this.refreshAllowedResources(),
]);
if (!this.ready) {
this.ready = true
}
this.ready = true
}
this.pushState();
}

View File

@ -11,8 +11,12 @@ export class KubeconfigManager {
protected configDir = app.getPath("temp")
protected tempFile: string;
constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) {
this.init();
private constructor(protected cluster: Cluster, protected contextHandler: ContextHandler, protected port: number) { }
static async create(cluster: Cluster, contextHandler: ContextHandler, port: number) {
const kcm = new KubeconfigManager(cluster, contextHandler, port)
await kcm.init()
return kcm
}
protected async init() {
@ -72,8 +76,13 @@ export class KubeconfigManager {
return tempFile;
}
unlink() {
async unlink() {
if (!this.tempFile) {
return
}
logger.info('Deleting temporary kubeconfig: ' + this.tempFile)
fs.unlinkSync(this.tempFile)
await fs.unlink(this.tempFile)
this.tempFile = undefined
}
}

View File

@ -47,6 +47,7 @@ export interface IPodLogsQuery {
tailLines?: number;
timestamps?: boolean;
sinceTime?: string; // Date.toISOString()-format
follow?: boolean;
}
export enum PodStatus {

View File

@ -13,7 +13,7 @@ import { AceEditor } from "../ace-editor";
import { Button } from "../button";
import { Icon } from "../icon";
import { WizardLayout } from "../layout/wizard-layout";
import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig } from "../../../common/kube-helpers";
import { kubeConfigDefaultPath, loadConfig, splitConfig, validateConfig, validateKubeConfig } from "../../../common/kube-helpers";
import { ClusterModel, ClusterStore, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid"
@ -23,6 +23,7 @@ import { clusterViewURL } from "../cluster-manager/cluster-view.route";
import { cssNames } from "../../utils";
import { Notifications } from "../notifications";
import { Tab, Tabs } from "../tabs";
import { ExecValidationNotFoundError } from "../../../common/custom-errors";
enum KubeConfigSourceTab {
FILE = "file",
@ -120,6 +121,9 @@ export class AddCluster extends React.Component {
@action
addClusters = () => {
const configValidationErrors:string[] = [];
let newClusters: ClusterModel[] = [];
try {
if (!this.selectedContexts.length) {
this.error = <Trans>Please select at least one cluster context</Trans>
@ -128,7 +132,21 @@ export class AddCluster extends React.Component {
this.error = ""
this.isWaiting = true
const newClusters: ClusterModel[] = this.selectedContexts.map(context => {
newClusters = this.selectedContexts.filter(context => {
try {
const kubeConfig = this.kubeContexts.get(context);
validateKubeConfig(kubeConfig);
return true;
} catch (err) {
this.error = String(err.message)
if (err instanceof ExecValidationNotFoundError ) {
Notifications.error(<Trans>Error while adding cluster(s): {this.error}</Trans>);
return false;
} else {
throw new Error(err);
}
}
}).map(context => {
const clusterId = uuid();
const kubeConfig = this.kubeContexts.get(context);
const kubeConfigPath = this.sourceTab === KubeConfigSourceTab.FILE
@ -144,7 +162,8 @@ export class AddCluster extends React.Component {
httpsProxy: this.proxyServer || undefined,
},
}
});
})
clusterStore.addCluster(...newClusters);
@ -152,9 +171,9 @@ export class AddCluster extends React.Component {
const clusterId = newClusters[0].id;
navigate(clusterViewURL({ params: { clusterId } }));
} else {
Notifications.ok(
<Trans>Successfully imported <b>{newClusters.length}</b> cluster(s)</Trans>
);
if (newClusters.length > 1) {Notifications.ok(
<Trans>Successfully imported <b>{newClusters.length}</b> cluster(s)</Trans>);
}
}
} catch (err) {
this.error = String(err);

View File

@ -53,9 +53,11 @@ export class ClusterSettings extends React.Component<Props> {
}
}
refreshCluster = (cluster: Cluster) => {
if (!cluster) return;
clusterIpc.refresh.invokeFromRenderer(cluster.id);
refreshCluster = async () => {
if(this.cluster) {
await clusterIpc.activate.invokeFromRenderer(this.cluster.id);
clusterIpc.refresh.invokeFromRenderer(this.cluster.id);
}
}
close() {

View File

@ -1,110 +0,0 @@
.PodLogsDialog {
--log-line-height: 16px;
.Wizard {
width: 90vw;
max-height: none;
.WizardStep {
& > .step-content.scrollable {
max-height: none;
}
& > :last-child {
padding: $padding * 2;
}
}
}
.log-controls {
padding-bottom: $padding * 2;
.time-range {
flex-grow: 2;
text-align: center;
}
.controls {
width: 100%;
}
.control-buttons {
margin-right: 0;
white-space: nowrap;
.Icon {
border-radius: $radius;
padding: 3px;
&:hover {
color: $textColorPrimary;
background: #f4f4f4;
}
&.active {
color: $primary;
background: #f4f4f4;
}
}
}
@include media("<=desktop") {
flex-direction: column;
align-items: start;
.container {
width: 100%;
}
.controls {
margin-top: $margin * 2;
.time-range {
text-align: left;
}
}
}
}
.logs-area {
position: relative;
@include custom-scrollbar;
// fix for `this.logsArea.scrollTop = this.logsArea.scrollHeight`
// `overflow: overlay` don't allow scroll to the last line
overflow: auto;
color: #C5C8C6;
background: #1D1F21;
line-height: var(--log-line-height);
border-radius: 2px;
height: 45vh;
padding: $padding / 4 $padding;
font-family: $font-monospace;
font-size: smaller;
white-space: pre;
.no-logs {
text-align: center;
}
}
.new-logs-sep {
position: relative;
display: block;
height: 0;
border-top: 1px solid $primary;
margin: $margin * 2;
&:after {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
content: 'new';
background: $primary;
color: white;
padding: $padding / 3 $padding /2;
border-radius: $radius;
}
}
}

View File

@ -1,307 +0,0 @@
import "./pod-logs-dialog.scss";
import React from "react";
import { observable } from "mobx";
import { observer } from "mobx-react";
import { t, Trans } from "@lingui/macro";
import { _i18n } from "../../i18n";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { IPodContainer, Pod, podsApi } from "../../api/endpoints";
import { Icon } from "../icon";
import { Select, SelectOption } from "../select";
import { Spinner } from "../spinner";
import { cssNames, downloadFile, interval } from "../../utils";
import AnsiUp from "ansi_up";
import DOMPurify from "dompurify"
interface IPodLogsDialogData {
pod: Pod;
container?: IPodContainer;
}
interface Props extends Partial<DialogProps> {
}
@observer
export class PodLogsDialog extends React.Component<Props> {
@observable static isOpen = false;
@observable static data: IPodLogsDialogData = null;
static open(pod: Pod, container?: IPodContainer) {
PodLogsDialog.isOpen = true;
PodLogsDialog.data = { pod, container };
}
static close() {
PodLogsDialog.isOpen = false;
}
get data() {
return PodLogsDialog.data;
}
private logsArea: HTMLDivElement;
private refresher = interval(5, () => this.load());
private containers: IPodContainer[] = []
private initContainers: IPodContainer[] = []
private lastLineIsShown = true; // used for proper auto-scroll content after refresh
private colorConverter = new AnsiUp();
@observable logs = ""; // latest downloaded logs for pod
@observable newLogs = ""; // new logs since dialog is open
@observable logsReady = false;
@observable selectedContainer: IPodContainer;
@observable showTimestamps = true;
@observable tailLines = 1000;
lineOptions = [
{ label: _i18n._(t`All logs`), value: Number.MAX_SAFE_INTEGER },
{ label: 1000, value: 1000 },
{ label: 10000, value: 10000 },
{ label: 100000, value: 100000 },
]
onOpen = async () => {
const { pod, container } = this.data;
this.containers = pod.getContainers();
this.initContainers = pod.getInitContainers();
this.selectedContainer = container || this.containers[0];
await this.load();
this.refresher.start();
}
onClose = () => {
this.resetLogs();
this.refresher.stop();
}
close = () => {
PodLogsDialog.close();
}
load = async () => {
if (!this.data) return;
const { pod } = this.data;
try {
// if logs already loaded, check the latest timestamp for getting updates only from this point
const logsTimestamps = this.getTimestamps(this.newLogs || this.logs);
let lastLogDate = new Date(0)
if (logsTimestamps) {
lastLogDate = new Date(logsTimestamps.slice(-1)[0]);
lastLogDate.setSeconds(lastLogDate.getSeconds() + 1); // avoid duplicates from last second
}
const namespace = pod.getNs();
const name = pod.getName();
const logs = await podsApi.getLogs({ namespace, name }, {
container: this.selectedContainer.name,
timestamps: true,
tailLines: this.tailLines ? this.tailLines : undefined,
sinceTime: lastLogDate.toISOString(),
});
if (!this.logs) {
this.logs = logs;
}
else if (logs) {
this.newLogs = `${this.newLogs}\n${logs}`.trim();
}
} catch (error) {
this.logs = [
_i18n._(t`Failed to load logs: ${error.message}`),
_i18n._(t`Reason: ${error.reason} (${error.code})`),
].join("\n")
}
this.logsReady = true;
}
reload = async () => {
this.resetLogs();
this.refresher.stop();
await this.load();
this.refresher.start();
}
componentDidUpdate() {
// scroll logs only when it's already in the end,
// otherwise it can interrupt reading by jumping after loading new logs update
if (this.logsArea && this.lastLineIsShown) {
this.logsArea.scrollTop = this.logsArea.scrollHeight;
}
}
onScroll = (evt: React.UIEvent<HTMLDivElement>) => {
const logsArea = evt.currentTarget;
const { scrollHeight, clientHeight, scrollTop } = logsArea;
this.lastLineIsShown = clientHeight + scrollTop === scrollHeight;
};
getLogs() {
const { logs, newLogs, showTimestamps } = this;
return {
logs: showTimestamps ? logs : this.removeTimestamps(logs),
newLogs: showTimestamps ? newLogs : this.removeTimestamps(newLogs),
}
}
getTimestamps(logs: string) {
return logs.match(/^\d+\S+/gm);
}
removeTimestamps(logs: string) {
return logs.replace(/^\d+.*?\s/gm, "");
}
resetLogs() {
this.logs = "";
this.newLogs = "";
this.lastLineIsShown = true;
this.logsReady = false;
}
onContainerChange = (option: SelectOption) => {
this.selectedContainer = this.containers
.concat(this.initContainers)
.find(container => container.name === option.value);
this.reload();
}
onTailLineChange = (option: SelectOption) => {
this.tailLines = option.value;
this.reload();
}
formatOptionLabel = (option: SelectOption) => {
const { value, label } = option;
return label || <><Icon small material="view_carousel"/> {value}</>;
}
toggleTimestamps = () => {
this.showTimestamps = !this.showTimestamps;
}
downloadLogs = () => {
const { logs, newLogs } = this.getLogs();
const fileName = this.selectedContainer.name + ".log";
const fileContents = logs + newLogs;
downloadFile(fileName, fileContents, "text/plain");
}
get containerSelectOptions() {
return [
{
label: _i18n._(t`Containers`),
options: this.containers.map(container => {
return { value: container.name }
}),
},
{
label: _i18n._(t`Init Containers`),
options: this.initContainers.map(container => {
return { value: container.name }
}),
}
];
}
renderControlsPanel() {
const { logsReady, showTimestamps } = this;
if (!logsReady) return;
const timestamps = this.getTimestamps(this.logs + this.newLogs);
let from = "";
let to = "";
if (timestamps) {
from = new Date(timestamps[0]).toLocaleString();
to = new Date(timestamps[timestamps.length - 1]).toLocaleString();
}
return (
<div className="controls flex align-center">
<div className="time-range">
{timestamps && <Trans>From <b>{from}</b> to <b>{to}</b></Trans>}
</div>
<div className="control-buttons flex gaps">
<Icon
material="av_timer"
onClick={this.toggleTimestamps}
className={cssNames("timestamps-icon", { active: showTimestamps })}
tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)}
/>
<Icon
material="get_app"
onClick={this.downloadLogs}
tooltip={_i18n._(t`Save`)}
/>
</div>
</div>
)
}
renderLogs() {
if (!this.logsReady) {
return <Spinner center/>
}
const { logs, newLogs } = this.getLogs();
if (!logs && !newLogs) {
return <p className="no-logs"><Trans>There are no logs available for container.</Trans></p>
}
return (
<>
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(logs))}} />
{newLogs && (
<>
<p className="new-logs-sep" title={_i18n._(t`New logs since opening the dialog`)}/>
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(newLogs))}} />
</>
)}
</>
);
}
render() {
const { ...dialogProps } = this.props;
const { selectedContainer, tailLines } = this;
const podName = this.data ? this.data.pod.getName() : "";
const header = <h5><Trans>{podName} Logs</Trans></h5>;
return (
<Dialog
{...dialogProps}
isOpen={PodLogsDialog.isOpen}
className="PodLogsDialog"
onOpen={this.onOpen}
onClose={this.onClose}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep hideNextBtn prevLabel={<Trans>Close</Trans>}>
<div className="log-controls flex gaps align-center justify-space-between">
<div className="container flex gaps align-center">
<span><Trans>Container</Trans></span>
{selectedContainer && (
<Select
className="container-selector"
options={this.containerSelectOptions}
themeName="light"
value={{ value: selectedContainer.name }}
onChange={this.onContainerChange}
formatOptionLabel={this.formatOptionLabel}
autoConvertOptions={false}
/>
)}
<span><Trans>Lines</Trans></span>
<Select
value={tailLines}
options={this.lineOptions}
onChange={this.onTailLineChange}
themeName="light"
/>
</div>
{this.renderControlsPanel()}
</div>
<div className="logs-area" onScroll={this.onScroll} ref={e => this.logsArea = e}>
{this.renderLogs()}
</div>
</WizardStep>
</Wizard>
</Dialog>
)
}
}

View File

@ -3,15 +3,15 @@ import "./pod-menu.scss";
import React from "react";
import { t, Trans } from "@lingui/macro";
import { MenuItem, SubMenu } from "../menu";
import { IPodContainer, Pod, nodesApi } from "../../api/endpoints";
import { IPodContainer, Pod } from "../../api/endpoints";
import { Icon } from "../icon";
import { StatusBrick } from "../status-brick";
import { PodLogsDialog } from "./pod-logs-dialog";
import { KubeObjectMenu, KubeObjectMenuProps } from "../kube-object/kube-object-menu";
import { cssNames, prevDefault } from "../../utils";
import { terminalStore, createTerminalTab } from "../dock/terminal.store";
import { _i18n } from "../../i18n";
import { hideDetails } from "../../navigation";
import { createPodLogsTab } from "../dock/pod-logs.store";
interface Props extends KubeObjectMenuProps<Pod> {
}
@ -42,7 +42,16 @@ export class PodMenu extends React.Component<Props> {
}
showLogs(container: IPodContainer) {
PodLogsDialog.open(this.props.object, container);
hideDetails();
const pod = this.props.object;
createPodLogsTab({
pod,
containers: pod.getContainers(),
initContainers: pod.getInitContainers(),
selectedContainer: container,
showTimestamps: false,
tailLines: 1000
});
}
renderShellMenu() {

View File

@ -23,7 +23,6 @@ import { eventRoute } from "./+events";
import { Apps, appsRoute } from "./+apps";
import { KubeObjectDetails } from "./kube-object/kube-object-details";
import { AddRoleBindingDialog } from "./+user-management-roles-bindings";
import { PodLogsDialog } from "./+workloads-pods/pod-logs-dialog";
import { DeploymentScaleDialog } from "./+workloads-deployments/deployment-scale-dialog";
import { CronJobTriggerDialog } from "./+workloads-cronjobs/cronjob-trigger-dialog";
import { CustomResources } from "./+custom-resources/custom-resources";
@ -82,7 +81,6 @@ export class App extends React.Component {
<KubeObjectDetails/>
<KubeConfigDialog/>
<AddRoleBindingDialog/>
<PodLogsDialog/>
<DeploymentScaleDialog/>
<CronJobTriggerDialog/>
</ErrorBoundary>

View File

@ -47,13 +47,14 @@ export class ClusterStatus extends React.Component<Props> {
ipcRenderer.removeAllListeners(`kube-auth:${this.props.clusterId}`);
}
activateCluster = async () => {
await clusterIpc.activate.invokeFromRenderer(this.props.clusterId);
activateCluster = async (force = false) => {
await clusterIpc.activate.invokeFromRenderer(this.props.clusterId, force);
}
reconnect = async () => {
this.authOutput = []
this.isReconnecting = true;
await this.activateCluster();
await this.activateCluster(true);
this.isReconnecting = false;
}

View File

@ -11,6 +11,7 @@ export enum TabKind {
EDIT_RESOURCE = "edit-resource",
INSTALL_CHART = "install-chart",
UPGRADE_CHART = "upgrade-chart",
POD_LOGS = "pod-logs",
}
export interface IDockTab {

View File

@ -22,6 +22,8 @@ import { createResourceTab, isCreateResourceTab } from "./create-resource.store"
import { isEditResourceTab } from "./edit-resource.store";
import { isInstallChartTab } from "./install-chart.store";
import { isUpgradeChartTab } from "./upgrade-chart.store";
import { PodLogs } from "./pod-logs";
import { isPodLogsTab } from "./pod-logs.store";
interface Props {
className?: string;
@ -59,6 +61,9 @@ export class Dock extends React.Component<Props> {
if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) {
return <DockTab value={tab} icon={<Icon svg="install" />} />
}
if (isPodLogsTab(tab)) {
return <DockTab value={tab} icon="subject" />
}
}
renderTabContent() {
@ -71,6 +76,7 @@ export class Dock extends React.Component<Props> {
{isInstallChartTab(tab) && <InstallChart tab={tab} />}
{isUpgradeChartTab(tab) && <UpgradeChart tab={tab} />}
{isTerminalTab(tab) && <TerminalWindow tab={tab} />}
{isPodLogsTab(tab) && <PodLogs tab={tab} />}
</div>
)
}

View File

@ -13,7 +13,7 @@ import { Notifications } from "../notifications";
interface Props extends OptionalProps {
tabId: TabId;
submit: () => Promise<ReactNode | string>;
submit?: () => Promise<ReactNode | string>;
}
interface OptionalProps {
@ -23,6 +23,7 @@ interface OptionalProps {
submitLabel?: ReactNode;
submittingMessage?: ReactNode;
disableSubmit?: boolean;
showButtons?: boolean
showSubmitClose?: boolean;
showInlineInfo?: boolean;
showNotifications?: boolean;
@ -33,6 +34,7 @@ export class InfoPanel extends Component<Props> {
static defaultProps: OptionalProps = {
submitLabel: <Trans>Submit</Trans>,
submittingMessage: <Trans>Submitting..</Trans>,
showButtons: true,
showSubmitClose: true,
showInlineInfo: true,
showNotifications: true,
@ -87,7 +89,7 @@ export class InfoPanel extends Component<Props> {
}
render() {
const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showSubmitClose } = this.props;
const { className, controls, submitLabel, disableSubmit, error, submittingMessage, showButtons, showSubmitClose } = this.props;
const { submit, close, submitAndClose, waiting } = this;
const isDisabled = !!(disableSubmit || waiting || error);
return (
@ -98,22 +100,26 @@ export class InfoPanel extends Component<Props> {
<div className="info flex gaps align-center">
{waiting ? <><Spinner /> {submittingMessage}</> : this.renderErrorIcon()}
</div>
<Button plain label={<Trans>Cancel</Trans>} onClick={close} />
<Button
active
outlined={showSubmitClose}
primary={!showSubmitClose}// one button always should be primary (blue)
label={submitLabel}
onClick={submit}
disabled={isDisabled}
/>
{showSubmitClose && (
<Button
primary active
label={<Trans>{submitLabel} & Close</Trans>}
onClick={submitAndClose}
disabled={isDisabled}
/>
{showButtons && (
<>
<Button plain label={<Trans>Cancel</Trans>} onClick={close} />
<Button
active
outlined={showSubmitClose}
primary={!showSubmitClose}// one button always should be primary (blue)
label={submitLabel}
onClick={submit}
disabled={isDisabled}
/>
{showSubmitClose && (
<Button
primary active
label={<Trans>{submitLabel} & Close</Trans>}
onClick={submitAndClose}
disabled={isDisabled}
/>
)}
</>
)}
</div>
);

View File

@ -0,0 +1,43 @@
.PodLogs {
.logs {
@include custom-scrollbar;
// fix for `this.logsElement.scrollTop = this.logsElement.scrollHeight`
// `overflow: overlay` don't allow scroll to the last line
overflow: auto;
color: $textColorAccent;
background: $logsBackground;
line-height: var(--log-line-height);
border-radius: 2px;
padding: $padding * 2;
font-family: $font-monospace;
font-size: smaller;
white-space: pre;
flex-grow: 1;
> div {
// Provides font better readability on large screens
-webkit-font-smoothing: subpixel-antialiased;
}
}
.new-logs-sep {
position: relative;
display: block;
height: 0;
border-top: 1px solid $primary;
margin: $margin * 2;
&:after {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
content: 'new';
background: $primary;
color: white;
padding: $padding / 3;
border-radius: $radius;
}
}
}

View File

@ -0,0 +1,126 @@
import { autorun, observable } from "mobx";
import { Pod, IPodContainer, podsApi } from "../../api/endpoints";
import { autobind, interval } from "../../utils";
import { DockTabStore } from "./dock-tab.store";
import { dockStore, IDockTab, TabKind } from "./dock.store";
import { t } from "@lingui/macro";
import { _i18n } from "../../i18n";
export interface IPodLogsData {
pod: Pod;
selectedContainer: IPodContainer
containers: IPodContainer[]
initContainers: IPodContainer[]
showTimestamps: boolean
tailLines: number
}
type TabId = string;
interface PodLogs {
oldLogs?: string
newLogs?: string
}
@autobind()
export class PodLogsStore extends DockTabStore<IPodLogsData> {
private refresher = interval(10, () => this.load(dockStore.selectedTabId));
@observable logs = observable.map<TabId, PodLogs>();
constructor() {
super({
storageName: "pod_logs"
});
autorun(() => {
const { selectedTab, isOpen } = dockStore;
if (isPodLogsTab(selectedTab) && isOpen) {
this.refresher.start();
} else {
this.refresher.stop();
}
}, { delay: 500 });
}
load = async (tabId: TabId) => {
if (!this.logs.has(tabId)) {
this.logs.set(tabId, { oldLogs: "", newLogs: "" })
}
const data = this.getData(tabId);
const { oldLogs, newLogs } = this.logs.get(tabId);
const { selectedContainer, tailLines } = data;
const pod = new Pod(data.pod);
try {
// if logs already loaded, check the latest timestamp for getting updates only from this point
const logsTimestamps = this.getTimestamps(newLogs || oldLogs);
let lastLogDate = new Date(0);
if (logsTimestamps) {
lastLogDate = new Date(logsTimestamps.slice(-1)[0]);
lastLogDate.setSeconds(lastLogDate.getSeconds() + 1); // avoid duplicates from last second
}
const namespace = pod.getNs();
const name = pod.getName();
const loadedLogs = await podsApi.getLogs({ namespace, name }, {
sinceTime: lastLogDate.toISOString(),
timestamps: true, // Always setting timestampt to separate old logs from new ones
container: selectedContainer.name,
tailLines: tailLines,
});
if (!oldLogs) {
this.logs.set(tabId, { oldLogs: loadedLogs, newLogs });
} else {
this.logs.set(tabId, { oldLogs, newLogs: loadedLogs });
}
} catch (error) {
this.logs.set(tabId, {
oldLogs: [
_i18n._(t`Failed to load logs: ${error.message}`),
_i18n._(t`Reason: ${error.reason} (${error.code})`)
].join("\n"),
newLogs
});
}
}
getTimestamps(logs: string) {
return logs.match(/^\d+\S+/gm);
}
removeTimestamps(logs: string) {
return logs.replace(/^\d+.*?\s/gm, "");
}
clearLogs(tabId: TabId) {
this.logs.delete(tabId);
}
clearData(tabId: TabId) {
this.data.delete(tabId);
this.clearLogs(tabId);
}
}
export const podLogsStore = new PodLogsStore();
export function createPodLogsTab(data: IPodLogsData, tabParams: Partial<IDockTab> = {}) {
const podId = data.pod.getId();
let tab = dockStore.getTabById(podId);
if (tab) {
dockStore.open();
dockStore.selectTab(tab.id);
return;
}
// If no existent tab found
tab = dockStore.createTab({
id: podId,
kind: TabKind.POD_LOGS,
title: `Logs: ${data.pod.getName()}`,
...tabParams
}, false);
podLogsStore.setData(tab.id, data);
return tab;
}
export function isPodLogsTab(tab: IDockTab) {
return tab && tab.kind === TabKind.POD_LOGS;
}

View File

@ -0,0 +1,235 @@
import "./pod-logs.scss";
import React from "react";
import AnsiUp from "ansi_up";
import DOMPurify from "dompurify";
import { t, Trans } from "@lingui/macro";
import { computed, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { _i18n } from "../../i18n";
import { autobind, cssNames, downloadFile } from "../../utils";
import { Icon } from "../icon";
import { Select, SelectOption } from "../select";
import { Spinner } from "../spinner";
import { IDockTab } from "./dock.store";
import { InfoPanel } from "./info-panel";
import { IPodLogsData, podLogsStore } from "./pod-logs.store";
interface Props {
className?: string
tab: IDockTab
}
@observer
export class PodLogs extends React.Component<Props> {
@observable ready = false;
private logsElement: HTMLDivElement;
private lastLineIsShown = true; // used for proper auto-scroll content after refresh
private colorConverter = new AnsiUp();
private lineOptions = [
{ label: _i18n._(t`All logs`), value: Number.MAX_SAFE_INTEGER },
{ label: 1000, value: 1000 },
{ label: 10000, value: 10000 },
{ label: 100000, value: 100000 },
];
componentDidMount() {
disposeOnUnmount(this,
reaction(() => this.props.tab.id, async () => {
if (podLogsStore.logs.has(this.tabId)) {
this.ready = true;
return;
}
await this.load();
}, { fireImmediately: true })
);
}
componentDidUpdate() {
// scroll logs only when it's already in the end,
// otherwise it can interrupt reading by jumping after loading new logs update
if (this.logsElement && this.lastLineIsShown) {
this.logsElement.scrollTop = this.logsElement.scrollHeight;
}
}
get tabData() {
return podLogsStore.getData(this.tabId);
}
get tabId() {
return this.props.tab.id;
}
@autobind()
save(data: Partial<IPodLogsData>) {
podLogsStore.setData(this.tabId, { ...this.tabData, ...data });
}
load = async () => {
this.ready = false;
await podLogsStore.load(this.tabId);
this.ready = true;
}
reload = async () => {
podLogsStore.clearLogs(this.tabId);
this.lastLineIsShown = true;
await this.load();
}
@computed
get logs() {
if (!podLogsStore.logs.has(this.tabId)) return;
const { oldLogs, newLogs } = podLogsStore.logs.get(this.tabId);
const { getData, removeTimestamps } = podLogsStore;
const { showTimestamps } = getData(this.tabId);
return {
oldLogs: showTimestamps ? oldLogs : removeTimestamps(oldLogs),
newLogs: showTimestamps ? newLogs : removeTimestamps(newLogs)
}
}
toggleTimestamps = () => {
this.save({ showTimestamps: !this.tabData.showTimestamps });
}
onScroll = (evt: React.UIEvent<HTMLDivElement>) => {
const logsArea = evt.currentTarget;
const { scrollHeight, clientHeight, scrollTop } = logsArea;
this.lastLineIsShown = clientHeight + scrollTop === scrollHeight;
};
downloadLogs = () => {
const { oldLogs, newLogs } = this.logs;
const { pod, selectedContainer } = this.tabData;
const fileName = selectedContainer ? selectedContainer.name : pod.getName();
const fileContents = oldLogs + newLogs;
downloadFile(fileName + ".log", fileContents, "text/plain");
}
onContainerChange = (option: SelectOption) => {
const { containers, initContainers } = this.tabData;
this.save({
selectedContainer: containers
.concat(initContainers)
.find(container => container.name === option.value)
})
this.reload();
}
onTailLineChange = (option: SelectOption) => {
this.save({ tailLines: option.value })
this.reload();
}
get containerSelectOptions() {
const { containers, initContainers } = this.tabData;
return [
{
label: _i18n._(t`Containers`),
options: containers.map(container => {
return { value: container.name }
}),
},
{
label: _i18n._(t`Init Containers`),
options: initContainers.map(container => {
return { value: container.name }
}),
}
];
}
formatOptionLabel = (option: SelectOption) => {
const { value, label } = option;
return label || <><Icon small material="view_carousel"/> {value}</>;
}
renderControls() {
if (!this.ready) return null;
const { selectedContainer, showTimestamps, tailLines } = this.tabData;
const timestamps = podLogsStore.getTimestamps(podLogsStore.logs.get(this.tabId).oldLogs);
return (
<div className="controls flex gaps align-center">
<span><Trans>Container</Trans></span>
<Select
options={this.containerSelectOptions}
value={{ value: selectedContainer.name }}
formatOptionLabel={this.formatOptionLabel}
onChange={this.onContainerChange}
autoConvertOptions={false}
/>
<span><Trans>Lines</Trans></span>
<Select
value={tailLines}
options={this.lineOptions}
onChange={this.onTailLineChange}
/>
<div className="time-range">
{timestamps && (
<>
<Trans>Since</Trans>{" "}
<b>{new Date(timestamps[0]).toLocaleString()}</b>
</>
)}
</div>
<div className="flex gaps">
<Icon
material="av_timer"
onClick={this.toggleTimestamps}
className={cssNames("timestamps-icon", { active: showTimestamps })}
tooltip={(showTimestamps ? _i18n._(t`Hide`) : _i18n._(t`Show`)) + " " + _i18n._(t`timestamps`)}
/>
<Icon
material="get_app"
onClick={this.downloadLogs}
tooltip={_i18n._(t`Save`)}
/>
</div>
</div>
);
}
renderLogs() {
if (!this.ready) {
return <Spinner center/>;
}
const { oldLogs, newLogs } = this.logs;
if (!oldLogs && !newLogs) {
return (
<div className="flex align-center justify-center">
<Trans>There are no logs available for container.</Trans>
</div>
);
}
return (
<>
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(oldLogs))}} />
{newLogs && (
<>
<p className="new-logs-sep" title={_i18n._(t`New logs since opening the dialog`)}/>
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(this.colorConverter.ansi_to_html(newLogs))}} />
</>
)}
</>
);
}
render() {
const { className } = this.props;
return (
<div className={cssNames("PodLogs flex column", className)}>
<InfoPanel
tabId={this.props.tab.id}
controls={this.renderControls()}
showSubmitClose={false}
showButtons={false}
/>
<div className="logs" onScroll={this.onScroll} ref={e => this.logsElement = e}>
{this.renderLogs()}
</div>
</div>
);
}
}

View File

@ -7,7 +7,11 @@
> label {
color: inherit;
border-radius: $radius;
padding: $padding / 1.33 $padding * 1.25;
padding: 6px 6px 6px 10px;
.Icon {
height: $margin * 2;
}
}
&.compact {

View File

@ -15,6 +15,14 @@
white-space: nowrap;
}
.NamespaceSelect {
.Select {
&__value-container {
margin-bottom: 0;
}
}
}
.SearchInput {
label {
background: none;

View File

@ -60,6 +60,7 @@
"dockHeadBackground": "#2e3136",
"dockInfoBackground": "#1e2125",
"dockInfoBorderColor": "#303136",
"logsBackground": "#000000",
"terminalBackground": "#000000",
"terminalForeground": "#ffffff",
"terminalCursor": "#ffffff",

View File

@ -61,6 +61,7 @@
"dockHeadBackground": "#e8e8e8",
"dockInfoBackground": "#e8e8e8",
"dockInfoBorderColor": "#c9cfd3",
"logsBackground": "#ffffff",
"terminalBackground": "#ffffff",
"terminalForeground": "#2d2d2d",
"terminalCursor": "#2d2d2d",

View File

@ -91,6 +91,9 @@ $terminalBrightMagenta: var(--terminalBrightMagenta);
$terminalBrightCyan: var(--terminalBrightCyan);
$terminalBrightWhite: var(--terminalBrightWhite);
// Logs
$logsBackground: var(--logsBackground);
// Dialogs
$dialogTextColor: var(--dialogTextColor);
$dialogBackground: var(--dialogBackground);

16
types/command-exists.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
// Type definitions for command-exists 1.2
// Project: https://github.com/mathisonian/command-exists
// Definitions by: BendingBender <https://github.com/BendingBender>
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
export = commandExists;
declare function commandExists(commandName: string): Promise<string>;
declare function commandExists(
commandName: string,
cb: (error: null, exists: boolean) => void
): void;
declare namespace commandExists {
function sync(commandName: string): boolean;
}

View File

@ -3918,6 +3918,11 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies:
delayed-stream "~1.0.0"
command-exists@1.2.9:
version "1.2.9"
resolved "https://registry.yarnpkg.com/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69"
integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==
commander@*, commander@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
@ -7134,6 +7139,13 @@ jest-message-util@^26.0.1:
slash "^3.0.0"
stack-utils "^2.0.2"
jest-mock-extended@^1.0.10:
version "1.0.10"
resolved "https://registry.yarnpkg.com/jest-mock-extended/-/jest-mock-extended-1.0.10.tgz#a4b1f5b0bb1121acf7c58cd5423d04c473532702"
integrity sha512-R2wKiOgEUPoHZ2kLsAQeQP2IfVEgo3oQqWLSXKdMXK06t3UHkQirA2Xnsdqg/pX6KPWTsdnrzE2ig6nqNjdgVw==
dependencies:
ts-essentials "^4.0.0"
jest-mock@^26.0.1:
version "26.0.1"
resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-26.0.1.tgz#7fd1517ed4955397cf1620a771dc2d61fad8fd40"
@ -11585,6 +11597,11 @@ truncate-utf8-bytes@^1.0.0:
dependencies:
utf8-byte-length "^1.0.1"
ts-essentials@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-4.0.0.tgz#506c42b270bbd0465574b90416533175b09205ab"
integrity sha512-uQJX+SRY9mtbKU+g9kl5Fi7AEMofPCvHfJkQlaygpPmHPZrtgaBqbWFOYyiA47RhnSwwnXdepUJrgqUYxoUyhQ==
ts-jest@^26.1.0:
version "26.3.0"
resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-26.3.0.tgz#6b2845045347dce394f069bb59358253bc1338a9"