diff --git a/src/common/routes/index.ts b/src/common/routes/index.ts index 6cdd81e2b4..82a81d29dc 100644 --- a/src/common/routes/index.ts +++ b/src/common/routes/index.ts @@ -40,6 +40,7 @@ export * from "./network-policies"; export * from "./network"; export * from "./nodes"; export * from "./pod-disruption-budgets"; +export * from "./port-forwards"; export * from "./preferences"; export * from "./releases"; export * from "./resource-quotas"; diff --git a/src/common/routes/network.ts b/src/common/routes/network.ts index 17b51e200a..15338675a6 100644 --- a/src/common/routes/network.ts +++ b/src/common/routes/network.ts @@ -25,6 +25,7 @@ import { endpointRoute } from "./endpoints"; import { ingressRoute } from "./ingresses"; import { networkPoliciesRoute } from "./network-policies"; import { servicesRoute, servicesURL } from "./services"; +import { portForwardsRoute } from "./port-forwards"; export const networkRoute: RouteProps = { path: [ @@ -32,6 +33,7 @@ export const networkRoute: RouteProps = { endpointRoute, ingressRoute, networkPoliciesRoute, + portForwardsRoute, ].map(route => route.path.toString()) }; diff --git a/src/common/routes/port-forwards.ts b/src/common/routes/port-forwards.ts new file mode 100644 index 0000000000..d05b8583ab --- /dev/null +++ b/src/common/routes/port-forwards.ts @@ -0,0 +1,32 @@ +/** + * 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 type { RouteProps } from "react-router"; +import { buildURL } from "../utils/buildUrl"; + +export const portForwardsRoute: RouteProps = { + path: "/port-forwards" +}; + +export interface PortForwardsRouteParams { +} + +export const portForwardsURL = buildURL(portForwardsRoute.path); diff --git a/src/main/router.ts b/src/main/router.ts index d758cc2314..9a7ded8a37 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -179,8 +179,11 @@ export class Router { this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, MetricsRoute.routeMetrics); this.router.add({ method: "get", path: `${apiPrefix}/metrics/providers` }, MetricsRoute.routeMetricsProviders); - // Port-forward API - this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, PortForwardRoute.routePortForward); + // Port-forward API (the container port and local forwarding port are obtained from the query parameters) + this.router.add({ method: "post", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routePortForward); + this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForward); + this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forwards` }, PortForwardRoute.routeAllPortForwards); + this.router.add({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForwardStop); // Helm API this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, HelmApiRoute.listCharts); diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts index e97b4171cb..763ea16b43 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -22,7 +22,6 @@ import type { LensApiRequest } from "../router"; import { spawn, ChildProcessWithoutNullStreams } from "child_process"; import { Kubectl } from "../kubectl"; -import { shell } from "electron"; import * as tcpPortUsed from "tcp-port-used"; import logger from "../logger"; import { getPortFrom } from "../utils/get-port"; @@ -33,7 +32,8 @@ interface PortForwardArgs { kind: string; namespace: string; name: string; - port: string; + port: number; + forwardPort: number; } const internalPortRegex = /^forwarding from (?
.+) ->/i; @@ -56,8 +56,8 @@ class PortForward { public kind: string; public namespace: string; public name: string; - public port: string; - public internalPort?: number; + public port: number; + public forwardPort: number; constructor(public kubeConfig: string, args: PortForwardArgs) { this.clusterId = args.clusterId; @@ -65,16 +65,17 @@ class PortForward { this.namespace = args.namespace; this.name = args.name; this.port = args.port; + this.forwardPort = args.forwardPort; } public async start() { - const kubectlBin = await Kubectl.bundled().getPath(); + const kubectlBin = await Kubectl.bundled().getPath(true); const args = [ "--kubeconfig", this.kubeConfig, "port-forward", "-n", this.namespace, `${this.kind}/${this.name}`, - `:${this.port}` + `${this.forwardPort ?? ""}:${this.port}` ]; this.process = spawn(kubectlBin, args, { @@ -89,12 +90,15 @@ class PortForward { } }); - this.internalPort = await getPortFrom(this.process.stdout, { + const internalPort = await getPortFrom(this.process.stdout, { lineRegex: internalPortRegex, }); try { - await tcpPortUsed.waitUntilUsed(this.internalPort, 500, 15000); + await tcpPortUsed.waitUntilUsed(internalPort, 500, 15000); + + // make sure this.forwardPort is set to the actual port used (if it was 0 then an available port is found by 'kubectl port-forward') + this.forwardPort = internalPort; return true; } catch (error) { @@ -104,58 +108,113 @@ class PortForward { } } - public open() { - shell.openExternal(`http://localhost:${this.internalPort}`) - .catch(error => logger.error(`[PORT-FORWARD]: failed to open external shell: ${error}`, { - clusterId: this.clusterId, - port: this.port, - kind: this.kind, - namespace: this.namespace, - name: this.name, - })); + public async stop() { + this.process.kill(); } } export class PortForwardRoute { static async routePortForward(request: LensApiRequest) { - const { params, response, cluster} = request; - const { namespace, port, resourceType, resourceName } = params; - let portForward = PortForward.getPortforward({ - clusterId: cluster.id, kind: resourceType, name: resourceName, - namespace, port - }); + const { params, query, response, cluster } = request; + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); - if (!portForward) { - logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`); - portForward = new PortForward(await cluster.getProxyKubeconfigPath(), { - clusterId: cluster.id, - kind: resourceType, - namespace, - name: resourceName, - port, + try { + let portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace, port, forwardPort, }); - try { + let thePort = 0; + + if (forwardPort > 0 && forwardPort < 65536) { + thePort = forwardPort; + } + + if (!portForward) { + logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`); + portForward = new PortForward(await cluster.getProxyKubeconfigPath(), { + clusterId: cluster.id, + kind: resourceType, + namespace, + name: resourceName, + port, + forwardPort: thePort, + }); + const started = await portForward.start(); if (!started) { - logger.warn("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { namespace, port, resourceType, resourceName }); + logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { namespace, port, resourceType, resourceName }); return respondJson(response, { - message: "Failed to open port-forward" + message: `Failed to forward port ${port} to ${thePort ? forwardPort : "random port"}` }, 400); } - } catch (error) { - logger.warn(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName }); - - return respondJson(response, { - message: error?.toString() || "Failed to open port-forward", - }, 400); } + + respondJson(response, { port: portForward.forwardPort }); + } catch (error) { + logger.error(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName }); + + return respondJson(response, { + message: `Failed to forward port ${port}` + }, 400); } + } - portForward.open(); + static async routeCurrentPortForward(request: LensApiRequest) { + const { params, query, response, cluster } = request; + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); - respondJson(response, {}); + const portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace, port, forwardPort + }); + + respondJson(response, { port: portForward?.forwardPort ?? null }); + } + + static async routeAllPortForwards(request: LensApiRequest) { + const { response } = request; + + const portForwards: PortForwardArgs[] = PortForward.portForwards.map(f => ( + { + clusterId: f.clusterId, + kind: f.kind, + namespace: f.namespace, + name: f.name, + port: f.port, + forwardPort: f.forwardPort, + }) + ); + + respondJson(response, { portForwards }); + } + + static async routeCurrentPortForwardStop(request: LensApiRequest) { + const { params, query, response, cluster } = request; + const { namespace, resourceType, resourceName } = params; + const port = Number(query.get("port")); + const forwardPort = Number(query.get("forwardPort")); + + const portForward = PortForward.getPortforward({ + clusterId: cluster.id, kind: resourceType, name: resourceName, + namespace, port, forwardPort, + }); + + try { + await portForward.stop(); + respondJson(response, { status: true }); + } catch (error) { + logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName }); + + return respondJson(response, { + message: `error stopping a forward port ${port}` + }, 400); + } } } diff --git a/src/renderer/components/+network-port-forwards/index.ts b/src/renderer/components/+network-port-forwards/index.ts new file mode 100644 index 0000000000..2b0c81f833 --- /dev/null +++ b/src/renderer/components/+network-port-forwards/index.ts @@ -0,0 +1,22 @@ +/** + * 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. + */ + +export * from "./port-forwards"; diff --git a/src/renderer/components/+network-port-forwards/port-forward-menu.tsx b/src/renderer/components/+network-port-forwards/port-forward-menu.tsx new file mode 100644 index 0000000000..7b0f35ef43 --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forward-menu.tsx @@ -0,0 +1,73 @@ +/** + * 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 { boundMethod, cssNames } from "../../utils"; +import { openPortForward, PortForwardItem, removePortForward } from "../../port-forward"; +import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; +import { MenuItem } from "../menu"; +import { Icon } from "../icon"; +import { PortForwardDialog } from "../../port-forward"; + +interface Props extends MenuActionsProps { + portForward: PortForwardItem; + hideDetails?(): void; +} + +export class PortForwardMenu extends React.Component { + @boundMethod + remove() { + return removePortForward(this.props.portForward); + } + + renderContent() { + const { portForward, toolbar } = this.props; + + if (!portForward) return null; + + return ( + <> + openPortForward(this.props.portForward)}> + + Open + + PortForwardDialog.open(portForward)}> + + Edit + + + ); + } + + render() { + const { className, ...menuProps } = this.props; + + return ( + + {this.renderContent()} + + ); + } +} diff --git a/src/renderer/components/+network-port-forwards/port-forwards.scss b/src/renderer/components/+network-port-forwards/port-forwards.scss new file mode 100644 index 0000000000..0c048fc94d --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forwards.scss @@ -0,0 +1,28 @@ +/** + * 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. + */ + +.PortForwards { + .TableCell { + &.warning { + @include table-cell-warning; + } + } +} \ No newline at end of file diff --git a/src/renderer/components/+network-port-forwards/port-forwards.tsx b/src/renderer/components/+network-port-forwards/port-forwards.tsx new file mode 100644 index 0000000000..c056dab454 --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forwards.tsx @@ -0,0 +1,103 @@ +/** + * 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 "./port-forwards.scss"; + +import React from "react"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { ItemListLayout } from "../item-object-list/item-list-layout"; +import { PortForwardItem, portForwardStore } from "../../port-forward"; +import { PortForwardMenu } from "./port-forward-menu"; + +enum columnId { + name = "name", + namespace = "namespace", + kind = "kind", + port = "port", + forwardPort = "forwardPort", +} + +@observer +export class PortForwards extends React.Component { + + componentDidMount() { + disposeOnUnmount(this, [ + portForwardStore.watch(), + ]); + } + + renderRemoveDialogMessage(selectedItems: PortForwardItem[]) { + const forwardPorts = selectedItems.map(item => item.getForwardPort()).join(", "); + + return ( +
+ <>Stop forwarding from {forwardPorts}? +
+ ); + } + + + render() { + return ( + <> + item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.kind]: item => item.getKind(), + [columnId.port]: item => item.getPort(), + [columnId.forwardPort]: item => item.getForwardPort(), + }} + searchFilters={[ + item => item.getSearchFields(), + ]} + renderHeaderTitle="Port Forwarding" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, + { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, + { title: "Kind", className: "kind", sortBy: columnId.kind, id: columnId.kind }, + { title: "Pod Port", className: "port", sortBy: columnId.port, id: columnId.port }, + { title: "Local Port", className: "forwardPort", sortBy: columnId.forwardPort, id: columnId.forwardPort }, + ]} + renderTableContents={item => [ + item.getName(), + item.getNs(), + item.getKind(), + item.getPort(), + item.getForwardPort(), + ]} + renderItemMenu={pf => ( + + )} + customizeRemoveDialog={selectedItems => ({ + message: this.renderRemoveDialogMessage(selectedItems) + })} + /> + + ); + } +} diff --git a/src/renderer/components/+network-services/service-details.tsx b/src/renderer/components/+network-services/service-details.tsx index 66a09672a1..38ed8d6c68 100644 --- a/src/renderer/components/+network-services/service-details.tsx +++ b/src/renderer/components/+network-services/service-details.tsx @@ -32,6 +32,7 @@ import { ServicePortComponent } from "./service-port-component"; import { endpointStore } from "../+network-endpoints/endpoints.store"; import { ServiceDetailsEndpoint } from "./service-details-endpoint"; import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; +import { portForwardStore } from "../../port-forward"; interface Props extends KubeObjectDetailsProps { } @@ -46,6 +47,7 @@ export class ServiceDetails extends React.Component { preload: true, namespaces: [service.getNs()], }), + portForwardStore.watch(), ]); } diff --git a/src/renderer/components/+network-services/service-port-component.scss b/src/renderer/components/+network-services/service-port-component.scss index 89369f560f..c22f41d1e0 100644 --- a/src/renderer/components/+network-services/service-port-component.scss +++ b/src/renderer/components/+network-services/service-port-component.scss @@ -33,11 +33,13 @@ cursor: pointer; color: $primary; text-decoration: underline; + padding-right: 1em; } - .Spinner { - --spinner-size: #{$unit * 2}; - margin-left: $margin; - position: absolute; + .portInput { + display: inline-block !important; + width: 70px; + margin-left: 10px; + margin-right: 10px; } } diff --git a/src/renderer/components/+network-services/service-port-component.tsx b/src/renderer/components/+network-services/service-port-component.tsx index 96bb7385cb..d49a1eb94a 100644 --- a/src/renderer/components/+network-services/service-port-component.tsx +++ b/src/renderer/components/+network-services/service-port-component.tsx @@ -22,12 +22,15 @@ import "./service-port-component.scss"; import React from "react"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import type { Service, ServicePort } from "../../../common/k8s-api/endpoints"; -import { apiBase } from "../../api"; -import { observable, makeObservable } from "mobx"; +import { observable, makeObservable, reaction } from "mobx"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; +import { Button } from "../button"; +import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, removePortForward } from "../../port-forward"; +import type { ForwardedPort } from "../../port-forward"; +import logger from "../../../common/logger"; import { Spinner } from "../spinner"; interface Props { @@ -38,20 +41,86 @@ interface Props { @observer export class ServicePortComponent extends React.Component { @observable waiting = false; + @observable forwardPort = 0; + @observable isPortForwarded = false; constructor(props: Props) { super(props); makeObservable(this); + this.init(); + } + + componentDidMount() { + disposeOnUnmount(this, [ + reaction(() => [ portForwardStore.portForwards, this.props.service ], () => this.init()), + ]); + } + + init() { + this.checkExistingPortForwarding().catch(error => { + logger.error(error); + }); + } + + async checkExistingPortForwarding() { + const { service, port } = this.props; + const portForward: ForwardedPort = { + kind: "service", + name: service.getName(), + namespace: service.getNs(), + port: port.port, + forwardPort: this.forwardPort, + }; + const activePort = await getPortForward(portForward) ?? 0; + + this.forwardPort = activePort; + this.isPortForwarded = activePort ? true : false; } async portForward() { const { service, port } = this.props; + const portForward: ForwardedPort = { + kind: "service", + name: service.getName(), + namespace: service.getNs(), + port: port.port, + forwardPort: this.forwardPort, + }; + + this.waiting = true; + this.isPortForwarded = false; + + try { + this.forwardPort = await addPortForward(portForward); + + if (this.forwardPort) { + portForward.forwardPort = this.forwardPort; + openPortForward(portForward); + this.isPortForwarded = true; + } + } catch (error) { + Notifications.error(error); + } finally { + this.waiting = false; + } + } + + async stopPortForward() { + const { service, port } = this.props; + const portForward: ForwardedPort = { + kind: "service", + name: service.getName(), + namespace: service.getNs(), + port: port.port, + forwardPort: this.forwardPort, + }; this.waiting = true; try { - await apiBase.post(`/pods/${service.getNs()}/service/${service.getName()}/port-forward/${port.port}`, {}); - } catch(error) { + await removePortForward(portForward); + this.isPortForwarded = false; + } catch (error) { Notifications.error(error); } finally { this.waiting = false; @@ -59,16 +128,33 @@ export class ServicePortComponent extends React.Component { } render() { - const { port } = this.props; + const { port, service } = this.props; + + const portForwardAction = async () => { + if (this.isPortForwarded) { + await this.stopPortForward(); + } else { + const portForward: ForwardedPort = { + kind: "service", + name: service.getName(), + namespace: service.getNs(), + port: port.port, + forwardPort: this.forwardPort, + }; + + PortForwardDialog.open(portForward, { openInBrowser: true }); + } + }; return (
- this.portForward() }> + this.portForward()}> {port.toString()} - {this.waiting && ( - - )} + + {this.waiting && ( + + )}
); } diff --git a/src/renderer/components/+network/network.tsx b/src/renderer/components/+network/network.tsx index 7c177ee2a3..083996374c 100644 --- a/src/renderer/components/+network/network.tsx +++ b/src/renderer/components/+network/network.tsx @@ -28,6 +28,7 @@ import { Services } from "../+network-services"; import { Endpoints } from "../+network-endpoints"; import { Ingresses } from "../+network-ingresses"; import { NetworkPolicies } from "../+network-policies"; +import { PortForwards } from "../+network-port-forwards"; import { isAllowedResource } from "../../../common/utils/allowed-resource"; import * as routes from "../../../common/routes"; @@ -72,6 +73,13 @@ export class Network extends React.Component { }); } + tabs.push({ + title: "Port Forwarding", + component: PortForwards, + url: routes.portForwardsURL(), + routePath: routes.portForwardsRoute.path.toString(), + }); + return tabs; } diff --git a/src/renderer/components/+workloads-pods/pod-container-port.scss b/src/renderer/components/+workloads-pods/pod-container-port.scss index 80d2425aac..efd5592065 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.scss +++ b/src/renderer/components/+workloads-pods/pod-container-port.scss @@ -34,11 +34,13 @@ color: $primary; text-decoration: underline; position: relative; + padding-right: 1em; } - .Spinner { - --spinner-size: #{$unit * 2}; - margin-left: $margin; - position: absolute; + .portInput { + display: inline-block !important; + width: 70px; + margin-left: 10px; + margin-right: 10px; } -} \ No newline at end of file +} diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx index c8bb435b55..4c468c46c8 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -22,12 +22,15 @@ import "./pod-container-port.scss"; import React from "react"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import type { Pod } from "../../../common/k8s-api/endpoints"; -import { apiBase } from "../../api"; -import { observable, makeObservable } from "mobx"; +import { observable, makeObservable, reaction } from "mobx"; import { cssNames } from "../../utils"; import { Notifications } from "../notifications"; +import { Button } from "../button"; +import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, removePortForward } from "../../port-forward"; +import type { ForwardedPort } from "../../port-forward"; +import logger from "../../../common/logger"; import { Spinner } from "../spinner"; interface Props { @@ -42,20 +45,86 @@ interface Props { @observer export class PodContainerPort extends React.Component { @observable waiting = false; + @observable forwardPort = 0; + @observable isPortForwarded = false; constructor(props: Props) { super(props); makeObservable(this); + this.init(); + } + + componentDidMount() { + disposeOnUnmount(this, [ + reaction(() => [ portForwardStore.portForwards, this.props.pod ], () => this.init()), + ]); + } + + init() { + this.checkExistingPortForwarding().catch(error => { + logger.error(error); + }); + } + + async checkExistingPortForwarding() { + const { pod, port } = this.props; + const portForward: ForwardedPort = { + kind: "pod", + name: pod.getName(), + namespace: pod.getNs(), + port: port.containerPort, + forwardPort: this.forwardPort, + }; + const activePort = await getPortForward(portForward) ?? 0; + + this.forwardPort = activePort; + this.isPortForwarded = activePort ? true : false; } async portForward() { const { pod, port } = this.props; + const portForward: ForwardedPort = { + kind: "pod", + name: pod.getName(), + namespace: pod.getNs(), + port: port.containerPort, + forwardPort: this.forwardPort, + }; + + this.waiting = true; + this.isPortForwarded = false; + + try { + this.forwardPort = await addPortForward(portForward); + + if (this.forwardPort) { + portForward.forwardPort = this.forwardPort; + openPortForward(portForward); + this.isPortForwarded = true; + } + } catch (error) { + Notifications.error(error); + } finally { + this.waiting = false; + } + } + + async stopPortForward() { + const { pod, port } = this.props; + const portForward: ForwardedPort = { + kind: "pod", + name: pod.getName(), + namespace: pod.getNs(), + port: port.containerPort, + forwardPort: this.forwardPort, + }; this.waiting = true; try { - await apiBase.post(`/pods/${pod.getNs()}/pod/${pod.getName()}/port-forward/${port.containerPort}`, {}); - } catch(error) { + await removePortForward(portForward); + this.isPortForwarded = false; + } catch (error) { Notifications.error(error); } finally { this.waiting = false; @@ -63,18 +132,35 @@ export class PodContainerPort extends React.Component { } render() { - const { port } = this.props; + const { pod, port } = this.props; const { name, containerPort, protocol } = port; const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`; + const portForwardAction = async () => { + if (this.isPortForwarded) { + await this.stopPortForward(); + } else { + const portForward: ForwardedPort = { + kind: "pod", + name: pod.getName(), + namespace: pod.getNs(), + port: port.containerPort, + forwardPort: this.forwardPort, + }; + + PortForwardDialog.open(portForward, { openInBrowser: true }); + } + }; + return (
- this.portForward() }> + this.portForward()}> {text} - {this.waiting && ( - - )} + + {this.waiting && ( + + )}
); } diff --git a/src/renderer/components/+workloads-pods/pod-details-container.tsx b/src/renderer/components/+workloads-pods/pod-details-container.tsx index c39ed80751..b4a4c5eaf2 100644 --- a/src/renderer/components/+workloads-pods/pod-details-container.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-container.tsx @@ -35,6 +35,8 @@ import { ContainerCharts } from "./container-charts"; import { LocaleDate } from "../locale-date"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../common/cluster-types"; +import { portForwardStore } from "../../port-forward/port-forward.store"; +import { disposeOnUnmount, observer } from "mobx-react"; interface Props { pod: Pod; @@ -42,8 +44,15 @@ interface Props { metrics?: { [key: string]: IMetrics }; } +@observer export class PodDetailsContainer extends React.Component { + componentDidMount() { + disposeOnUnmount(this, [ + portForwardStore.watch(), + ]); + } + renderStatus(state: string, status: IPodContainerStatus) { const ready = status ? status.ready : ""; diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 9769db06b7..769c9fe9c4 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -74,6 +74,7 @@ import { ClusterStore } from "../../common/cluster-store"; import type { ClusterId } from "../../common/cluster-types"; import { watchHistoryState } from "../remote-helpers/history-updater"; import { unmountComponentAtNode } from "react-dom"; +import { PortForwardDialog } from "../port-forward"; @observer export class App extends React.Component { @@ -231,6 +232,7 @@ export class App extends React.Component { + diff --git a/src/renderer/port-forward/index.ts b/src/renderer/port-forward/index.ts new file mode 100644 index 0000000000..5be9fb5aed --- /dev/null +++ b/src/renderer/port-forward/index.ts @@ -0,0 +1,24 @@ +/** + * 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. + */ + +export * from "./port-forward.store"; +export * from "./port-forward-item"; +export * from "./port-forward-dialog"; diff --git a/src/renderer/port-forward/port-forward-dialog.scss b/src/renderer/port-forward/port-forward-dialog.scss new file mode 100644 index 0000000000..6d5e07176b --- /dev/null +++ b/src/renderer/port-forward/port-forward-dialog.scss @@ -0,0 +1,77 @@ +/** + * 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. + */ + +.PortForwardDialog { + .Wizard { + .header { + span { + color: #a0a0a0; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .WizardStep { + .step-content { + min-height: 90px; + overflow: hidden; + } + } + + .current-scale { + font-weight: bold + } + + .desired-scale { + flex: 1.1 0; + } + + .slider-container { + flex: 1 0; + } + + .plus-minus-container { + margin-left: $margin * 2; + .Icon { + --color-active: black; + } + } + + .warning { + color: $colorSoftError; + font-size: small; + display: flex; + align-items: center; + + .Icon { + margin: 0; + margin-right: $margin; + } + } + + .portInput { + display: inline-block !important; + width: 70px; + margin-left: 10px; + margin-right: 10px; + } } + +} diff --git a/src/renderer/port-forward/port-forward-dialog.tsx b/src/renderer/port-forward/port-forward-dialog.tsx new file mode 100644 index 0000000000..6ba05ec1c4 --- /dev/null +++ b/src/renderer/port-forward/port-forward-dialog.tsx @@ -0,0 +1,173 @@ +/** + * 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 "./port-forward-dialog.scss"; + +import React, { Component } from "react"; +import { observable, makeObservable } from "mobx"; +import { observer } from "mobx-react"; +import { Dialog, DialogProps } from "../components/dialog"; +import { Wizard, WizardStep } from "../components/wizard"; +import { Input } from "../components/input"; +import { Notifications } from "../components/notifications"; +import { cssNames } from "../utils"; +import { addPortForward, modifyPortForward } from "./port-forward.store"; +import type { ForwardedPort } from "./port-forward-item"; +import { openPortForward } from "."; + +interface Props extends Partial { +} + +interface PortForwardDialogOpenOptions { + openInBrowser: boolean +} + +const dialogState = observable.object({ + isOpen: false, + data: null as ForwardedPort, + openInBrowser: false +}); + +@observer +export class PortForwardDialog extends Component { + @observable ready = false; + @observable currentPort = 0; + @observable desiredPort = 0; + + constructor(props: Props) { + super(props); + makeObservable(this); + } + + static open(portForward: ForwardedPort, options : PortForwardDialogOpenOptions = { openInBrowser: false }) { + dialogState.isOpen = true; + dialogState.data = portForward; + dialogState.openInBrowser = options.openInBrowser; + } + + static close() { + dialogState.isOpen = false; + } + + get portForward() { + return dialogState.data; + } + + close = () => { + PortForwardDialog.close(); + }; + + onOpen = async () => { + const { portForward } = this; + + this.currentPort = +portForward.forwardPort; + this.desiredPort = this.currentPort; + this.ready = this.currentPort ? false : true; + }; + + onClose = () => { + this.ready = false; + }; + + changePort = (value: string) => { + this.desiredPort = Number(value); + this.ready = Boolean(this.desiredPort == 0 || this.currentPort !== this.desiredPort); + }; + + startPortForward = async () => { + const { portForward } = this; + const { currentPort, desiredPort, close } = this; + + try { + let port: number; + + if (currentPort) { + port = await modifyPortForward(portForward, desiredPort); + } else { + portForward.forwardPort = desiredPort; + port = await addPortForward(portForward); + } + + if (dialogState.openInBrowser) { + portForward.forwardPort = port; + openPortForward(portForward); + } + } catch (err) { + Notifications.error(err); + } finally { + close(); + } + }; + + renderContents() { + return ( + <> +
+
+
+ Local port to forward from: +
+ +
+
+ + ); + } + + render() { + const { className, ...dialogProps } = this.props; + const resourceName = this.portForward?.name ?? ""; + const header = ( +
+ Port Forwarding for {resourceName} +
+ ); + + return ( + + + + {this.renderContents()} + + + + ); + } +} diff --git a/src/renderer/port-forward/port-forward-item.ts b/src/renderer/port-forward/port-forward-item.ts new file mode 100644 index 0000000000..9251b0fcef --- /dev/null +++ b/src/renderer/port-forward/port-forward-item.ts @@ -0,0 +1,91 @@ +/** + * 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 type { ItemObject } from "../../common/item.store"; +import { autoBind } from "../../common/utils"; + +export interface ForwardedPort { + clusterId?: string; + kind: string; + namespace: string; + name: string; + port: number; + forwardPort: number; +} + +export class PortForwardItem implements ItemObject { + clusterId: string; + kind: string; + namespace: string; + name: string; + port: number; + forwardPort: number; + + constructor(pf: ForwardedPort) { + this.clusterId = pf.clusterId; + this.kind = pf.kind; + this.namespace = pf.namespace; + this.name = pf.name; + this.port = pf.port; + this.forwardPort = pf.forwardPort; + + autoBind(this); + } + + getName() { + return this.name; + } + + getNs() { + return this.namespace; + } + + get id() { + return this.forwardPort; + } + + getId() { + return String(this.forwardPort); + } + + getKind() { + return this.kind; + } + + getPort() { + return this.port; + } + + getForwardPort() { + return this.forwardPort; + } + + getSearchFields() { + return [ + this.name, + this.id, + this.kind, + this.port, + this.forwardPort, + ]; + } +} diff --git a/src/renderer/port-forward/port-forward.store.ts b/src/renderer/port-forward/port-forward.store.ts new file mode 100644 index 0000000000..49ebee61d4 --- /dev/null +++ b/src/renderer/port-forward/port-forward.store.ts @@ -0,0 +1,179 @@ +/** + * 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 { makeObservable, observable, reaction } from "mobx"; +import { ItemStore } from "../../common/item.store"; +import { autoBind, createStorage, disposer, getHostedClusterId, openExternal } from "../utils"; +import { ForwardedPort, PortForwardItem } from "./port-forward-item"; +import { apiBase } from "../api"; +import { waitUntilFree } from "tcp-port-used"; +import { Notifications } from "../components/notifications"; +import logger from "../../common/logger"; + +export class PortForwardStore extends ItemStore { + private storage = createStorage("port_forwards", undefined); + + @observable portForwards: PortForwardItem[]; + + constructor() { + super(); + makeObservable(this); + autoBind(this); + + this.init(); + } + + private async init() { + await this.storage.whenReady; + + const savedPortForwards = this.storage.get(); // undefined on first load + + if (Array.isArray(savedPortForwards)) { + logger.info("[PORT_FORWARD] starting saved port-forwards"); + await Promise.all(savedPortForwards.map(addPortForward)); + } + } + + watch() { + return disposer( + reaction(() => this.portForwards, () => this.loadAll()), + ); + } + + loadAll() { + return this.loadItems(async () => { + let portForwards = await getPortForwards(); + + // filter out any not for this cluster + portForwards = portForwards.filter(pf => pf.clusterId == getHostedClusterId()); + this.storage.set(portForwards); + + this.reset(); + portForwards.map(pf => this.portForwards.push(new PortForwardItem(pf))); + + return this.portForwards; + }); + } + + reset() { + this.portForwards = []; + } + + async removeSelectedItems() { + return Promise.all(this.selectedItems.map(removePortForward)); + } +} + +interface PortForwardResult { + port: number; +} + +interface PortForwardsResult { + portForwards: ForwardedPort[]; +} + +export async function addPortForward(portForward: ForwardedPort): Promise { + let response: PortForwardResult; + + try { + response = await apiBase.post(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}`); + + if (response?.port && response.port != +portForward.forwardPort) { + logger.warn(`specified ${portForward.forwardPort} got ${response.port}`); + } + } catch (error) { + logger.warn(error); // don't care, caller must check + } + portForwardStore.reset(); + + return response?.port; +} + +export async function getPortForward(portForward: ForwardedPort): Promise { + let response: PortForwardResult; + + try { + response = await apiBase.get(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}`); + } catch (error) { + logger.warn(error); // don't care, caller must check + } + + return response?.port; +} + +export async function modifyPortForward(portForward: ForwardedPort, desiredPort: number): Promise { + let port = 0; + + try { + await removePortForward(portForward); + portForward.forwardPort = desiredPort; + port = await addPortForward(portForward); + } catch (error) { + logger.warn(error); // don't care, caller must check + } + portForwardStore.reset(); + + return port; +} + + +export async function removePortForward(portForward: ForwardedPort) { + try { + await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}`); + await waitUntilFree(+portForward.forwardPort, 200, 1000); + } catch (error) { + logger.warn(error); // don't care, caller must check + } + portForwardStore.reset(); +} + +export async function getPortForwards(): Promise { + try { + const response = await apiBase.get(`/pods/port-forwards`); + + return response.portForwards; + } catch (error) { + logger.warn(error); // don't care, caller must check + + return []; + } +} + +export function openPortForward(portForward: ForwardedPort) { + const browseTo = `http://localhost:${portForward.forwardPort}`; + + openExternal(browseTo) + .catch(error => { + logger.error(`failed to open in browser: ${error}`, { + clusterId: portForward.clusterId, + port: portForward.port, + kind: portForward.kind, + namespace: portForward.namespace, + name: portForward.name, + }); + Notifications.error(`Failed to open ${browseTo} in browser`); + } + ); + +} + +export const portForwardStore = new PortForwardStore();