1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Merge branch 'master' into implement-kube-api-patch

This commit is contained in:
Jari Kolehmainen 2021-11-12 10:00:53 +02:00 committed by GitHub
commit bc207fa0db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 1285 additions and 556 deletions

14
.github/workflows/stale.yml vendored Normal file
View File

@ -0,0 +1,14 @@
name: Cull Stale Issues
on:
schedule:
- cron: "30 1 * * *"
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@4
with:
only-issue-labels: "needs-information"
repo-token: "${{ secrets.GITHUB_TOKEN }}"
# -1 here means that PRs will never be marked as stale
days-before-pr-stale: -1

5
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

73
.idea/codeStyles/Project.xml generated Normal file
View File

@ -0,0 +1,73 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<option name="AUTODETECT_INDENTS" value="false" />
<option name="RIGHT_MARGIN" value="100" />
<HTMLCodeStyleSettings>
<option name="HTML_ATTRIBUTE_WRAP" value="4" />
<option name="HTML_TEXT_WRAP" value="0" />
<option name="HTML_KEEP_BLANK_LINES" value="1" />
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
<option name="HTML_DO_NOT_INDENT_CHILDREN_OF" value="" />
<option name="HTML_ENFORCE_QUOTES" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="OBJECT_LITERAL_WRAP" value="2" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
<option name="IMPORT_USE_NODE_RESOLUTION" value="FALSE" />
</JSCodeStyleSettings>
<MarkdownNavigatorCodeStyleSettings>
<option name="RIGHT_MARGIN" value="72" />
</MarkdownNavigatorCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<codeStyleSettings language="HTML">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="KEEP_FIRST_COLUMN_COMMENT" value="false" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
<option name="CATCH_ON_NEW_LINE" value="true" />
<option name="FINALLY_ON_NEW_LINE" value="true" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="CALL_PARAMETERS_WRAP" value="5" />
<option name="PREFER_PARAMETERS_WRAP" value="true" />
<option name="METHOD_PARAMETERS_WRAP" value="5" />
<option name="METHOD_PARAMETERS_LPAREN_ON_NEXT_LINE" value="true" />
<option name="METHOD_PARAMETERS_RPAREN_ON_NEXT_LINE" value="true" />
<option name="METHOD_CALL_CHAIN_WRAP" value="5" />
<option name="BINARY_OPERATION_SIGN_ON_NEXT_LINE" value="true" />
<option name="TERNARY_OPERATION_WRAP" value="5" />
<option name="TERNARY_OPERATION_SIGNS_ON_NEXT_LINE" value="true" />
<option name="ARRAY_INITIALIZER_WRAP" value="5" />
<option name="ARRAY_INITIALIZER_LBRACE_ON_NEXT_LINE" value="true" />
<option name="ARRAY_INITIALIZER_RBRACE_ON_NEXT_LINE" value="true" />
<option name="ASSIGNMENT_WRAP" value="5" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/encodings.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="PROJECT" charset="UTF-8" />
</component>
</project>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

6
.idea/jsLinters/eslint.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<option name="fix-on-save" value="true" />
</component>
</project>

19
.idea/lens.iml generated Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/extensions/kube-object-event-status/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/extensions/metrics-cluster-feature/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/extensions/node-menu/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/extensions/pod-menu/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/static/build" />
<excludeFolder url="file://$MODULE_DIR$/src/extensions/npm/extensions/dist" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebPackConfiguration">
<option name="mode" value="DISABLED" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/lens.iml" filepath="$PROJECT_DIR$/.idea/lens.iml" />
</modules>
</component>
</project>

6
.idea/prettier.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myRunOnReformat" value="true" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

14
.idea/webResources.xml generated Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="WebResourcesPaths">
<contentEntries>
<entry url="file://$PROJECT_DIR$">
<entryData>
<resourceRoots>
<path value="file://$PROJECT_DIR$" />
</resourceRoots>
</entryData>
</entry>
</contentEntries>
</component>
</project>

View File

@ -61,14 +61,14 @@ export class MetricsSettings extends React.Component<Props> {
persistence: { persistence: {
enabled: false, enabled: false,
storageClass: null, storageClass: null,
size: "20G", size: "20GiB",
}, },
nodeExporter: { nodeExporter: {
enabled: false, enabled: false,
}, },
retention: { retention: {
time: "2d", time: "2d",
size: "5GB", size: "5GiB",
}, },
kubeStateMetrics: { kubeStateMetrics: {
enabled: false, enabled: false,

View File

@ -331,6 +331,14 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
await cleanup(); await cleanup();
}, 10*60*1000); }, 10*60*1000);
it("shows cluster context menu in sidebar", async () => {
await frame.click(`[data-testid="sidebar-cluster-dropdown"]`);
await frame.waitForSelector(`.Menu >> text="Add to Hotbar"`);
await frame.waitForSelector(`.Menu >> text="Settings"`);
await frame.waitForSelector(`.Menu >> text="Disconnect"`);
await frame.waitForSelector(`.Menu >> text="Delete"`);
});
it("should navigate around common cluster pages", async () => { it("should navigate around common cluster pages", async () => {
for (const test of commonPageTests) { for (const test of commonPageTests) {
if (isTopPageTest(test)) { if (isTopPageTest(test)) {
@ -362,6 +370,8 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
} }
}, 10*60*1000); }, 10*60*1000);
it("show logs and highlight the log search entries", async () => { it("show logs and highlight the log search entries", async () => {
await frame.click(`a[href="/workloads"]`); await frame.click(`a[href="/workloads"]`);
await frame.click(`a[href="/pods"]`); await frame.click(`a[href="/pods"]`);

View File

@ -3,7 +3,7 @@
"productName": "OpenLens", "productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes", "description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens", "homepage": "https://github.com/lensapp/lens",
"version": "5.3.0-alpha.9", "version": "5.3.0-alpha.10",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors", "copyright": "© 2021 OpenLens Authors",
"license": "MIT", "license": "MIT",
@ -220,7 +220,7 @@
"moment-timezone": "^0.5.33", "moment-timezone": "^0.5.33",
"monaco-editor": "^0.29.1", "monaco-editor": "^0.29.1",
"monaco-editor-webpack-plugin": "^5.0.0", "monaco-editor-webpack-plugin": "^5.0.0",
"node-fetch": "^2.6.6", "node-fetch": "lensapp/node-fetch#2.x",
"node-pty": "^0.10.1", "node-pty": "^0.10.1",
"npm": "^6.14.15", "npm": "^6.14.15",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
@ -254,7 +254,7 @@
"@material-ui/icons": "^4.11.2", "@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.60", "@material-ui/lab": "^4.0.0-alpha.60",
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
"@sentry/types": "^6.13.3", "@sentry/types": "^6.14.1",
"@testing-library/jest-dom": "^5.15.0", "@testing-library/jest-dom": "^5.15.0",
"@testing-library/react": "^11.2.7", "@testing-library/react": "^11.2.7",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
@ -278,13 +278,13 @@
"@types/md5-file": "^4.0.2", "@types/md5-file": "^4.0.2",
"@types/mini-css-extract-plugin": "^0.9.1", "@types/mini-css-extract-plugin": "^0.9.1",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "14.17.27", "@types/node": "14.17.33",
"@types/node-fetch": "^2.5.12", "@types/node-fetch": "^2.5.12",
"@types/npm": "^2.0.32", "@types/npm": "^2.0.32",
"@types/progress-bar-webpack-plugin": "^2.1.2", "@types/progress-bar-webpack-plugin": "^2.1.2",
"@types/proper-lockfile": "^4.1.2", "@types/proper-lockfile": "^4.1.2",
"@types/randomcolor": "^0.5.6", "@types/randomcolor": "^0.5.6",
"@types/react": "^17.0.33", "@types/react": "^17.0.34",
"@types/react-beautiful-dnd": "^13.1.2", "@types/react-beautiful-dnd": "^13.1.2",
"@types/react-dom": "^17.0.9", "@types/react-dom": "^17.0.9",
"@types/react-router-dom": "^5.3.2", "@types/react-router-dom": "^5.3.2",
@ -361,7 +361,7 @@
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"sharp": "^0.29.2", "sharp": "^0.29.2",
"style-loader": "^2.0.0", "style-loader": "^2.0.0",
"tailwindcss": "^2.2.17", "tailwindcss": "^2.2.19",
"ts-jest": "26.5.6", "ts-jest": "26.5.6",
"ts-loader": "^7.0.5", "ts-loader": "^7.0.5",
"ts-node": "^10.4.0", "ts-node": "^10.4.0",

View File

@ -20,7 +20,7 @@
*/ */
import type { Request } from "node-fetch"; import type { Request } from "node-fetch";
import { Pod } from "../endpoints/pods.api"; import { Pod, PodsApi } from "../endpoints/pods.api";
import { forRemoteCluster, KubeApi } from "../kube-api"; import { forRemoteCluster, KubeApi } from "../kube-api";
import { KubeJsonApi } from "../kube-json-api"; import { KubeJsonApi } from "../kube-json-api";
import { KubeObject } from "../kube-object"; import { KubeObject } from "../kube-object";
@ -34,7 +34,33 @@ class TestKubeObject extends KubeObject {
class TestKubeApi extends KubeApi<TestKubeObject> {} class TestKubeApi extends KubeApi<TestKubeObject> {}
describe("forRemoteCluster", () => { describe("forRemoteCluster", () => {
it("builds api client", async (done) => { it("builds api client for KubeObject", async () => {
const api = forRemoteCluster({
cluster: {
server: "https://127.0.0.1:6443",
},
user: {
token: "daa",
},
}, Pod);
expect(api).toBeInstanceOf(KubeApi);
});
it("builds api client for given KubeApi", async () => {
const api = forRemoteCluster({
cluster: {
server: "https://127.0.0.1:6443",
},
user: {
token: "daa",
},
}, Pod, PodsApi);
expect(api).toBeInstanceOf(PodsApi);
});
it("calls right api endpoint", async () => {
const api = forRemoteCluster({ const api = forRemoteCluster({
cluster: { cluster: {
server: "https://127.0.0.1:6443", server: "https://127.0.0.1:6443",
@ -47,13 +73,13 @@ describe("forRemoteCluster", () => {
(fetch as any).mockResponse(async (request: any) => { (fetch as any).mockResponse(async (request: any) => {
expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods"); expect(request.url).toEqual("https://127.0.0.1:6443/api/v1/pods");
done();
return { return {
body: "", body: "hello",
}; };
}); });
expect.hasAssertions();
await api.list(); await api.list();
}); });
}); });

View File

@ -25,6 +25,7 @@ import { IMetrics, metricsApi } from "./metrics.api";
import { KubeApi } from "../kube-api"; import { KubeApi } from "../kube-api";
import type { KubeJsonApiData } from "../kube-json-api"; import type { KubeJsonApiData } from "../kube-json-api";
import { isClusterPageContext } from "../../utils/cluster-id-url-parsing"; import { isClusterPageContext } from "../../utils/cluster-id-url-parsing";
import type { RequireExactlyOne } from "type-fest";
export class IngressApi extends KubeApi<Ingress> { export class IngressApi extends KubeApi<Ingress> {
} }
@ -58,7 +59,7 @@ export interface ILoadBalancerIngress {
// extensions/v1beta1 // extensions/v1beta1
interface IExtensionsBackend { interface IExtensionsBackend {
serviceName: string; serviceName: string;
servicePort: number; servicePort: number | string;
} }
// networking.k8s.io/v1 // networking.k8s.io/v1
@ -101,14 +102,18 @@ export interface Ingress {
}[]; }[];
// extensions/v1beta1 // extensions/v1beta1
backend?: IExtensionsBackend; backend?: IExtensionsBackend;
// networking.k8s.io/v1 /**
defaultBackend?: INetworkingBackend & { * The default backend which is exactly on of:
* - service
* - resource
*/
defaultBackend?: RequireExactlyOne<INetworkingBackend & {
resource: { resource: {
apiGroup: string; apiGroup: string;
kind: string; kind: string;
name: string; name: string;
} }
} }>
}; };
status: { status: {
loadBalancer: { loadBalancer: {
@ -153,10 +158,11 @@ export class Ingress extends KubeObject {
return routes; return routes;
} }
getServiceNamePort() { getServiceNamePort(): IExtensionsBackend {
const { spec } = this; const { spec: { backend, defaultBackend } = {}} = this;
const serviceName = spec?.defaultBackend?.service.name ?? spec?.backend?.serviceName;
const servicePort = spec?.defaultBackend?.service.port.number ?? spec?.defaultBackend?.service.port.name ?? spec?.backend?.servicePort; const serviceName = defaultBackend?.service?.name ?? backend?.serviceName;
const servicePort = defaultBackend?.service?.port.number ?? defaultBackend?.service?.port.name ?? backend?.servicePort;
return { return {
serviceName, serviceName,

View File

@ -236,13 +236,10 @@ export class Node extends KubeObject {
} }
getOperatingSystem(): string { getOperatingSystem(): string {
const label = this.getLabels().find(label => label.startsWith("kubernetes.io/os=")); return this.metadata?.labels?.["kubernetes.io/os"]
|| this.metadata?.labels?.["beta.kubernetes.io/os"]
if (label) { || this.status?.nodeInfo?.operatingSystem
return label.split("=", 2)[1]; || "linux";
}
return "linux";
} }
isUnschedulable() { isUnschedulable() {

View File

@ -165,40 +165,40 @@ interface IContainerProbe {
failureThreshold?: number; failureThreshold?: number;
} }
export interface ContainerStateRunning {
startedAt: string;
}
export interface ContainerStateWaiting {
reason: string;
message: string;
}
export interface ContainerStateTerminated {
startedAt: string;
finishedAt: string;
exitCode: number;
reason: string;
containerID?: string;
message?: string;
signal?: number;
}
/**
* ContainerState holds a possible state of container. Only one of its members
* may be specified. If none of them is specified, the default one is
* `ContainerStateWaiting`.
*/
export interface ContainerState {
running?: ContainerStateRunning;
waiting?: ContainerStateWaiting;
terminated?: ContainerStateTerminated;
}
export interface IPodContainerStatus { export interface IPodContainerStatus {
name: string; name: string;
state?: { state?: ContainerState;
[index: string]: object; lastState?: ContainerState;
running?: {
startedAt: string;
};
waiting?: {
reason: string;
message: string;
};
terminated?: {
startedAt: string;
finishedAt: string;
exitCode: number;
reason: string;
};
};
lastState?: {
[index: string]: object;
running?: {
startedAt: string;
};
waiting?: {
reason: string;
message: string;
};
terminated?: {
startedAt: string;
finishedAt: string;
exitCode: number;
reason: string;
};
};
ready: boolean; ready: boolean;
restartCount: number; restartCount: number;
image: string; image: string;
@ -373,23 +373,16 @@ export class Pod extends WorkloadKubeObject {
} }
// Returns pod phase or container error if occurred // Returns pod phase or container error if occurred
getStatusMessage() { getStatusMessage(): string {
if (this.getReason() === PodStatus.EVICTED) return "Evicted"; if (this.getReason() === PodStatus.EVICTED) {
if (this.metadata.deletionTimestamp) return "Terminating"; return "Evicted";
const statuses = this.getContainerStatuses(false); // not including initContainers
for (const { state } of statuses.reverse()) {
if (state.waiting) {
return state.waiting.reason || "Waiting";
}
if (state.terminated) {
return state.terminated.reason || "Terminated";
}
} }
return this.getStatusPhase(); if (this.metadata.deletionTimestamp) {
return "Terminating";
}
return this.getStatusPhase() || "Waiting";
} }
getStatusPhase() { getStatusPhase() {

View File

@ -116,7 +116,7 @@ export interface IRemoteKubeApiConfig {
} }
} }
export function forCluster<T extends KubeObject>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor<T>): KubeApi<T> { export function forCluster<T extends KubeObject, Y extends KubeApi<T> = KubeApi<T>>(cluster: ILocalKubeApiConfig, kubeClass: KubeObjectConstructor<T>, apiClass: new (apiOpts: IKubeApiOptions<T>) => Y = null): KubeApi<T> {
const url = new URL(apiBase.config.serverAddress); const url = new URL(apiBase.config.serverAddress);
const request = new KubeJsonApi({ const request = new KubeJsonApi({
serverAddress: apiBase.config.serverAddress, serverAddress: apiBase.config.serverAddress,
@ -128,15 +128,18 @@ export function forCluster<T extends KubeObject>(cluster: ILocalKubeApiConfig, k
}, },
}); });
return new KubeApi({ if (!apiClass) {
apiClass = KubeApi as new (apiOpts: IKubeApiOptions<T>) => Y;
}
return new apiClass({
objectConstructor: kubeClass, objectConstructor: kubeClass,
request, request,
}); });
} }
export function forRemoteCluster<T extends KubeObject>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor<T>): KubeApi<T> { export function forRemoteCluster<T extends KubeObject, Y extends KubeApi<T> = KubeApi<T>>(config: IRemoteKubeApiConfig, kubeClass: KubeObjectConstructor<T>, apiClass: new (apiOpts: IKubeApiOptions<T>) => Y = null): Y {
const reqInit: RequestInit = {}; const reqInit: RequestInit = {};
const agentOptions: AgentOptions = {}; const agentOptions: AgentOptions = {};
if (config.cluster.skipTLSVerify === true) { if (config.cluster.skipTLSVerify === true) {
@ -173,8 +176,12 @@ export function forRemoteCluster<T extends KubeObject>(config: IRemoteKubeApiCon
} : {}), } : {}),
}, reqInit); }, reqInit);
return new KubeApi({ if (!apiClass) {
objectConstructor: kubeClass, apiClass = KubeApi as new (apiOpts: IKubeApiOptions<T>) => Y;
}
return new apiClass({
objectConstructor: kubeClass as KubeObjectConstructor<T>,
request, request,
}); });
} }
@ -233,10 +240,7 @@ export class KubeApi<T extends KubeObject> {
isNamespaced = options.objectConstructor?.namespaced, isNamespaced = options.objectConstructor?.namespaced,
} = options || {}; } = options || {};
if (!options.apiBase) { const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase || objectConstructor.apiBase);
options.apiBase = objectConstructor.apiBase;
}
const { apiBase, apiPrefix, apiGroup, apiVersion, resource } = parseKubeApi(options.apiBase);
this.kind = kind; this.kind = kind;
this.isNamespaced = isNamespaced; this.isNamespaced = isNamespaced;

View File

@ -21,6 +21,9 @@
import { JsonApi, JsonApiData, JsonApiError } from "./json-api"; import { JsonApi, JsonApiData, JsonApiError } from "./json-api";
import type { Response } from "node-fetch"; import type { Response } from "node-fetch";
import type { Cluster } from "../../main/cluster";
import { LensProxy } from "../../main/lens-proxy";
import { apiKubePrefix, isDebugging } from "../vars";
export interface KubeJsonApiListMetadata { export interface KubeJsonApiListMetadata {
resourceVersion: string; resourceVersion: string;
@ -70,6 +73,20 @@ export interface KubeJsonApiError extends JsonApiError {
} }
export class KubeJsonApi extends JsonApi<KubeJsonApiData> { export class KubeJsonApi extends JsonApi<KubeJsonApiData> {
static forCluster(cluster: Cluster): KubeJsonApi {
const port = LensProxy.getInstance().port;
return new this({
serverAddress: `http://127.0.0.1:${port}`,
apiBase: apiKubePrefix,
debug: isDebugging,
}, {
headers: {
"Host": `${cluster.id}.localhost:${port}`,
},
});
}
protected parseError(error: KubeJsonApiError | any, res: Response): string[] { protected parseError(error: KubeJsonApiError | any, res: Response): string[] {
const { status, reason, message } = error; const { status, reason, message } = error;
@ -80,4 +97,3 @@ export class KubeJsonApi extends JsonApi<KubeJsonApiData> {
return super.parseError(error, res); return super.parseError(error, res);
} }
} }

View File

@ -23,11 +23,20 @@
import * as Base64 from "crypto-js/enc-base64"; import * as Base64 from "crypto-js/enc-base64";
import * as Utf8 from "crypto-js/enc-utf8"; import * as Utf8 from "crypto-js/enc-utf8";
export const base64 = { /**
decode(data: string) { * Computes utf-8 from base64
return Base64.parse(data).toString(Utf8); * @param data A Base64 encoded string
}, * @returns The original utf-8 string
encode(data: string) { */
return Utf8.parse(data).toString(Base64); export function decode(data: string): string {
}, return Base64.parse(data).toString(Utf8);
}; }
/**
* Computes base64 from utf-8
* @param data A normal string
* @returns A base64 encoded version
*/
export function encode(data: string): string {
return Utf8.parse(data).toString(Base64);
}

View File

@ -28,7 +28,6 @@ export function noop<T extends any[]>(...args: T): void {
export * from "./app-version"; export * from "./app-version";
export * from "./autobind"; export * from "./autobind";
export * from "./base64";
export * from "./camelCase"; export * from "./camelCase";
export * from "./cloneJson"; export * from "./cloneJson";
export * from "./cluster-id-url-parsing"; export * from "./cluster-id-url-parsing";
@ -62,9 +61,11 @@ export * from "./types";
import * as iter from "./iter"; import * as iter from "./iter";
import * as array from "./array"; import * as array from "./array";
import * as tuple from "./tuple"; import * as tuple from "./tuple";
import * as base64 from "./base64";
export { export {
iter, iter,
array, array,
tuple, tuple,
base64,
}; };

View File

@ -20,7 +20,7 @@
*/ */
import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx";
import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityKindData } from "../../common/catalog"; import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityConstructor, CatalogEntityKindData } from "../../common/catalog";
import { iter } from "../../common/utils"; import { iter } from "../../common/utils";
export class CatalogEntityRegistry { export class CatalogEntityRegistry {
@ -59,7 +59,7 @@ export class CatalogEntityRegistry {
return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[];
} }
getItemsByEntityClass<T extends CatalogEntity>({ apiVersion, kind }: CatalogEntityKindData): T[] { getItemsByEntityClass<T extends CatalogEntity>({ apiVersion, kind }: CatalogEntityKindData & CatalogEntityConstructor<T>): T[] {
return this.getItemsForApiKind(apiVersion, kind); return this.getItemsForApiKind(apiVersion, kind);
} }
} }

View File

@ -41,6 +41,8 @@ export class ClusterManager extends Singleton {
private store = ClusterStore.getInstance(); private store = ClusterStore.getInstance();
deleting = observable.set<ClusterId>(); deleting = observable.set<ClusterId>();
@observable visibleCluster: ClusterId | undefined = undefined;
constructor() { constructor() {
super(); super();
makeObservable(this); makeObservable(this);
@ -61,8 +63,22 @@ export class ClusterManager extends Singleton {
{ fireImmediately: false }, { fireImmediately: false },
); );
reaction(() => catalogEntityRegistry.getItemsForApiKind<KubernetesCluster>("entity.k8slens.dev/v1alpha1", "KubernetesCluster"), (entities) => { reaction(
this.syncClustersFromCatalog(entities); () => catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster),
entities => this.syncClustersFromCatalog(entities),
);
reaction(() => [
catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster),
this.visibleCluster,
] as const, ([entities, visibleCluster]) => {
for (const entity of entities) {
if (entity.getId() === visibleCluster) {
entity.status.active = true;
} else {
entity.status.active = false;
}
}
}); });
observe(this.deleting, change => { observe(this.deleting, change => {
@ -240,27 +256,20 @@ export class ClusterManager extends Singleton {
} }
getClusterForRequest(req: http.IncomingMessage): Cluster { getClusterForRequest(req: http.IncomingMessage): Cluster {
let cluster: Cluster = null;
// lens-server is connecting to 127.0.0.1:<port>/<uid> // lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) { if (req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1]; const clusterId = req.url.split("/")[1];
const cluster = this.store.getById(clusterId);
cluster = this.store.getById(clusterId);
if (cluster) { if (cluster) {
// we need to swap path prefix so that request is proxied to kube api // we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix); req.url = req.url.replace(`/${clusterId}`, apiKubePrefix);
} }
} else if (req.headers["x-cluster-id"]) {
cluster = this.store.getById(req.headers["x-cluster-id"].toString());
} else {
const clusterId = getClusterIdFromHost(req.headers.host);
cluster = this.store.getById(clusterId); return cluster;
} }
return cluster; return this.store.getById(getClusterIdFromHost(req.headers.host));
} }
} }

View File

@ -63,8 +63,9 @@ import { ensureDir } from "fs-extra";
import { Router } from "./router"; import { Router } from "./router";
import { initMenu } from "./menu"; import { initMenu } from "./menu";
import { initTray } from "./tray"; import { initTray } from "./tray";
import { kubeApiRequest, shellApiRequest } from "./proxy-functions"; import { kubeApiRequest, shellApiRequest, ShellRequestAuthenticator } from "./proxy-functions";
import { AppPaths } from "../common/app-paths"; import { AppPaths } from "../common/app-paths";
import { ShellSession } from "./shell-session/shell-session";
injectSystemCAs(); injectSystemCAs();
@ -122,7 +123,7 @@ app.on("second-instance", (event, argv) => {
WindowManager.getInstance(false)?.ensureMainWindow(); WindowManager.getInstance(false)?.ensureMainWindow();
}); });
app.on("ready", async () => { app.on("ready", async () => {
logger.info(`🚀 Starting ${productName} from "${AppPaths.get("exe")}"`); logger.info(`🚀 Starting ${productName} from "${AppPaths.get("exe")}"`);
logger.info("🐚 Syncing shell environment"); logger.info("🐚 Syncing shell environment");
await shellSync(); await shellSync();
@ -134,6 +135,7 @@ app.on("ready", async () => {
registerFileProtocol("static", __static); registerFileProtocol("static", __static);
PrometheusProviderRegistry.createInstance(); PrometheusProviderRegistry.createInstance();
ShellRequestAuthenticator.createInstance().init();
initializers.initPrometheusProviderRegistry(); initializers.initPrometheusProviderRegistry();
/** /**
@ -227,6 +229,7 @@ app.on("ready", async () => {
onQuitCleanup.push( onQuitCleanup.push(
initMenu(windowManager), initMenu(windowManager),
initTray(windowManager), initTray(windowManager),
() => ShellSession.cleanup(),
); );
installDeveloperTools(); installDeveloperTools();

View File

@ -20,13 +20,12 @@
*/ */
import { BrowserWindow, dialog, IpcMainInvokeEvent } from "electron"; import { BrowserWindow, dialog, IpcMainInvokeEvent } from "electron";
import { KubernetesCluster } from "../../common/catalog-entities";
import { clusterFrameMap } from "../../common/cluster-frames"; import { clusterFrameMap } from "../../common/cluster-frames";
import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc"; import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc";
import type { ClusterId } from "../../common/cluster-types"; import type { ClusterId } from "../../common/cluster-types";
import { ClusterStore } from "../../common/cluster-store"; import { ClusterStore } from "../../common/cluster-store";
import { appEventBus } from "../../common/event-bus"; import { appEventBus } from "../../common/event-bus";
import { dialogShowOpenDialogHandler, ipcMainHandle } from "../../common/ipc"; import { dialogShowOpenDialogHandler, ipcMainHandle, ipcMainOn } from "../../common/ipc";
import { catalogEntityRegistry } from "../catalog"; import { catalogEntityRegistry } from "../catalog";
import { pushCatalogToRenderer } from "../catalog-pusher"; import { pushCatalogToRenderer } from "../catalog-pusher";
import { ClusterManager } from "../cluster-manager"; import { ClusterManager } from "../cluster-manager";
@ -54,16 +53,8 @@ export function initIpcMainHandlers() {
} }
}); });
ipcMainHandle(clusterVisibilityHandler, (event: IpcMainInvokeEvent, clusterId: ClusterId, visible: boolean) => { ipcMainOn(clusterVisibilityHandler, (event, clusterId?: ClusterId) => {
const entity = catalogEntityRegistry.getById(clusterId); ClusterManager.getInstance().visibleCluster = clusterId;
for (const kubeEntity of catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster)) {
kubeEntity.status.active = false;
}
if (entity) {
entity.status.active = visible;
}
}); });
ipcMainHandle(clusterRefreshHandler, (event, clusterId: ClusterId) => { ipcMainHandle(clusterRefreshHandler, (event, clusterId: ClusterId) => {

View File

@ -60,12 +60,10 @@ export class LensProxy extends Singleton {
public port: number; public port: number;
constructor(protected router: Router, functions: LensProxyFunctions) { constructor(protected router: Router, { shellApiRequest, kubeApiRequest, getClusterForRequest }: LensProxyFunctions) {
super(); super();
const { shellApiRequest, kubeApiRequest } = functions; this.getClusterForRequest = getClusterForRequest;
this.getClusterForRequest = functions.getClusterForRequest;
this.proxyServer = spdy.createServer({ this.proxyServer = spdy.createServer({
spdy: { spdy: {
@ -79,10 +77,16 @@ export class LensProxy extends Singleton {
this.proxyServer this.proxyServer
.on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => { .on("upgrade", (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => {
const isInternal = req.url.startsWith(`${apiPrefix}?`); const isInternal = req.url.startsWith(`${apiPrefix}?`);
const cluster = getClusterForRequest(req);
if (!cluster) {
return void logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`);
}
const reqHandler = isInternal ? shellApiRequest : kubeApiRequest; const reqHandler = isInternal ? shellApiRequest : kubeApiRequest;
(async () => reqHandler({ req, socket, head }))() (async () => reqHandler({ req, socket, head, cluster }))()
.catch(error => logger.error(logger.error(`[LENS-PROXY]: failed to handle proxy upgrade: ${error}`))); .catch(error => logger.error("[LENS-PROXY]: failed to handle proxy upgrade", error));
}); });
} }
@ -142,6 +146,10 @@ export class LensProxy extends Singleton {
res.flushHeaders(); res.flushHeaders();
} }
} }
proxyRes.on("aborted", () => { // happens when proxy target aborts connection
res.end();
});
}); });
proxy.on("error", (error, req, res, target) => { proxy.on("error", (error, req, res, target) => {

View File

@ -23,18 +23,11 @@ import { chunk } from "lodash";
import net from "net"; import net from "net";
import url from "url"; import url from "url";
import { apiKubePrefix } from "../../common/vars"; import { apiKubePrefix } from "../../common/vars";
import { ClusterManager } from "../cluster-manager";
import type { ProxyApiRequestArgs } from "./types"; import type { ProxyApiRequestArgs } from "./types";
const skipRawHeaders = new Set(["Host", "Authorization"]); const skipRawHeaders = new Set(["Host", "Authorization"]);
export async function kubeApiRequest({ req, socket, head }: ProxyApiRequestArgs) { export async function kubeApiRequest({ req, socket, head, cluster }: ProxyApiRequestArgs) {
const cluster = ClusterManager.getInstance().getClusterForRequest(req);
if (!cluster) {
return;
}
const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, "");
const apiUrl = url.parse(cluster.apiUrl); const apiUrl = url.parse(cluster.apiUrl);
const pUrl = url.parse(proxyUrl); const pUrl = url.parse(proxyUrl);

View File

@ -19,29 +19,81 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type http from "http";
import url from "url";
import logger from "../logger"; import logger from "../logger";
import * as WebSocket from "ws"; import { Server as WebSocketServer } from "ws";
import { NodeShellSession, LocalShellSession } from "../shell-session"; import { NodeShellSession, LocalShellSession } from "../shell-session";
import type { ProxyApiRequestArgs } from "./types"; import type { ProxyApiRequestArgs } from "./types";
import { ClusterManager } from "../cluster-manager"; import { ClusterManager } from "../cluster-manager";
import URLParse from "url-parse";
import { ExtendedMap, Singleton } from "../../common/utils";
import type { ClusterId } from "../../common/cluster-types";
import { ipcMainHandle } from "../../common/ipc";
import crypto from "crypto";
import { promisify } from "util";
export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs) { const randomBytes = promisify(crypto.randomBytes);
const ws = new WebSocket.Server({ noServer: true });
ws.on("connection", ((socket: WebSocket, req: http.IncomingMessage) => { export class ShellRequestAuthenticator extends Singleton {
const cluster = ClusterManager.getInstance().getClusterForRequest(req); private tokens = new ExtendedMap<ClusterId, Map<string, Uint8Array>>();
const nodeParam = url.parse(req.url, true).query["node"]?.toString();
const shell = nodeParam init() {
? new NodeShellSession(socket, cluster, nodeParam) ipcMainHandle("cluster:shell-api", async (event, clusterId, tabId) => {
: new LocalShellSession(socket, cluster); const authToken = Uint8Array.from(await randomBytes(128));
this.tokens
.getOrInsert(clusterId, () => new Map())
.set(tabId, authToken);
return authToken;
});
}
/**
* Authenticates a single use token for creating a new shell
* @param clusterId The `ClusterId` for the shell
* @param tabId The ID for the shell
* @param token The value that is being presented as a one time authentication token
* @returns `true` if `token` was valid, false otherwise
*/
authenticate(clusterId: ClusterId, tabId: string, token: string): boolean {
const clusterTokens = this.tokens.get(clusterId);
if (!clusterTokens) {
return false;
}
const authToken = clusterTokens.get(tabId);
const buf = Uint8Array.from(Buffer.from(token, "base64"));
if (authToken instanceof Uint8Array && authToken.length === buf.length && crypto.timingSafeEqual(authToken, buf)) {
// remove the token because it is a single use token
clusterTokens.delete(tabId);
return true;
}
return false;
}
}
export function shellApiRequest({ req, socket, head }: ProxyApiRequestArgs): void {
const cluster = ClusterManager.getInstance().getClusterForRequest(req);
const { query: { node, shellToken, id: tabId }} = new URLParse(req.url, true);
if (!cluster || !ShellRequestAuthenticator.getInstance().authenticate(cluster.id, tabId, shellToken)) {
socket.write("Invalid shell request");
return void socket.end();
}
const ws = new WebSocketServer({ noServer: true });
ws.handleUpgrade(req, socket, head, (webSocket) => {
const shell = node
? new NodeShellSession(webSocket, cluster, node, tabId)
: new LocalShellSession(webSocket, cluster, tabId);
shell.open() shell.open()
.catch(error => logger.error(`[SHELL-SESSION]: failed to open: ${error}`, { error })); .catch(error => logger.error(`[SHELL-SESSION]: failed to open a ${node ? "node" : "local"} shell`, error));
}));
ws.handleUpgrade(req, socket, head, (con) => {
ws.emit("connection", con, req);
}); });
} }

View File

@ -21,9 +21,11 @@
import type http from "http"; import type http from "http";
import type net from "net"; import type net from "net";
import type { Cluster } from "../cluster";
export interface ProxyApiRequestArgs { export interface ProxyApiRequestArgs {
req: http.IncomingMessage, req: http.IncomingMessage,
socket: net.Socket, socket: net.Socket,
head: Buffer, head: Buffer,
cluster: Cluster,
} }

View File

@ -95,7 +95,7 @@ class PortForward {
}); });
this.process.stderr.on("data", (data) => { this.process.stderr.on("data", (data) => {
logger.warn(`[PORT-FORWARD-ROUTE]: kubectl port-forward process stderr: ${data}`); logger.debug(`[PORT-FORWARD-ROUTE]: kubectl port-forward process stderr: ${data}`);
}); });
const internalPort = await getPortFrom(this.process.stdout, { const internalPort = await getPortFrom(this.process.stdout, {

View File

@ -40,7 +40,7 @@ export class LocalShellSession extends ShellSession {
const shell = env.PTYSHELL; const shell = env.PTYSHELL;
const args = await this.getShellArgs(shell); const args = await this.getShellArgs(shell);
super.open(env.PTYSHELL, args, env); await this.openShellProcess(env.PTYSHELL, args, env);
} }
protected async getShellArgs(shell: string): Promise<string[]> { protected async getShellArgs(shell: string): Promise<string[]> {

View File

@ -19,26 +19,27 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type * as WebSocket from "ws"; import type WebSocket from "ws";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import * as k8s from "@kubernetes/client-node"; import * as k8s from "@kubernetes/client-node";
import type { KubeConfig } from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node";
import type { Cluster } from "../cluster"; import type { Cluster } from "../cluster";
import { ShellOpenError, ShellSession } from "./shell-session"; import { ShellOpenError, ShellSession } from "./shell-session";
import { get } from "lodash"; import { get } from "lodash";
import { Node, NodesApi } from "../../common/k8s-api/endpoints";
import { KubeJsonApi } from "../../common/k8s-api/kube-json-api";
import logger from "../logger";
export class NodeShellSession extends ShellSession { export class NodeShellSession extends ShellSession {
ShellType = "node-shell"; ShellType = "node-shell";
protected podId = `node-shell-${uuid()}`; protected readonly podName = `node-shell-${uuid()}`;
protected kc: KubeConfig; protected kc: KubeConfig;
protected get cwd(): string | undefined { protected readonly cwd: string | undefined = undefined;
return undefined;
}
constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) { constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string, terminalId: string) {
super(socket, cluster); super(socket, cluster, terminalId);
} }
public async open() { public async open() {
@ -55,10 +56,27 @@ export class NodeShellSession extends ShellSession {
throw new ShellOpenError("failed to create node pod", error); throw new ShellOpenError("failed to create node pod", error);
} }
const args = ["exec", "-i", "-t", "-n", "kube-system", this.podId, "--", "sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))"];
const env = await this.getCachedShellEnv(); const env = await this.getCachedShellEnv();
const args = ["exec", "-i", "-t", "-n", "kube-system", this.podName, "--"];
const nodeApi = new NodesApi({
objectConstructor: Node,
request: KubeJsonApi.forCluster(this.cluster),
});
const node = await nodeApi.get({ name: this.nodeName });
const nodeOs = node.getOperatingSystem();
await super.open(shell, args, env); switch (nodeOs) {
default:
logger.warn(`[NODE-SHELL-SESSION]: could not determine node OS, falling back with assumption of linux`);
case "linux":
args.push("sh", "-c", "((clear && bash) || (clear && ash) || (clear && sh))");
break;
case "windows":
args.push("powershell");
break;
}
await this.openShellProcess(shell, args, env);
} }
protected createNodeShellPod() { protected createNodeShellPod() {
@ -73,7 +91,7 @@ export class NodeShellSession extends ShellSession {
.makeApiClient(k8s.CoreV1Api) .makeApiClient(k8s.CoreV1Api)
.createNamespacedPod("kube-system", { .createNamespacedPod("kube-system", {
metadata: { metadata: {
name: this.podId, name: this.podName,
namespace: "kube-system", namespace: "kube-system",
}, },
spec: { spec: {
@ -101,33 +119,39 @@ export class NodeShellSession extends ShellSession {
} }
protected waitForRunningPod(): Promise<void> { protected waitForRunningPod(): Promise<void> {
return new Promise((resolve, reject) => { logger.debug(`[NODE-SHELL]: waiting for ${this.podName} to be running`);
const watch = new k8s.Watch(this.kc);
watch return new Promise((resolve, reject) => {
new k8s.Watch(this.kc)
.watch(`/api/v1/namespaces/kube-system/pods`, .watch(`/api/v1/namespaces/kube-system/pods`,
{}, {},
// callback is called for each received object. // callback is called for each received object.
(type, obj) => { (type, { metadata: { name }, status }) => {
if (obj.metadata.name == this.podId && obj.status.phase === "Running") { if (name === this.podName) {
resolve(); switch (status.phase) {
case "Running":
return resolve();
case "Failed":
return reject(`Failed to be created: ${status.message || "unknown error"}`);
}
} }
}, },
// done callback is called if the watch terminates normally // done callback is called if the watch terminates normally
(err) => { (err) => {
console.log(err); logger.error(`[NODE-SHELL]: ${this.podName} was not created in time`);
reject(err); reject(err);
}, },
) )
.then(req => { .then(req => {
setTimeout(() => { setTimeout(() => {
console.log("aborting"); logger.error(`[NODE-SHELL]: aborting wait for ${this.podName}, timing out`);
req.abort(); req.abort();
}, 2 * 60 * 1000); reject("Pod creation timed out");
}, 2 * 60 * 1000); // 2 * 60 * 1000
}) })
.catch(err => { .catch(error => {
console.log("watch failed"); logger.error(`[NODE-SHELL]: waiting for ${this.podName} failed: ${error}`);
reject(err); reject(error);
}); });
}); });
} }
@ -141,6 +165,7 @@ export class NodeShellSession extends ShellSession {
this this
.kc .kc
.makeApiClient(k8s.CoreV1Api) .makeApiClient(k8s.CoreV1Api)
.deleteNamespacedPod(this.podId, "kube-system"); .deleteNamespacedPod(this.podName, "kube-system")
.catch(error => logger.warn(`[NODE-SHELL]: failed to remove pod shell`, error));
} }
} }

View File

@ -22,7 +22,7 @@
import fse from "fs-extra"; import fse from "fs-extra";
import type { Cluster } from "../cluster"; import type { Cluster } from "../cluster";
import { Kubectl } from "../kubectl"; import { Kubectl } from "../kubectl";
import type * as WebSocket from "ws"; import type WebSocket from "ws";
import { shellEnv } from "../utils/shell-env"; import { shellEnv } from "../utils/shell-env";
import { app } from "electron"; import { app } from "electron";
import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars"; import { clearKubeconfigEnvVars } from "../utils/clear-kube-env-vars";
@ -31,6 +31,7 @@ import { isWindows } from "../../common/vars";
import { UserStore } from "../../common/user-store"; import { UserStore } from "../../common/user-store";
import * as pty from "node-pty"; import * as pty from "node-pty";
import { appEventBus } from "../../common/event-bus"; import { appEventBus } from "../../common/event-bus";
import logger from "../logger";
export class ShellOpenError extends Error { export class ShellOpenError extends Error {
constructor(message: string, public cause: Error) { constructor(message: string, public cause: Error) {
@ -40,41 +41,140 @@ export class ShellOpenError extends Error {
} }
} }
export enum WebSocketCloseEvent {
/**
* The connection successfully completed the purpose for which it was created.
*/
NormalClosure = 1000,
/**
* The endpoint is going away, either because of a server failure or because
* the browser is navigating away from the page that opened the connection.
*/
GoingAway = 1001,
/**
* The endpoint is terminating the connection due to a protocol error.
*/
ProtocolError = 1002,
/**
* The connection is being terminated because the endpoint received data of a
* type it cannot accept. (For example, a text-only endpoint received binary
* data.)
*/
UnsupportedData = 1003,
/**
* Indicates that no status code was provided even though one was expected.
*/
NoStatusReceived = 1005,
/**
* Indicates that a connection was closed abnormally (that is, with no close
* frame being sent) when a status code is expected.
*/
AbnormalClosure = 1006,
/**
* The endpoint is terminating the connection because a message was received
* that contained inconsistent data (e.g., non-UTF-8 data within a text message).
*/
InvalidFramePayloadData = 1007,
/**
* The endpoint is terminating the connection because it received a message
* that violates its policy. This is a generic status code, used when codes
* 1003 and 1009 are not suitable.
*/
PolicyViolation = 1008,
/**
* The endpoint is terminating the connection because a data frame was
* received that is too large.
*/
MessageTooBig = 1009,
/**
* The client is terminating the connection because it expected the server to
* negotiate one or more extension, but the server didn't.
*/
MissingExtension = 1010,
/**
* The server is terminating the connection because it encountered an
* unexpected condition that prevented it from fulfilling the request.
*/
InternalError = 1011,
/**
* The server is terminating the connection because it is restarting.
*/
ServiceRestart = 1012,
/**
* The server is terminating the connection due to a temporary condition,
* e.g. it is overloaded and is casting off some of its clients.
*/
TryAgainLater = 1013,
/**
* The server was acting as a gateway or proxy and received an invalid
* response from the upstream server. This is similar to 502 HTTP Status Code.
*/
BadGateway = 1014,
/**
* Indicates that the connection was closed due to a failure to perform a TLS
* handshake (e.g., the server certificate can't be verified).
*/
TlsHandshake = 1015,
}
export abstract class ShellSession { export abstract class ShellSession {
abstract ShellType: string; abstract ShellType: string;
static shellEnvs: Map<string, Record<string, any>> = new Map(); private static shellEnvs = new Map<string, Record<string, string>>();
private static processes = new Map<string, pty.IPty>();
/**
* Kill all remaining shell backing processes. Should be called when about to
* quit
*/
public static cleanup(): void {
for (const shellProcess of this.processes.values()) {
try {
process.kill(shellProcess.pid);
} catch {}
}
this.processes.clear();
}
protected kubectl: Kubectl; protected kubectl: Kubectl;
protected running = false; protected running = false;
protected shellProcess: pty.IPty;
protected kubectlBinDirP: Promise<string>; protected kubectlBinDirP: Promise<string>;
protected kubeconfigPathP: Promise<string>; protected kubeconfigPathP: Promise<string>;
protected readonly terminalId: string;
protected abstract get cwd(): string | undefined; protected abstract get cwd(): string | undefined;
constructor(protected websocket: WebSocket, protected cluster: Cluster) { protected ensureShellProcess(shell: string, args: string[], env: Record<string, string>, cwd: string): pty.IPty {
if (!ShellSession.processes.has(this.terminalId)) {
ShellSession.processes.set(this.terminalId, pty.spawn(shell, args, {
cols: 80,
cwd,
env,
name: "xterm-256color",
rows: 30,
}));
}
return ShellSession.processes.get(this.terminalId);
}
constructor(protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) {
this.kubectl = new Kubectl(cluster.version); this.kubectl = new Kubectl(cluster.version);
this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath(); this.kubeconfigPathP = this.cluster.getProxyKubeconfigPath();
this.kubectlBinDirP = this.kubectl.binDir(); this.kubectlBinDirP = this.kubectl.binDir();
this.terminalId = `${cluster.id}:${terminalId}`;
} }
protected async open(shell: string, args: string[], env: Record<string, any>) { protected async openShellProcess(shell: string, args: string[], env: Record<string, any>) {
const cwd = (this.cwd && await fse.pathExists(this.cwd)) const cwd = (this.cwd && await fse.pathExists(this.cwd))
? this.cwd ? this.cwd
: env.HOME; : env.HOME;
const shellProcess = this.ensureShellProcess(shell, args, env, cwd);
this.shellProcess = pty.spawn(shell, args, {
cols: 80,
cwd,
env,
name: "xterm-256color",
rows: 30,
});
this.running = true; this.running = true;
shellProcess.onData(data => this.sendResponse(data));
this.shellProcess.onData(data => this.sendResponse(data)); shellProcess.onExit(({ exitCode }) => {
this.shellProcess.onExit(({ exitCode }) => {
this.running = false; this.running = false;
if (exitCode > 0) { if (exitCode > 0) {
@ -95,24 +195,28 @@ export abstract class ShellSession {
switch (data[0]) { switch (data[0]) {
case "0": case "0":
this.shellProcess.write(message); shellProcess.write(message);
break; break;
case "4": case "4":
const { Width, Height } = JSON.parse(message); const { Width, Height } = JSON.parse(message);
this.shellProcess.resize(Width, Height); shellProcess.resize(Width, Height);
break; break;
} }
}) })
.on("close", () => { .on("close", (code) => {
if (this.running) { logger.debug(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${code}`);
if (this.running && code !== WebSocketCloseEvent.AbnormalClosure) {
// This code is the one that gets sent when the network is turned off
try { try {
process.kill(this.shellProcess.pid); logger.info(`[SHELL-SESSION]: Killing shell process for ${this.terminalId}`);
process.kill(shellProcess.pid);
ShellSession.processes.delete(this.terminalId);
} catch (e) { } catch (e) {
} }
this.running = false;
} }
this.running = false;
}); });
appEventBus.emit({ name: this.ShellType, action: "open" }); appEventBus.emit({ name: this.ShellType, action: "open" });
@ -191,7 +295,7 @@ export abstract class ShellSession {
return env; return env;
} }
protected exit(code = 1000) { protected exit(code = WebSocketCloseEvent.NormalClosure) {
if (this.websocket.readyState == this.websocket.OPEN) { if (this.websocket.readyState == this.websocket.OPEN) {
this.websocket.close(code); this.websocket.close(code);
} }

View File

@ -19,13 +19,13 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { boundMethod, base64, EventEmitter } from "../utils"; import { boundMethod, base64, EventEmitter, getHostedClusterId } from "../utils";
import { WebSocketApi } from "./websocket-api"; import { WebSocketApi } from "./websocket-api";
import isEqual from "lodash/isEqual"; import isEqual from "lodash/isEqual";
import { isDevelopment } from "../../common/vars"; import { isDevelopment } from "../../common/vars";
import url from "url"; import url from "url";
import { makeObservable, observable } from "mobx"; import { makeObservable, observable } from "mobx";
import type { ParsedUrlQueryInput } from "querystring"; import { ipcRenderer } from "electron";
export enum TerminalChannels { export enum TerminalChannels {
STDIN = 0, STDIN = 0,
@ -50,7 +50,7 @@ enum TerminalColor {
export type TerminalApiQuery = Record<string, string> & { export type TerminalApiQuery = Record<string, string> & {
id: string; id: string;
node?: string; node?: string;
type?: string | "node"; type?: string;
}; };
export class TerminalApi extends WebSocketApi { export class TerminalApi extends WebSocketApi {
@ -58,9 +58,8 @@ export class TerminalApi extends WebSocketApi {
public onReady = new EventEmitter<[]>(); public onReady = new EventEmitter<[]>();
@observable public isReady = false; @observable public isReady = false;
public readonly url: string;
constructor(protected options: TerminalApiQuery) { constructor(protected query: TerminalApiQuery) {
super({ super({
logging: isDevelopment, logging: isDevelopment,
flushOnOpen: false, flushOnOpen: false,
@ -68,30 +67,41 @@ export class TerminalApi extends WebSocketApi {
}); });
makeObservable(this); makeObservable(this);
const { hostname, protocol, port } = location; if (query.node) {
const query: ParsedUrlQueryInput = { query.type ||= "node";
id: options.id, }
}; }
if (options.node) { async connect() {
query.node = options.node; if (!this.socket) {
query.type = options.type || "node"; /**
* Only emit this message if we are not "reconnecting", so as to keep the
* output display clean when the computer wakes from sleep
*/
this.emitStatus("Connecting ...");
} }
this.url = url.format({ const authTokenArray = await ipcRenderer.invoke("cluster:shell-api", getHostedClusterId(), this.query.id);
if (!(authTokenArray instanceof Uint8Array)) {
throw new TypeError("ShellApi token is not a Uint8Array");
}
const { hostname, protocol, port } = location;
const socketUrl = url.format({
protocol: protocol.includes("https") ? "wss" : "ws", protocol: protocol.includes("https") ? "wss" : "ws",
hostname, hostname,
port, port,
pathname: "/api", pathname: "/api",
query, query: {
...this.query,
shellToken: Buffer.from(authTokenArray).toString("base64"),
},
slashes: true, slashes: true,
}); });
}
connect() {
this.emitStatus("Connecting ...");
this.onData.addListener(this._onReady, { prepend: true }); this.onData.addListener(this._onReady, { prepend: true });
super.connect(this.url); super.connect(socketUrl);
} }
destroy() { destroy() {

View File

@ -37,11 +37,11 @@ interface IMessage {
} }
export enum WebSocketApiState { export enum WebSocketApiState {
PENDING = -1, PENDING = "pending",
OPEN, OPEN = "open",
CONNECTING, CONNECTING = "connecting",
RECONNECTING, RECONNECTING = "reconnecting",
CLOSED, CLOSED = "closed",
} }
export class WebSocketApi { export class WebSocketApi {

View File

@ -49,9 +49,9 @@ const LargeTooltip = withStyles({
@observer @observer
export class HelmChartDetails extends Component<Props> { export class HelmChartDetails extends Component<Props> {
@observable chartVersions: HelmChart[]; @observable chartVersions: HelmChart[];
@observable selectedChart: HelmChart; @observable selectedChart?: HelmChart;
@observable readme: string = null; @observable readme?: string;
@observable error: string = null; @observable error?: string;
private abortController?: AbortController; private abortController?: AbortController;
@ -68,6 +68,10 @@ export class HelmChartDetails extends Component<Props> {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => this.props.chart, async ({ name, repo, version }) => { reaction(() => this.props.chart, async ({ name, repo, version }) => {
try { try {
this.selectedChart = undefined;
this.chartVersions = undefined;
this.readme = undefined;
const { readme, versions } = await getChartDetails(repo, name, { version }); const { readme, versions } = await getChartDetails(repo, name, { version });
this.readme = readme; this.readme = readme;

View File

@ -26,12 +26,9 @@ import type { ItemObject } from "../../../common/item.store";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { searchUrlParam } from "../input"; import { searchUrlParam } from "../input";
import { makeCss } from "../../../common/utils/makeCss";
import { KubeObject } from "../../../common/k8s-api/kube-object"; import { KubeObject } from "../../../common/k8s-api/kube-object";
import type { CatalogEntityRegistry } from "../../api/catalog-entity-registry"; import type { CatalogEntityRegistry } from "../../api/catalog-entity-registry";
const css = makeCss(styles);
export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject { export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject {
constructor(public entity: T, private registry: CatalogEntityRegistry) { constructor(public entity: T, private registry: CatalogEntityRegistry) {
if (!(entity instanceof CatalogEntity)) { if (!(entity instanceof CatalogEntity)) {
@ -79,7 +76,7 @@ export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject {
return this.labels return this.labels
.map(label => ( .map(label => (
<Badge <Badge
className={css.badge} className={styles.badge}
key={label} key={label}
label={label} label={label}
title={label} title={label}

View File

@ -19,59 +19,77 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
.list :global(.TableRow) { .Catalog {
.entityName { :global(.TableHead) .entityName {
position: relative; /* offset for icon to align texts in the column */
width: min-content; padding-left: calc(21px + var(--padding) * 2.5);
max-width: 100%; }
:global(.TableRow):hover .pinIcon {
display: block;
}
}
.entityName {
position: relative;
width: 100%;
max-width: 100%;
display: flex;
align-items: center;
padding: 0 var(--padding);
padding-bottom: 0;
padding-right: 24px; // + reserved space for .pinIcon
> span {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
padding-right: 24px; padding-left: var(--padding);
}
.pinIcon { :global(.HotbarIcon){
position: absolute; align-self: center;
right: 0;
opacity: 0;
transition: none;
&:hover { div {
/* Drop styles defined for <Icon/> */ /* icons with plain text */
background-color: transparent; font-size: var(--unit);
box-shadow: none; }
}
.Icon {
/* icons with font-icon */
font-size: var(--small-size);
} }
} }
&:hover .pinIcon { .pinIcon {
opacity: 1; position: absolute;
right: 0;
transition: none;
display: none;
&:hover {
/* Drop styles defined for <Icon/> */
background-color: transparent;
box-shadow: none;
}
} }
} }
.iconCell {
@apply flex items-center max-w-[40px];
}
.iconCell > div * {
font-size: var(--unit);
}
.nameCell {
}
.sourceCell { .sourceCell {
max-width: 100px; max-width: 100px;
} }
.statusCell { .statusCell {
max-width: 100px; max-width: 100px;
}
.connected, .available { :global {
color: var(--colorSuccess); .connected, .available {
} color: var(--colorSuccess);
}
.disconnected, .deleting, .unavailable { .disconnected, .deleting, .unavailable {
color: var(--halfGray); color: var(--halfGray);
}
}
} }
.labelsCell { .labelsCell {

View File

@ -37,8 +37,7 @@ import { CatalogAddButton } from "./catalog-add-button";
import type { RouteComponentProps } from "react-router"; import type { RouteComponentProps } from "react-router";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { MainLayout } from "../layout/main-layout"; import { MainLayout } from "../layout/main-layout";
import { createStorage, cssNames, prevDefault } from "../../utils"; import { createStorage, prevDefault } from "../../utils";
import { makeCss } from "../../../common/utils/makeCss";
import { CatalogEntityDetails } from "./catalog-entity-details"; import { CatalogEntityDetails } from "./catalog-entity-details";
import { browseCatalogTab, catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { browseCatalogTab, catalogURL, CatalogViewRouteParam } from "../../../common/routes";
import { CatalogMenu } from "./catalog-menu"; import { CatalogMenu } from "./catalog-menu";
@ -56,8 +55,6 @@ enum sortBy {
status = "status", status = "status",
} }
const css = makeCss(styles);
interface Props extends RouteComponentProps<CatalogViewRouteParam> { interface Props extends RouteComponentProps<CatalogViewRouteParam> {
catalogEntityStore?: CatalogEntityStore; catalogEntityStore?: CatalogEntityStore;
} }
@ -73,6 +70,7 @@ export class Catalog extends React.Component<Props> {
makeObservable(this); makeObservable(this);
this.catalogEntityStore = props.catalogEntityStore; this.catalogEntityStore = props.catalogEntityStore;
} }
static defaultProps = { static defaultProps = {
catalogEntityStore: new CatalogEntityStore(), catalogEntityStore: new CatalogEntityStore(),
}; };
@ -105,7 +103,7 @@ export class Catalog extends React.Component<Props> {
this.activeTab = routeTab; this.activeTab = routeTab;
this.catalogEntityStore.activeCategory = item; this.catalogEntityStore.activeCategory = item;
}); });
} catch(error) { } catch (error) {
console.error(error); console.error(error);
Notifications.error(<p>Unknown category: {routeTab}</p>); Notifications.error(<p>Unknown category: {routeTab}</p>);
} }
@ -177,7 +175,7 @@ export class Catalog extends React.Component<Props> {
renderNavigation() { renderNavigation() {
return ( return (
<CatalogMenu activeItem={this.activeTab} onItemClick={this.onTabChange}/> <CatalogMenu activeItem={this.activeTab} onItemClick={this.onTabChange} />
); );
} }
@ -214,23 +212,7 @@ export class Catalog extends React.Component<Props> {
const isItemInHotbar = HotbarStore.getInstance().isAddedToActive(item.entity); const isItemInHotbar = HotbarStore.getInstance().isAddedToActive(item.entity);
return ( return (
<div className={styles.entityName}> <>
{item.name}
<Icon
small
className={styles.pinIcon}
material={!isItemInHotbar && "push_pin"}
svg={isItemInHotbar && "push_off"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={prevDefault(() => isItemInHotbar ? this.removeFromHotbar(item) : this.addToHotbar(item))}
/>
</div>
);
}
renderIcon(item: CatalogEntityItem<CatalogEntity>) {
return (
<RenderDelay>
<HotbarIcon <HotbarIcon
uid={`catalog-icon-${item.getId()}`} uid={`catalog-icon-${item.getId()}`}
title={item.getName()} title={item.getName()}
@ -240,7 +222,16 @@ export class Catalog extends React.Component<Props> {
background={item.entity.spec.icon?.background} background={item.entity.spec.icon?.background}
size={24} size={24}
/> />
</RenderDelay> <span>{item.name}</span>
<Icon
small
className={styles.pinIcon}
material={!isItemInHotbar && "push_pin"}
svg={isItemInHotbar ? "push_off" : "push_pin"}
tooltip={isItemInHotbar ? "Remove from Hotbar" : "Add to Hotbar"}
onClick={prevDefault(() => isItemInHotbar ? this.removeFromHotbar(item) : this.addToHotbar(item))}
/>
</>
); );
} }
@ -254,11 +245,11 @@ export class Catalog extends React.Component<Props> {
return ( return (
<ItemListLayout <ItemListLayout
className={styles.Catalog}
tableId={tableId} tableId={tableId}
renderHeaderTitle={activeCategory?.metadata.name || "Browse All"} renderHeaderTitle={activeCategory?.metadata.name || "Browse All"}
isSelectable={false} isSelectable={false}
isConfigurable={true} isConfigurable={true}
className={styles.list}
store={this.catalogEntityStore} store={this.catalogEntityStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: item => item.name, [sortBy.name]: item => item.name,
@ -270,23 +261,21 @@ export class Catalog extends React.Component<Props> {
entity => entity.searchFields, entity => entity.searchFields,
]} ]}
renderTableHeader={[ renderTableHeader={[
{ title: "", className: css.iconCell, id: "icon" }, { title: "Name", className: styles.entityName, sortBy: sortBy.name, id: "name" },
{ title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" }, !activeCategory && { title: "Kind", sortBy: sortBy.kind, id: "kind" },
!activeCategory && { title: "Kind", className: css.kindCell, sortBy: sortBy.kind, id: "kind" }, { title: "Source", className: styles.sourceCell, sortBy: sortBy.source, id: "source" },
{ title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" }, { title: "Labels", className: styles.labelsCell, id: "labels" },
{ title: "Labels", className: css.labelsCell, id: "labels" }, { title: "Status", className: styles.statusCell, sortBy: sortBy.status, id: "status" },
{ title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" },
].filter(Boolean)} ].filter(Boolean)}
customizeTableRowProps={item => ({ customizeTableRowProps={item => ({
disabled: !item.enabled, disabled: !item.enabled,
})} })}
renderTableContents={item => [ renderTableContents={item => [
this.renderIcon(item),
this.renderName(item), this.renderName(item),
!activeCategory && item.kind, !activeCategory && item.kind,
item.source, item.source,
item.getLabelBadges(), item.getLabelBadges(),
{ title: item.phase, className: cssNames(css[item.phase]) }, <span key="phase" className={item.phase}>{item.phase}</span>,
].filter(Boolean)} ].filter(Boolean)}
onDetails={this.onDetails} onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu} renderItemMenu={this.renderItemMenu}
@ -302,7 +291,7 @@ export class Catalog extends React.Component<Props> {
return ( return (
<MainLayout sidebar={this.renderNavigation()}> <MainLayout sidebar={this.renderNavigation()}>
<div className="p-6 h-full"> <div className="p-6 h-full">
{ this.renderList() } {this.renderList()}
</div> </div>
{ {
this.catalogEntityStore.selectedItem this.catalogEntityStore.selectedItem

View File

@ -50,11 +50,14 @@ interface Props extends RouteComponentProps<HpaRouteParams> {
export class HorizontalPodAutoscalers extends React.Component<Props> { export class HorizontalPodAutoscalers extends React.Component<Props> {
getTargets(hpa: HorizontalPodAutoscaler) { getTargets(hpa: HorizontalPodAutoscaler) {
const metrics = hpa.getMetrics(); const metrics = hpa.getMetrics();
const metricsRemainCount = metrics.length - 1;
const metricsRemain = metrics.length > 1 ? <>{metricsRemainCount} more...</> : null;
const metricValues = hpa.getMetricValues(metrics[0]);
return <p>{metricValues} {metricsRemain && "+"}{metricsRemain}</p>; if (metrics.length === 0) {
return <p>--</p>;
}
const metricsRemain = metrics.length > 1 ? `+${metrics.length - 1} more...` : "";
return <p>{hpa.getMetricValues(metrics[0])} {metricsRemain}</p>;
} }
render() { render() {

View File

@ -28,7 +28,7 @@ import { observable, makeObservable, reaction } from "mobx";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Button } from "../button"; import { Button } from "../button";
import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward"; import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward";
import type { ForwardedPort } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
@ -93,12 +93,20 @@ export class ServicePortComponent extends React.Component<Props> {
this.waiting = true; this.waiting = true;
try { try {
// determine how many port-forwards are already active
const { length } = await getPortForwards();
this.forwardPort = await addPortForward(portForward); this.forwardPort = await addPortForward(portForward);
if (this.forwardPort) { if (this.forwardPort) {
portForward.forwardPort = this.forwardPort; portForward.forwardPort = this.forwardPort;
openPortForward(portForward); openPortForward(portForward);
this.isPortForwarded = true; this.isPortForwarded = true;
// if this is the first port-forward show the about notification
if (!length) {
aboutPortForwarding();
}
} }
} catch (error) { } catch (error) {
Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);

View File

@ -28,7 +28,7 @@ import { observable, makeObservable, reaction } from "mobx";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Button } from "../button"; import { Button } from "../button";
import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward"; import { aboutPortForwarding, addPortForward, getPortForward, getPortForwards, openPortForward, PortForwardDialog, portForwardStore, predictProtocol, removePortForward } from "../../port-forward";
import type { ForwardedPort } from "../../port-forward"; import type { ForwardedPort } from "../../port-forward";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
@ -97,12 +97,20 @@ export class PodContainerPort extends React.Component<Props> {
this.waiting = true; this.waiting = true;
try { try {
// determine how many port-forwards are already active
const { length } = await getPortForwards();
this.forwardPort = await addPortForward(portForward); this.forwardPort = await addPortForward(portForward);
if (this.forwardPort) { if (this.forwardPort) {
portForward.forwardPort = this.forwardPort; portForward.forwardPort = this.forwardPort;
openPortForward(portForward); openPortForward(portForward);
this.isPortForwarded = true; this.isPortForwarded = true;
// if this is the first port-forward show the about notification
if (!length) {
aboutPortForwarding();
}
} }
} catch (error) { } catch (error) {
Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`); Notifications.error(`Error occurred starting port-forward, the local port may not be available or the ${portForward.kind} ${portForward.name} may not be reachable`);

View File

@ -69,7 +69,7 @@ export class Pods extends React.Component<Props> {
formatters: { formatters: {
tableView: true, tableView: true,
}, },
children: Object.keys(state).map(status => ( children: Object.keys(state).map((status: keyof typeof state) => (
<Fragment key={status}> <Fragment key={status}>
<div className="title"> <div className="title">
{name} <span className="text-secondary">({status}{ready ? ", ready" : ""})</span> {name} <span className="text-secondary">({status}{ready ? ", ready" : ""})</span>

View File

@ -25,7 +25,7 @@ import { computed, makeObservable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import type { RouteComponentProps } from "react-router"; import type { RouteComponentProps } from "react-router";
import { ClusterStatus } from "./cluster-status"; import { ClusterStatus } from "./cluster-status";
import { hasLoadedView, initView, refreshViews } from "./lens-views"; import { ClusterFrameHandler } from "./lens-views";
import type { Cluster } from "../../../main/cluster"; import type { Cluster } from "../../../main/cluster";
import { ClusterStore } from "../../../common/cluster-store"; import { ClusterStore } from "../../../common/cluster-store";
import { requestMain } from "../../../common/ipc"; import { requestMain } from "../../../common/ipc";
@ -57,7 +57,7 @@ export class ClusterView extends React.Component<Props> {
@computed get isReady(): boolean { @computed get isReady(): boolean {
const { cluster, clusterId } = this; const { cluster, clusterId } = this;
return cluster?.ready && cluster?.available && hasLoadedView(clusterId); return cluster?.ready && cluster?.available && ClusterFrameHandler.getInstance().hasLoadedView(clusterId);
} }
componentDidMount() { componentDidMount() {
@ -65,25 +65,23 @@ export class ClusterView extends React.Component<Props> {
} }
componentWillUnmount() { componentWillUnmount() {
refreshViews(); ClusterFrameHandler.getInstance().clearVisibleCluster();
catalogEntityRegistry.activeEntity = null; catalogEntityRegistry.activeEntity = null;
} }
bindEvents() { bindEvents() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => this.clusterId, async (clusterId) => { reaction(() => this.clusterId, async (clusterId) => {
refreshViews(clusterId); // refresh visibility of active cluster ClusterFrameHandler.getInstance().setVisibleCluster(clusterId);
initView(clusterId); // init cluster-view (iframe), requires parent container #lens-views to be in DOM ClusterFrameHandler.getInstance().initView(clusterId);
requestMain(clusterActivateHandler, clusterId, false); // activate and fetch cluster's state from main requestMain(clusterActivateHandler, clusterId, false); // activate and fetch cluster's state from main
catalogEntityRegistry.activeEntity = catalogEntityRegistry.getById(clusterId); catalogEntityRegistry.activeEntity = clusterId;
}, { }, {
fireImmediately: true, fireImmediately: true,
}), }),
reaction(() => [this.cluster?.ready, this.cluster?.disconnected], (values) => { reaction(() => [this.cluster?.ready, this.cluster?.disconnected], ([, disconnected]) => {
const disconnected = values[1]; if (ClusterFrameHandler.getInstance().hasLoadedView(this.clusterId) && disconnected) {
if (hasLoadedView(this.clusterId) && disconnected) {
navigate(catalogURL()); // redirect to catalog when active cluster get disconnected/not available navigate(catalogURL()); // redirect to catalog when active cluster get disconnected/not available
} }
}), }),

View File

@ -19,82 +19,117 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { observable, when } from "mobx"; import { action, IReactionDisposer, makeObservable, observable, reaction, when } from "mobx";
import logger from "../../../main/logger"; import logger from "../../../main/logger";
import { requestMain } from "../../../common/ipc";
import { clusterVisibilityHandler } from "../../../common/cluster-ipc"; import { clusterVisibilityHandler } from "../../../common/cluster-ipc";
import { ClusterStore } from "../../../common/cluster-store"; import { ClusterStore } from "../../../common/cluster-store";
import type { ClusterId } from "../../../common/cluster-types"; import type { ClusterId } from "../../../common/cluster-types";
import { getClusterFrameUrl } from "../../utils"; import { getClusterFrameUrl, Singleton } from "../../utils";
import { ipcRenderer } from "electron";
export interface LensView { export interface LensView {
isLoaded?: boolean isLoaded: boolean;
clusterId: ClusterId; frame: HTMLIFrameElement;
view: HTMLIFrameElement
} }
export const lensViews = observable.map<ClusterId, LensView>(); export class ClusterFrameHandler extends Singleton {
private views = observable.map<string, LensView>();
@observable private visibleCluster: string | null = null;
export function hasLoadedView(clusterId: ClusterId): boolean { constructor() {
return !!lensViews.get(clusterId)?.isLoaded; super();
} makeObservable(this);
reaction(() => this.visibleCluster, this.handleVisibleClusterChange);
export async function initView(clusterId: ClusterId) {
const cluster = ClusterStore.getInstance().getById(clusterId);
if (!cluster || lensViews.has(clusterId)) {
return;
} }
logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`); public hasLoadedView(clusterId: string): boolean {
const parentElem = document.getElementById("lens-views"); return Boolean(this.views.get(clusterId)?.isLoaded);
const iframe = document.createElement("iframe");
iframe.id = `cluster-frame-${cluster.id}`;
iframe.name = cluster.contextName;
iframe.setAttribute("src", getClusterFrameUrl(clusterId));
iframe.addEventListener("load", () => {
logger.info(`[LENS-VIEW]: loaded from ${iframe.src}`);
lensViews.get(clusterId).isLoaded = true;
}, { once: true });
lensViews.set(clusterId, { clusterId, view: iframe });
parentElem.appendChild(iframe);
logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`);
try {
await when(() => cluster.ready, { timeout: 5_000 }); // we cannot wait forever because cleanup would be blocked for broken cluster connections
logger.info(`[LENS-VIEW]: cluster is ready, clusterId=${clusterId}`);
} finally {
await autoCleanOnRemove(clusterId, iframe);
} }
}
export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrameElement) { @action
await when(() => { public initView(clusterId: ClusterId) {
const cluster = ClusterStore.getInstance().getById(clusterId); const cluster = ClusterStore.getInstance().getById(clusterId);
return !cluster || (cluster.disconnected && lensViews.get(clusterId)?.isLoaded); if (!cluster || this.views.has(clusterId)) {
}); return;
logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`); }
lensViews.delete(clusterId);
iframe.parentNode.removeChild(iframe); logger.info(`[LENS-VIEW]: init dashboard, clusterId=${clusterId}`);
} const parentElem = document.getElementById("lens-views");
const iframe = document.createElement("iframe");
export function refreshViews(visibleClusterId?: string) {
logger.info(`[LENS-VIEW]: refreshing iframe views, visible cluster id=${visibleClusterId}`); iframe.id = `cluster-frame-${cluster.id}`;
const cluster = ClusterStore.getInstance().getById(visibleClusterId); iframe.name = cluster.contextName;
iframe.style.display = "none";
lensViews.forEach(({ clusterId, view, isLoaded }) => { iframe.setAttribute("src", getClusterFrameUrl(clusterId));
const isCurrent = clusterId === cluster?.id; iframe.addEventListener("load", () => {
const isReady = cluster?.available && cluster?.ready; logger.info(`[LENS-VIEW]: loaded from ${iframe.src}`);
const isVisible = isCurrent && isLoaded && isReady; this.views.get(clusterId).isLoaded = true;
}, { once: true });
view.style.display = isVisible ? "flex" : "none"; this.views.set(clusterId, { frame: iframe, isLoaded: false });
parentElem.appendChild(iframe);
requestMain(clusterVisibilityHandler, clusterId, isVisible).catch(() => {
logger.error(`[LENS-VIEW]: failed to set cluster visibility, clusterId=${clusterId}`); logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`);
});
}); const dispose = when(
() => cluster.ready,
() => logger.info(`[LENS-VIEW]: cluster is ready, clusterId=${clusterId}`),
);
when(
// cluster.disconnect is set to `false` when the cluster starts to connect
() => !cluster.disconnected,
() => {
when(
() => {
const cluster = ClusterStore.getInstance().getById(clusterId);
return !cluster || (cluster.disconnected && this.views.get(clusterId)?.isLoaded);
},
() => {
logger.info(`[LENS-VIEW]: remove dashboard, clusterId=${clusterId}`);
this.views.delete(clusterId);
iframe.parentNode.removeChild(iframe);
dispose();
},
);
},
);
}
public setVisibleCluster(clusterId: ClusterId) {
this.visibleCluster = clusterId;
}
public clearVisibleCluster() {
this.visibleCluster = null;
}
private prevVisibleClusterChange?: IReactionDisposer;
private handleVisibleClusterChange = (clusterId: ClusterId | undefined) => {
logger.info(`[LENS-VIEW]: refreshing iframe views, visible cluster id=${clusterId}`);
ipcRenderer.send(clusterVisibilityHandler);
const cluster = ClusterStore.getInstance().getById(clusterId);
for (const { frame: view } of this.views.values()) {
view.style.display = "none";
}
if (cluster) {
const lensView = this.views.get(clusterId);
this.prevVisibleClusterChange?.();
this.prevVisibleClusterChange = when(
() => cluster.available && cluster.ready && lensView.isLoaded,
() => {
logger.info(`[LENS-VIEW]: cluster id=${clusterId} should now be visible`);
lensView.frame.style.display = "flex";
ipcRenderer.send(clusterVisibilityHandler, clusterId);
},
);
}
};
} }

View File

@ -53,7 +53,7 @@ export class TerminalWindow extends React.Component<Props> {
} }
activate(tabId: TabId) { activate(tabId: TabId) {
if (this.terminal) this.terminal.detach(); // detach previous this.terminal?.detach(); // detach previous
this.terminal = TerminalStore.getInstance().getTerminal(tabId); this.terminal = TerminalStore.getInstance().getTerminal(tabId);
this.terminal.attachTo(this.elem); this.terminal.attachTo(this.elem);
} }

View File

@ -26,16 +26,15 @@ import { FitAddon } from "xterm-addon-fit";
import { dockStore, TabId } from "./dock.store"; import { dockStore, TabId } from "./dock.store";
import type { TerminalApi } from "../../api/terminal-api"; import type { TerminalApi } from "../../api/terminal-api";
import { ThemeStore } from "../../theme.store"; import { ThemeStore } from "../../theme.store";
import { boundMethod } from "../../utils"; import { boundMethod, disposer } from "../../utils";
import { isMac } from "../../../common/vars"; import { isMac } from "../../../common/vars";
import { camelCase } from "lodash"; import { camelCase } from "lodash";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { clipboard } from "electron"; import { clipboard } from "electron";
import logger from "../../../common/logger";
export class Terminal { export class Terminal {
static spawningPool: HTMLElement; public static readonly spawningPool = (() => {
static init() {
// terminal element must be in DOM before attaching via xterm.open(elem) // terminal element must be in DOM before attaching via xterm.open(elem)
// https://xtermjs.org/docs/api/terminal/classes/terminal/#open // https://xtermjs.org/docs/api/terminal/classes/terminal/#open
const pool = document.createElement("div"); const pool = document.createElement("div");
@ -43,8 +42,9 @@ export class Terminal {
pool.className = "terminal-init"; pool.className = "terminal-init";
pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden"; pool.style.cssText = "position: absolute; top: 0; left: 0; height: 0; visibility: hidden; overflow: hidden";
document.body.appendChild(pool); document.body.appendChild(pool);
Terminal.spawningPool = pool;
} return pool;
})();
static async preloadFonts() { static async preloadFonts() {
const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires const fontPath = require("../fonts/roboto-mono-nerd.ttf").default; // eslint-disable-line @typescript-eslint/no-var-requires
@ -54,13 +54,22 @@ export class Terminal {
document.fonts.add(fontFace); document.fonts.add(fontFace);
} }
public xterm: XTerm; private xterm: XTerm | null = new XTerm({
public fitAddon: FitAddon; cursorBlink: true,
public scrollPos = 0; cursorStyle: "bar",
public disposers: Function[] = []; fontSize: 13,
fontFamily: "RobotoMono",
});
private readonly fitAddon = new FitAddon();
private scrollPos = 0;
private disposer = disposer();
@boundMethod @boundMethod
protected setTheme(colors: Record<string, string>) { protected setTheme(colors: Record<string, string>) {
if (!this.xterm) {
return;
}
// Replacing keys stored in styles to format accepted by terminal // Replacing keys stored in styles to format accepted by terminal
// E.g. terminalBrightBlack -> brightBlack // E.g. terminalBrightBlack -> brightBlack
const colorPrefix = "terminal"; const colorPrefix = "terminal";
@ -73,17 +82,13 @@ export class Terminal {
} }
get elem() { get elem() {
return this.xterm.element; return this.xterm?.element;
} }
get viewport() { get viewport() {
return this.xterm.element.querySelector(".xterm-viewport"); return this.xterm.element.querySelector(".xterm-viewport");
} }
constructor(public tabId: TabId, protected api: TerminalApi) {
this.init();
}
get isActive() { get isActive() {
const { isOpen, selectedTabId } = dockStore; const { isOpen, selectedTabId } = dockStore;
@ -96,22 +101,15 @@ export class Terminal {
} }
detach() { detach() {
Terminal.spawningPool.appendChild(this.elem); const { elem } = this;
if (elem) {
Terminal.spawningPool.appendChild(elem);
}
} }
async init() { constructor(public tabId: TabId, protected api: TerminalApi) {
if (this.xterm) {
return;
}
this.xterm = new XTerm({
cursorBlink: true,
cursorStyle: "bar",
fontSize: 13,
fontFamily: "RobotoMono",
});
// enable terminal addons // enable terminal addons
this.fitAddon = new FitAddon();
this.xterm.loadAddon(this.fitAddon); this.xterm.loadAddon(this.fitAddon);
this.xterm.open(Terminal.spawningPool); this.xterm.open(Terminal.spawningPool);
@ -128,7 +126,7 @@ export class Terminal {
this.api.onData.addListener(this.onApiData); this.api.onData.addListener(this.onApiData);
window.addEventListener("resize", this.onResize); window.addEventListener("resize", this.onResize);
this.disposers.push( this.disposer.push(
reaction(() => ThemeStore.getInstance().activeTheme.colors, this.setTheme, { reaction(() => ThemeStore.getInstance().activeTheme.colors, this.setTheme, {
fireImmediately: true, fireImmediately: true,
}), }),
@ -142,16 +140,18 @@ export class Terminal {
} }
destroy() { destroy() {
if (!this.xterm) return; if (this.xterm) {
this.disposers.forEach(dispose => dispose()); this.disposer();
this.disposers = []; this.xterm.dispose();
this.xterm.dispose(); this.xterm = null;
this.xterm = null; }
} }
fit = () => { fit = () => {
// Since this function is debounced we need to read this value as late as possible // Since this function is debounced we need to read this value as late as possible
if (!this.isActive) return; if (!this.isActive || !this.xterm) {
return;
}
try { try {
this.fitAddon.fit(); this.fitAddon.fit();
@ -159,9 +159,8 @@ export class Terminal {
this.api.sendTerminalSize(cols, rows); this.api.sendTerminalSize(cols, rows);
} catch (error) { } catch (error) {
console.error(error); // see https://github.com/lensapp/lens/issues/1891
logger.error(`[TERMINAL]: failed to resize terminal to fit`, error);
return; // see https://github.com/lensapp/lens/issues/1891
} }
}; };
@ -204,19 +203,21 @@ export class Terminal {
}; };
onContextMenu = () => { onContextMenu = () => {
const { terminalCopyOnSelect } = UserStore.getInstance(); if (
const textFromClipboard = clipboard.readText(); // don't paste if user hasn't turned on the feature
UserStore.getInstance().terminalCopyOnSelect
if (terminalCopyOnSelect) { // don't paste if the clipboard doesn't have text
this.xterm.paste(textFromClipboard); && clipboard.availableFormats().includes("text/plain")
) {
this.xterm.paste(clipboard.readText());
} }
}; };
onSelectionChange = () => { onSelectionChange = () => {
const { terminalCopyOnSelect } = UserStore.getInstance();
const selection = this.xterm.getSelection().trim(); const selection = this.xterm.getSelection().trim();
if (terminalCopyOnSelect && selection !== "") { if (UserStore.getInstance().terminalCopyOnSelect && selection) {
clipboard.writeText(selection); clipboard.writeText(selection);
} }
}; };
@ -251,5 +252,3 @@ export class Terminal {
return true; return true;
}; };
} }
Terminal.init();

View File

Before

Width:  |  Height:  |  Size: 959 B

After

Width:  |  Height:  |  Size: 959 B

View File

@ -0,0 +1,74 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import { render, fireEvent } from "@testing-library/react";
import { SidebarCluster } from "../sidebar-cluster";
import { KubernetesCluster } from "../../../../common/catalog-entities";
jest.mock("../../../../common/hotbar-store", () => ({
HotbarStore: {
getInstance: () => ({
isAddedToActive: jest.fn(),
}),
},
}));
const clusterEntity = new KubernetesCluster({
metadata: {
uid: "test-uid",
name: "test-cluster",
source: "local",
labels: {},
},
spec: {
kubeconfigPath: "",
kubeconfigContext: "",
},
status: {
phase: "connected",
},
});
describe("<SidebarCluster/>", () => {
it("renders w/o errors", () => {
const { container } = render(<SidebarCluster clusterEntity={clusterEntity}/>);
expect(container).toBeInstanceOf(HTMLElement);
});
it("renders cluster avatar and name", () => {
const { getByText } = render(<SidebarCluster clusterEntity={clusterEntity}/>);
expect(getByText("tc")).toBeInTheDocument();
expect(getByText("test-cluster")).toBeInTheDocument();
});
it("renders cluster menu", async () => {
const { getByTestId, getByText } = render(<SidebarCluster clusterEntity={clusterEntity}/>);
const link = getByTestId("sidebar-cluster-dropdown");
fireEvent.click(link);
expect(await getByText("Add to Hotbar")).toBeInTheDocument();
});
});

View File

@ -19,12 +19,39 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
/** .SidebarCluster {
* Function expands generic CSS Modules literal types and adds dictionary with arbitrary display: flex;
* indexes. align-items: center;
* @param styles Styles imported from CSS Module having only literal types padding: 1.25rem;
* @returns Passed style list with expanded typescript types cursor: pointer;
*/
export function makeCss<T>(styles: T) { &:focus-visible {
return styles as typeof styles & { [key: string]: string }; background: var(--blue);
color: white;
}
&:hover {
background: var(--sidebarLogoBackground);
}
} }
.clusterName {
font-weight: bold;
overflow: hidden;
word-break: break-word;
color: var(--textColorAccent);
display: -webkit-box;
/* Simulate text-overflow:ellipsis styles but for multiple text lines */
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.menu {
width: 185px;
margin-top: -10px;
}
.avatar {
font-weight: 500;
margin-right: 1.25rem;
}

View File

@ -0,0 +1,140 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import styles from "./sidebar-cluster.module.css";
import { observable } from "mobx";
import React, { useState } from "react";
import { HotbarStore } from "../../../common/hotbar-store";
import { broadcastMessage } from "../../../common/ipc";
import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import { IpcRendererNavigationEvents } from "../../navigation/events";
import { Avatar } from "../avatar/avatar";
import { Icon } from "../icon";
import { navigate } from "../../navigation";
import { Menu, MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog";
const contextMenu: CatalogEntityContextMenuContext = observable({
menuItems: [],
navigate: (url: string, forceMainFrame = true) => {
if (forceMainFrame) {
broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url);
} else {
navigate(url);
}
},
});
function onMenuItemClick(menuItem: CatalogEntityContextMenu) {
if (menuItem.confirm) {
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
},
ok: () => {
menuItem.onClick();
},
message: menuItem.confirm.message,
});
} else {
menuItem.onClick();
}
}
export function SidebarCluster({ clusterEntity }: { clusterEntity: CatalogEntity }) {
const [opened, setOpened] = useState(false);
if (!clusterEntity) {
return null;
}
const onMenuOpen = () => {
const hotbarStore = HotbarStore.getInstance();
const isAddedToActive = HotbarStore.getInstance().isAddedToActive(clusterEntity);
const title = isAddedToActive
? "Remove from Hotbar"
: "Add to Hotbar";
const onClick = isAddedToActive
? () => hotbarStore.removeFromHotbar(metadata.uid)
: () => hotbarStore.addToHotbar(clusterEntity);
contextMenu.menuItems = [{ title, onClick }];
clusterEntity.onContextMenuOpen(contextMenu);
toggle();
};
const onKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
if (evt.code == "Space") {
toggle();
}
};
const toggle = () => {
setOpened(!opened);
};
const { metadata, spec } = clusterEntity;
const id = `cluster-${metadata.uid}`;
return (
<div
id={id}
className={styles.SidebarCluster}
tabIndex={0}
onKeyDown={onKeyDown}
role="menubar"
data-testid="sidebar-cluster-dropdown"
>
<Avatar
title={metadata.name}
colorHash={`${metadata.name}-${metadata.source}`}
width={40}
height={40}
src={spec.icon?.src}
className={styles.avatar}
/>
<div className={styles.clusterName}>
{metadata.name}
</div>
<Icon material="arrow_drop_down"/>
<Menu
usePortal
htmlFor={id}
isOpen={opened}
open={onMenuOpen}
closeOnClickItem
closeOnClickOutside
close={toggle}
className={styles.menu}
>
{
contextMenu.menuItems.map((menuItem) => (
<MenuItem key={menuItem.title} onClick={() => onMenuItemClick(menuItem)}>
{menuItem.title}
</MenuItem>
))
}
</Menu>
</div>
);
}

View File

@ -40,17 +40,3 @@
padding: 3px; padding: 3px;
border-radius: 50%; border-radius: 50%;
} }
.cluster {
@apply flex items-center m-5;
}
.clusterName {
@apply font-bold overflow-hidden;
word-break: break-word;
color: var(--textColorAccent);
display: -webkit-box;
/* Simulate text-overflow:ellipsis styles but for multiple text lines */
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}

View File

@ -32,7 +32,7 @@ import { Storage } from "../+storage";
import { Network } from "../+network"; import { Network } from "../+network";
import { crdStore } from "../+custom-resources/crd.store"; import { crdStore } from "../+custom-resources/crd.store";
import { CustomResources } from "../+custom-resources/custom-resources"; import { CustomResources } from "../+custom-resources/custom-resources";
import { isActiveRoute, navigate } from "../../navigation"; import { isActiveRoute } from "../../navigation";
import { isAllowedResource } from "../../../common/utils/allowed-resource"; import { isAllowedResource } from "../../../common/utils/allowed-resource";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { ClusterPageMenuRegistration, ClusterPageMenuRegistry, ClusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries"; import { ClusterPageMenuRegistration, ClusterPageMenuRegistry, ClusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries";
@ -41,12 +41,7 @@ import { Apps } from "../+apps";
import * as routes from "../../../common/routes"; import * as routes from "../../../common/routes";
import { Config } from "../+config"; import { Config } from "../+config";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { HotbarIcon } from "../hotbar/hotbar-icon"; import { SidebarCluster } from "./sidebar-cluster";
import { makeObservable, observable } from "mobx";
import type { CatalogEntityContextMenuContext } from "../../../common/catalog";
import { HotbarStore } from "../../../common/hotbar-store";
import { broadcastMessage } from "../../../common/ipc";
import { IpcRendererNavigationEvents } from "../../navigation/events";
interface Props { interface Props {
className?: string; className?: string;
@ -55,21 +50,6 @@ interface Props {
@observer @observer
export class Sidebar extends React.Component<Props> { export class Sidebar extends React.Component<Props> {
static displayName = "Sidebar"; static displayName = "Sidebar";
@observable private contextMenu: CatalogEntityContextMenuContext = {
menuItems: [],
navigate: (url: string, forceMainFrame = true) => {
if (forceMainFrame) {
broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, url);
} else {
navigate(url);
}
},
};
constructor(props: Props) {
super(props);
makeObservable(this);
}
async componentDidMount() { async componentDidMount() {
crdStore.reloadAll(); crdStore.reloadAll();
@ -198,44 +178,6 @@ export class Sidebar extends React.Component<Props> {
}); });
} }
renderCluster() {
if (!this.clusterEntity) {
return null;
}
const { metadata, spec } = this.clusterEntity;
return (
<div className={styles.cluster}>
<HotbarIcon
uid={metadata.uid}
title={metadata.name}
source={metadata.source}
src={spec.icon?.src}
className="mr-5"
onClick={() => navigate("/")}
menuItems={this.contextMenu.menuItems}
onMenuOpen={() => {
const hotbarStore = HotbarStore.getInstance();
const isAddedToActive = HotbarStore.getInstance().isAddedToActive(this.clusterEntity);
const title = isAddedToActive
? "Remove from Hotbar"
: "Add to Hotbar";
const onClick = isAddedToActive
? () => hotbarStore.removeFromHotbar(metadata.uid)
: () => hotbarStore.addToHotbar(this.clusterEntity);
this.contextMenu.menuItems = [{ title, onClick }];
this.clusterEntity.onContextMenuOpen(this.contextMenu);
}}
/>
<div className={styles.clusterName}>
{metadata.name}
</div>
</div>
);
}
get clusterEntity() { get clusterEntity() {
return catalogEntityRegistry.activeEntity; return catalogEntityRegistry.activeEntity;
} }
@ -245,7 +187,7 @@ export class Sidebar extends React.Component<Props> {
return ( return (
<div className={cssNames("flex flex-col", className)} data-testid="cluster-sidebar"> <div className={cssNames("flex flex-col", className)} data-testid="cluster-sidebar">
{this.renderCluster()} <SidebarCluster clusterEntity={this.clusterEntity}/>
<div className={styles.sidebarNav}> <div className={styles.sidebarNav}>
<SidebarItem <SidebarItem
id="cluster" id="cluster"

View File

@ -38,6 +38,7 @@ import { IpcRendererNavigationEvents } from "./navigation/events";
import { catalogEntityRegistry } from "./api/catalog-entity-registry"; import { catalogEntityRegistry } from "./api/catalog-entity-registry";
import logger from "../common/logger"; import logger from "../common/logger";
import { unmountComponentAtNode } from "react-dom"; import { unmountComponentAtNode } from "react-dom";
import { ClusterFrameHandler } from "./components/cluster-manager/lens-views";
injectSystemCAs(); injectSystemCAs();
@ -61,6 +62,12 @@ export class LensApp extends React.Component {
}; };
} }
constructor(props: {}) {
super(props);
ClusterFrameHandler.createInstance();
}
componentDidMount() { componentDidMount() {
ipcRenderer.send(IpcRendererNavigationEvents.LOADED); ipcRenderer.send(IpcRendererNavigationEvents.LOADED);
} }

View File

@ -22,4 +22,5 @@
export * from "./port-forward.store"; export * from "./port-forward.store";
export * from "./port-forward-item"; export * from "./port-forward-item";
export * from "./port-forward-dialog"; export * from "./port-forward-dialog";
export * from "./port-forward-notify";
export * from "./port-forward-utils"; export * from "./port-forward-utils";

View File

@ -29,9 +29,9 @@ import { Wizard, WizardStep } from "../components/wizard";
import { Input } from "../components/input"; import { Input } from "../components/input";
import { Notifications } from "../components/notifications"; import { Notifications } from "../components/notifications";
import { cssNames } from "../utils"; import { cssNames } from "../utils";
import { addPortForward, modifyPortForward } from "./port-forward.store"; import { addPortForward, getPortForwards, modifyPortForward } from "./port-forward.store";
import type { ForwardedPort } from "./port-forward-item"; import type { ForwardedPort } from "./port-forward-item";
import { openPortForward } from "."; import { aboutPortForwarding, openPortForward } from ".";
import { Checkbox } from "../components/checkbox"; import { Checkbox } from "../components/checkbox";
interface Props extends Partial<DialogProps> { interface Props extends Partial<DialogProps> {
@ -96,6 +96,9 @@ export class PortForwardDialog extends Component<Props> {
const { currentPort, desiredPort, close } = this; const { currentPort, desiredPort, close } = this;
try { try {
// determine how many port-forwards are already active
const { length } = await getPortForwards();
let port: number; let port: number;
portForward.protocol = dialogState.useHttps ? "https" : "http"; portForward.protocol = dialogState.useHttps ? "https" : "http";
@ -105,6 +108,11 @@ export class PortForwardDialog extends Component<Props> {
} else { } else {
portForward.forwardPort = desiredPort; portForward.forwardPort = desiredPort;
port = await addPortForward(portForward); port = await addPortForward(portForward);
// if this is the first port-forward show the about notification
if (!length) {
aboutPortForwarding();
}
} }
if (dialogState.openInBrowser) { if (dialogState.openInBrowser) {

View File

@ -0,0 +1,58 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { portForwardsURL } from "../../common/routes/port-forwards";
import { Button } from "../components/button";
import { Notifications, notificationsStore } from "../components/notifications";
import { navigate } from "../navigation";
import { getHostedClusterId } from "../utils";
export function aboutPortForwarding() {
const notificationId = `port-forward-notification-${getHostedClusterId()}`;
Notifications.info(
(
<div className="flex column gaps">
<b>Port Forwarding</b>
<p>
You can manage your port forwards on the Port Forwarding Page.
</p>
<div className="flex gaps row align-left box grow">
<Button
active
outlined
label="Go to Port Forwarding"
onClick={() => {
navigate(portForwardsURL());
notificationsStore.remove(notificationId);
}}
/>
</div>
</div>
),
{
id: notificationId,
timeout: 10_000,
},
);
}

View File

@ -47,7 +47,7 @@ export class PortForwardStore extends ItemStore<PortForwardItem> {
const savedPortForwards = this.storage.get(); // undefined on first load const savedPortForwards = this.storage.get(); // undefined on first load
if (Array.isArray(savedPortForwards)) { if (Array.isArray(savedPortForwards)) {
logger.info("[PORT_FORWARD] starting saved port-forwards"); logger.info("[PORT-FORWARD-STORE] starting saved port-forwards");
await Promise.all(savedPortForwards.map(addPortForward)); await Promise.all(savedPortForwards.map(addPortForward));
} }
} }
@ -108,11 +108,12 @@ export async function addPortForward(portForward: ForwardedPort): Promise<number
response = await apiBase.post<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}&protocol=${protocol}`); response = await apiBase.post<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}&protocol=${protocol}`);
if (response?.port && response.port != +portForward.forwardPort) { // expecting the received port to be the specified port, unless the specified port is 0, which indicates any available port is suitable
logger.warn(`specified ${portForward.forwardPort} got ${response.port}`); if (portForward.forwardPort && response?.port && response.port != +portForward.forwardPort) {
logger.warn(`[PORT-FORWARD-STORE] specified ${portForward.forwardPort} got ${response.port}`);
} }
} catch (error) { } catch (error) {
logger.warn("Error adding port-forward:", error, portForward); logger.warn("[PORT-FORWARD-STORE] Error adding port-forward:", error, portForward);
throw(error); throw(error);
} }
portForwardStore.reset(); portForwardStore.reset();
@ -134,7 +135,7 @@ export async function getPortForward(portForward: ForwardedPort): Promise<number
try { try {
response = await apiBase.get<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}${getProtocolQuery(portForward.protocol)}`); response = await apiBase.get<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}${getProtocolQuery(portForward.protocol)}`);
} catch (error) { } catch (error) {
logger.warn("Error getting port-forward:", error, portForward); logger.warn("[PORT-FORWARD-STORE] Error getting port-forward:", error, portForward);
throw(error); throw(error);
} }
@ -159,7 +160,7 @@ export async function removePortForward(portForward: ForwardedPort) {
await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}`); await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}`);
await waitUntilFree(+portForward.forwardPort, 200, 1000); await waitUntilFree(+portForward.forwardPort, 200, 1000);
} catch (error) { } catch (error) {
logger.warn("Error removing port-forward:", error, portForward); logger.warn("[PORT-FORWARD-STORE] Error removing port-forward:", error, portForward);
throw(error); throw(error);
} }
portForwardStore.reset(); portForwardStore.reset();
@ -171,7 +172,7 @@ export async function getPortForwards(): Promise<ForwardedPort[]> {
return response.portForwards; return response.portForwards;
} catch (error) { } catch (error) {
logger.warn("Error getting all port-forwards:", error); logger.warn("[PORT-FORWARD-STORE] Error getting all port-forwards:", error);
return []; return [];
} }

View File

@ -1051,7 +1051,7 @@
"@sentry/utils" "6.7.1" "@sentry/utils" "6.7.1"
tslib "^1.9.3" tslib "^1.9.3"
"@sentry/types@6.13.3", "@sentry/types@^6.13.3": "@sentry/types@6.13.3":
version "6.13.3" version "6.13.3"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.13.3.tgz#63ad5b6735b0dfd90b3a256a9f8e77b93f0f66b2" resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.13.3.tgz#63ad5b6735b0dfd90b3a256a9f8e77b93f0f66b2"
integrity sha512-Vrz5CdhaTRSvCQjSyIFIaV9PodjAVFkzJkTRxyY7P77RcegMsRSsG1yzlvCtA99zG9+e6MfoJOgbOCwuZids5A== integrity sha512-Vrz5CdhaTRSvCQjSyIFIaV9PodjAVFkzJkTRxyY7P77RcegMsRSsG1yzlvCtA99zG9+e6MfoJOgbOCwuZids5A==
@ -1061,6 +1061,11 @@
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.7.1.tgz#c8263e1886df5e815570c4668eb40a1cfaa1c88b" resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.7.1.tgz#c8263e1886df5e815570c4668eb40a1cfaa1c88b"
integrity sha512-9AO7HKoip2MBMNQJEd6+AKtjj2+q9Ze4ooWUdEvdOVSt5drg7BGpK221/p9JEOyJAZwEPEXdcMd3VAIMiOb4MA== integrity sha512-9AO7HKoip2MBMNQJEd6+AKtjj2+q9Ze4ooWUdEvdOVSt5drg7BGpK221/p9JEOyJAZwEPEXdcMd3VAIMiOb4MA==
"@sentry/types@^6.14.1":
version "6.14.1"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.14.1.tgz#0d562a7aa91253b7843723344b4ba03a010e6376"
integrity sha512-RIk3ZwQKZnASrYWfV5i4wbzVveHz8xLFAS2ySIMqh+hICKnB0N4/r8a1Of/84j7pj+iAbf5vPS85639eIf+9qg==
"@sentry/utils@6.13.3": "@sentry/utils@6.13.3":
version "6.13.3" version "6.13.3"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.13.3.tgz#188754d40afe693c3fcae410f9322531588a9926" resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.13.3.tgz#188754d40afe693c3fcae410f9322531588a9926"
@ -1601,10 +1606,10 @@
"@types/node" "*" "@types/node" "*"
form-data "^3.0.0" form-data "^3.0.0"
"@types/node@*", "@types/node@14.17.27", "@types/node@^14.6.2": "@types/node@*", "@types/node@14.17.33", "@types/node@^14.6.2":
version "14.17.27" version "14.17.33"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.27.tgz#5054610d37bb5f6e21342d0e6d24c494231f3b85" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.17.33.tgz#011ee28e38dc7aee1be032ceadf6332a0ab15b12"
integrity sha512-94+Ahf9IcaDuJTle/2b+wzvjmutxXAEXU6O81JHblYXUg2BDG+dnBy7VxIPHKAyEEDHzCMQydTJuWvrE+Aanzw== integrity sha512-noEeJ06zbn3lOh4gqe2v7NMGS33jrulfNqYFDjjEbhpDEHR5VTxgYNQSBqBlJIsBJW3uEYDgD6kvMnrrhGzq8g==
"@types/node@^10.12.0": "@types/node@^10.12.0":
version "10.17.24" version "10.17.24"
@ -1766,10 +1771,10 @@
dependencies: dependencies:
"@types/react" "*" "@types/react" "*"
"@types/react@*", "@types/react@^17.0.33": "@types/react@*", "@types/react@^17.0.34":
version "17.0.33" version "17.0.34"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.33.tgz#e01ae3de7613dac1094569880bb3792732203ad5" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.34.tgz#797b66d359b692e3f19991b6b07e4b0c706c0102"
integrity sha512-pLWntxXpDPaU+RTAuSGWGSEL2FRTNyRQOjSWDke/rxRg14ncsZvx8AKWMWZqvc1UOaJIAoObdZhAWvRaHFi5rw== integrity sha512-46FEGrMjc2+8XhHXILr+3+/sTe3OfzSPU9YGKILLrUYbQ1CLQC9Daqo1KzENGXAWwrFwiY0l4ZbF20gRvgpWTg==
dependencies: dependencies:
"@types/prop-types" "*" "@types/prop-types" "*"
"@types/scheduler" "*" "@types/scheduler" "*"
@ -3783,23 +3788,7 @@ color-name@^1.0.0, color-name@^1.1.4, color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-string@^1.5.2: color-string@^1.5.2, color-string@^1.5.4, color-string@^1.6.0:
version "1.5.3"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
color-string@^1.5.4:
version "1.5.5"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.5.tgz#65474a8f0e7439625f3d27a6a19d89fc45223014"
integrity sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
color-string@^1.6.0:
version "1.6.0" version "1.6.0"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312"
integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA== integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==
@ -4092,11 +4081,16 @@ core-js@^3.6.5:
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==
core-util-is@1.0.2, core-util-is@~1.0.0: core-util-is@1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
core-util-is@~1.0.0:
version "1.0.3"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cosmiconfig@^6.0.0: cosmiconfig@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982"
@ -8617,9 +8611,9 @@ jss@10.6.0, jss@^10.5.1:
object.assign "^4.1.1" object.assign "^4.1.1"
jszip@^3.1.0: jszip@^3.1.0:
version "3.5.0" version "3.7.1"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.5.0.tgz#b4fd1f368245346658e781fec9675802489e15f6" resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.7.1.tgz#bd63401221c15625a1228c556ca8a68da6fda3d9"
integrity sha512-WRtu7TPCmYePR1nazfrtuF216cIVon/3GWOvHS9QR5bIwSbnxtdpma6un3jyGGNhHsKCSzn5Ypk+EkDRvTGiFA== integrity sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg==
dependencies: dependencies:
lie "~3.3.0" lie "~3.3.0"
pako "~1.0.2" pako "~1.0.2"
@ -9767,10 +9761,9 @@ node-fetch@2.6.1:
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
node-fetch@^2.6.6: node-fetch@lensapp/node-fetch#2.x:
version "2.6.6" version "2.6.6"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.6.tgz#1751a7c01834e8e1697758732e9efb6eeadfaf89" resolved "https://codeload.github.com/lensapp/node-fetch/tar.gz/c869d40ba7dd3bce392c34e36118c225b6ada8fb"
integrity sha512-Z8/6vRlTUChSdIgMa51jxQ4lrw/Jy5SOW10ObaA47/RElsAN2c5Pn8bTgFGWn/ibwzXTE8qwr1Yzx28vsecXEA==
dependencies: dependencies:
whatwg-url "^5.0.0" whatwg-url "^5.0.0"
@ -13218,10 +13211,10 @@ table@^6.0.9:
string-width "^4.2.3" string-width "^4.2.3"
strip-ansi "^6.0.1" strip-ansi "^6.0.1"
tailwindcss@^2.2.17: tailwindcss@^2.2.19:
version "2.2.17" version "2.2.19"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-2.2.17.tgz#c6332731f9ff1b6628ff589c95c38685347775e3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-2.2.19.tgz#540e464832cd462bb9649c1484b0a38315c2653c"
integrity sha512-WgRpn+Pxn7eWqlruxnxEbL9ByVRWi3iC10z4b6dW0zSdnkPVC4hPMSWLQkkW8GCyBIv/vbJ0bxIi9dVrl4CfoA== integrity sha512-6Ui7JSVtXadtTUo2NtkBBacobzWiQYVjYW0ZnKaP9S1ZCKQ0w7KVNz+YSDI/j7O7KCMHbOkz94ZMQhbT9pOqjw==
dependencies: dependencies:
arg "^5.0.1" arg "^5.0.1"
bytes "^3.0.0" bytes "^3.0.0"