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..bf644e5003 --- /dev/null +++ b/src/common/routes/port-forwards.ts @@ -0,0 +1,33 @@ +/** + * 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/:forwardport?" +}; + +export interface PortForwardsRouteParams { + forwardport?: string; +} + +export const portForwardsURL = buildURL(portForwardsRoute.path); diff --git a/src/main/router.ts b/src/main/router.ts index 16fed03734..8706ff1f08 100644 --- a/src/main/router.ts +++ b/src/main/router.ts @@ -182,6 +182,7 @@ export class Router { // Port-forward API this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}/{forwardPort}` }, PortForwardRoute.routePortForward); this.router.add({ method: "get", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}/{forwardPort}` }, PortForwardRoute.routeCurrentPortForward); + this.router.add({ method: "get", path: `${apiPrefix}/port-forwards` }, PortForwardRoute.routeAllPortForwards); this.router.add({ method: "delete", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}/{forwardPort}` }, PortForwardRoute.routeCurrentPortForwardStop); // Helm API diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts index c9e3036e86..c2bc609ccd 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -99,6 +99,8 @@ class PortForward { try { await tcpPortUsed.waitUntilUsed(this.internalPort, 500, 15000); + this.forwardPort = String(this.internalPort); + return true; } catch (error) { this.process.kill(); @@ -134,6 +136,12 @@ export class PortForwardRoute { namespace, port, forwardPort, }); + let thePort: string; // undefined + const portNum = Number(forwardPort); + if (portNum > 0 && portNum < 65536) { + thePort = forwardPort; + } + if (!portForward) { logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`); portForward = new PortForward(await cluster.getProxyKubeconfigPath(), { @@ -142,7 +150,7 @@ export class PortForwardRoute { namespace, name: resourceName, port, - forwardPort + forwardPort: thePort, }); const started = await portForward.start(); @@ -151,7 +159,7 @@ export class PortForwardRoute { logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { namespace, port, resourceType, resourceName }); return respondJson(response, { - message: `Failed to forward port ${port}` + message: `Failed to forward port ${port} to ${thePort ? forwardPort : "random port"}` }, 400); } } @@ -179,6 +187,26 @@ export class PortForwardRoute { respondJson(response, {port: portForward?.internalPort?? null}); } + static async routeAllPortForwards(request: LensApiRequest) { + const { response } = request; + + const portForwards: PortForwardArgs[] = []; + + PortForward.portForwards.forEach(f => { + const pf: PortForwardArgs = { + clusterId: f.clusterId, + kind: f.kind, + namespace: f.namespace, + name: f.name, + port: f.port, + forwardPort: f.forwardPort, + }; + portForwards.push(pf) + }); + + respondJson(response, {portForwards}); + } + static async routeCurrentPortForwardStop(request: LensApiRequest) { const { params, response, cluster} = request; const { namespace, port, forwardPort, resourceType, resourceName } = params; 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..73091cfc84 --- /dev/null +++ b/src/renderer/components/+network-port-forwards/index.ts @@ -0,0 +1,23 @@ +/** + * 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"; +export * from "./port-forward-details"; diff --git a/src/renderer/components/+network-port-forwards/port-forward-details.scss b/src/renderer/components/+network-port-forwards/port-forward-details.scss new file mode 100644 index 0000000000..8a0fec0992 --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forward-details.scss @@ -0,0 +1,26 @@ +/** + * 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. + */ + +.PortForwardDetails { + .SubTitle { + text-transform: none + } +} \ No newline at end of file diff --git a/src/renderer/components/+network-port-forwards/port-forward-details.tsx b/src/renderer/components/+network-port-forwards/port-forward-details.tsx new file mode 100644 index 0000000000..4a9d6dac59 --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forward-details.tsx @@ -0,0 +1,41 @@ +/** + * 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-details.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import type { PortForwardItem } from "./port-forward.store"; + +interface Props { + portForward: PortForwardItem; + hideDetails(): void; +} + +@observer +export class PortForwardDetails extends React.Component { + render() { + return ( +
+
+ ); + } +} diff --git a/src/renderer/components/+network-port-forwards/port-forward-dialog.scss b/src/renderer/components/+network-port-forwards/port-forward-dialog.scss new file mode 100644 index 0000000000..6fcfe9ed0a --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forward-dialog.scss @@ -0,0 +1,70 @@ +/** + * 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; + } + } + } +} diff --git a/src/renderer/components/+network-port-forwards/port-forward-dialog.tsx b/src/renderer/components/+network-port-forwards/port-forward-dialog.tsx new file mode 100644 index 0000000000..48356f7734 --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forward-dialog.tsx @@ -0,0 +1,165 @@ +/** + * 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 { computed, observable, makeObservable } from "mobx"; +import { observer } from "mobx-react"; +import { Dialog, DialogProps } from "../dialog"; +import { Wizard, WizardStep } from "../wizard"; +import { Icon } from "../icon"; +import { Input } from "../input"; +import { Notifications } from "../notifications"; +import { cssNames } from "../../utils"; +import { PortForwardItem, portForwardStore } from "./port-forward.store"; +import { isNumber } from "../input/input_validators"; + + interface Props extends Partial { + } + +const dialogState = observable.object({ + isOpen: false, + data: null as PortForwardItem, +}); + + @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: PortForwardItem) { + dialogState.isOpen = true; + dialogState.data = portForward; + } + + static close() { + dialogState.isOpen = false; + } + + get portForward() { + return dialogState.data; + } + + close = () => { + PortForwardDialog.close(); + }; + + @computed get scaleMax() { + const { currentPort } = this; + const defaultMax = 50; + + return currentPort <= defaultMax + ? defaultMax * 2 + : currentPort * 2; + } + + onOpen = async () => { + const { portForward } = this; + + this.currentPort = +portForward.forwardPort; + this.desiredPort = this.currentPort; + this.ready = true; + }; + + onClose = () => { + this.ready = false; + }; + + changePort = async () => { + const { portForward } = this; + const { currentPort, desiredPort, close } = this; + + try { + if (currentPort !== desiredPort) { + await portForwardStore.modify(portForward, desiredPort); + } + close(); + } catch (err) { + Notifications.error(err); + } + }; + + renderContents() { + return ( + <> +
+
+
+ Current port: {this.currentPort} +
+ this.desiredPort = +v} + /> +
+
+
+ + Current port-forwarding will be removed and a new one will be started +
+ + ); + } + + render() { + const { className, ...dialogProps } = this.props; + const resourceName = this.portForward ? this.portForward.getName() : ""; + const header = ( +
+ Change Port {resourceName} +
+ ); + + return ( + + + + {this.renderContents()} + + + + ); + } + } + \ No newline at end of file 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..30273e64b1 --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forward-menu.tsx @@ -0,0 +1,94 @@ +/** + * 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, openExternal } from "../../utils"; +import { PortForwardItem, portForwardStore } from "./port-forward.store"; +import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; +import { MenuItem } from "../menu"; +import { Icon } from "../icon"; +import { Notifications } from "../notifications"; +import { PortForwardDialog } from "./port-forward-dialog"; + +interface Props extends MenuActionsProps { + portForward: PortForwardItem; + hideDetails?(): void; +} + +export class PortForwardMenu extends React.Component { + @boundMethod + remove() { + return portForwardStore.remove(this.props.portForward); + } + + @boundMethod + openInBrowser() { + const { portForward } = this.props; + const browseTo = `http://localhost:${portForward.forwardPort}`; + + openExternal(browseTo) + .catch(error => { + console.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`); + } + ); + } + + renderContent() { + const { portForward, toolbar } = this.props; + + if (!portForward) return null; + + return ( + <> + + + Open + + PortForwardDialog.open(portForward)}> + + Edit + + + ); + } + + render() { + const { className, ...menuProps } = this.props; + + return ( + + {this.renderContent()} + + ); + } + } + \ No newline at end of file diff --git a/src/renderer/components/+network-port-forwards/port-forward.store.ts b/src/renderer/components/+network-port-forwards/port-forward.store.ts new file mode 100644 index 0000000000..46a95365fe --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forward.store.ts @@ -0,0 +1,242 @@ +/** + * 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 { computed, IReactionDisposer, makeObservable, observable, reaction } from "mobx"; +import { ItemObject, ItemStore } from "../../../common/item.store"; +import { autoBind, createStorage, delay } from "../../utils"; +import { apiBase } from "../../api"; +import { waitUntilFree } from "tcp-port-used"; +import { getHostedClusterId } from "../../utils"; +import { podsApi } from "../../../common/k8s-api/endpoints"; + +interface PortForwardResult { + port: number; +} + +interface ForwardedPort { + clusterId: string; + kind: string; + namespace: string; + name: string; + port: string; + forwardPort: string; +} + +interface PortForwardsResult { + portForwards: ForwardedPort[]; +} + +export class PortForwardItem implements ItemObject { + clusterId: string; + kind: string; + namespace: string; + name: string; + port: string; + forwardPort: string; + + interval: NodeJS.Timeout; + + constructor() { + autoBind(this); + } + + getName() { + return this.name; + } + + getNs() { + return this.namespace; + } + + get id() { + return this.forwardPort; + } + + getId() { + return 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, + ]; + } + + monitor() { + // for now only support pods + if (this.kind === "pod") { + this.interval = setInterval( this.keepAlive, 5000); + } + } + + async keepAlive() { + console.log("keepAlive() called"); + let pod = await podsApi.get({name: this.getName(), namespace: this.getNs()}); + if (!pod || pod.metadata.deletionTimestamp) { + console.log("keepAlive() pod lost!"); + clearInterval(this.interval); + this.interval = undefined; + // wait for resource + while (true) { + await delay(1000); + pod = await podsApi.get({name: this.getName(), namespace: this.getNs()}); + if (pod) { + if (pod.getContainerStatuses()?.[0]?.ready) { + console.log("keepAlive() pod found!"); + // restart port forward + await portForwardStore.modify(this, +this.getForwardPort()); + break; + } + } + } + } + } +} + + export class PortForwardStore extends ItemStore { + private storage = createStorage("port_forwards", undefined); + + 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)) { + await Promise.all(savedPortForwards.map(pf => { + const port = new PortForwardItem; + port.clusterId = pf.clusterId; + port.kind = pf.kind; + port.namespace = pf.namespace; + port.name = pf.name; + port.port = pf.port; + port.forwardPort = pf.forwardPort; + return this.add(port); + })); + + this.portForwards = []; + } + } + + @observable selectedItemId?: string; + @observable portForwards: PortForwardItem[]; + + @computed get selectedItem() { + return this.portForwards.find((e: ItemObject) => e.getId() === this.selectedItemId); + } + + watch() { + const disposers: IReactionDisposer[] = [ + reaction(() => this.portForwards, () => this.loadAll()), + ]; + + return () => disposers.forEach((dispose) => dispose()); + } + + loadAll() { + return this.loadItems(async () => { + const response = await apiBase.get(`/port-forwards`, {}); + + const portForwards = response.portForwards?.filter(pf => pf.clusterId == getHostedClusterId()); + this.storage.set(portForwards); + + this.reset(); + portForwards?.forEach(pf => { + const port = new PortForwardItem; + port.clusterId = pf.clusterId; + port.kind = pf.kind; + port.namespace = pf.namespace; + port.name = pf.name; + port.port = pf.port; + port.forwardPort = pf.forwardPort; + port.monitor(); + this.portForwards.push(port); + }) + return this.portForwards; + }); + } + + reset() { + this.portForwards?.forEach(pf => { + if (pf.interval) clearInterval(pf.interval) + }); + this.portForwards = []; + } + + async add(portForward: PortForwardItem) { + await add(portForward); + this.reset(); + } + + async modify(portForward: PortForwardItem, desiredPort: number) { + await remove(portForward); + portForward.forwardPort = desiredPort.toString(); + await add(portForward); + this.reset(); + } + + async remove(portForward: PortForwardItem) { + await remove(portForward); + this.reset(); + } + + async removeSelectedItems() { + return Promise.all(this.selectedItems.map(this.remove)); + } +} + +async function add(portForward: PortForwardItem) { + const response = await apiBase.post(`/pods/${portForward.getNs()}/${portForward.getKind()}/${portForward.getName()}/port-forward/${portForward.getPort()}/${portForward.getForwardPort()}`, {}); + if (response?.port != +portForward.getForwardPort() ) { + console.log(`specified ${portForward.getForwardPort()} got ${response.port}`); + } +} + +async function remove(portForward: PortForwardItem) { + await apiBase.del(`/pods/${portForward.getNs()}/${portForward.getKind()}/${portForward.getName()}/port-forward/${portForward.getPort()}/${portForward.forwardPort}`, {}) + await waitUntilFree(+portForward.getForwardPort(), 200, 1000); +} + +export const portForwardStore = new PortForwardStore(); 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..8acfeba829 --- /dev/null +++ b/src/renderer/components/+network-port-forwards/port-forwards.tsx @@ -0,0 +1,136 @@ +/** + * 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 type { RouteComponentProps } from "react-router-dom"; +import { ItemListLayout } from "../item-object-list/item-list-layout"; +import { PortForwardItem, portForwardStore } from "./port-forward.store"; +import { PortForwardsRouteParams, portForwardsURL } from "../../../common/routes"; +import { navigation } from "../../navigation"; +import { PortForwardDetails } from "./port-forward-details"; +import { PortForwardMenu } from "./port-forward-menu"; + +enum columnId { + name = "name", + namespace = "namespace", + kind = "kind", + port = "port", + forwardPort = "forwardPort", +} + +interface Props extends RouteComponentProps { +} + +@observer +export class PortForwards extends React.Component { + + componentDidMount() { + disposeOnUnmount(this, [ + portForwardStore.watch(), + ]); + } + + get selectedPortForward() { + const { match: { params: { forwardport } } } = this.props; + + return portForwardStore.items.find(pf => { + return pf.getForwardPort() == forwardport; + }); + } + + showDetails = (item: PortForwardItem) => { + navigation.push(portForwardsURL({ + params: { + forwardport: item.getForwardPort(), + } + })); + }; + + hideDetails = () => { + navigation.push(portForwardsURL()); + }; + + 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="Forwarded Ports" + 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) + })} + detailsItem={this.selectedPortForward} + onDetails={this.showDetails} + /> + + + ); + } +} diff --git a/src/renderer/components/+network-services/service-port-component.tsx b/src/renderer/components/+network-services/service-port-component.tsx index 6ad22a9dd3..fe41462808 100644 --- a/src/renderer/components/+network-services/service-port-component.tsx +++ b/src/renderer/components/+network-services/service-port-component.tsx @@ -75,6 +75,10 @@ export class ServicePortComponent extends React.Component { this.waiting = true; + if (!this.forwardPort) { + this.forwardPort = 0; + } + try { const response = await apiBase.post(`/pods/${service.getNs()}/service/${service.getName()}/port-forward/${port.port}/${this.forwardPort}`, {}); @@ -117,7 +121,8 @@ export class ServicePortComponent extends React.Component {
{port.toString()} {" "} - to + + local port: { placeholder={"Random"} onChange={(value) => this.forwardPort = Number(value)} /> -
); } diff --git a/src/renderer/components/+network/network.tsx b/src/renderer/components/+network/network.tsx index 7c177ee2a3..4ade9cf8a7 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: "Forwarded Ports", + component: PortForwards, + url: routes.portForwardsURL(), + routePath: routes.portForwardsRoute.path.toString(), + }); + return tabs; } diff --git a/src/renderer/components/+workloads-pods/pod-container-port.tsx b/src/renderer/components/+workloads-pods/pod-container-port.tsx index e70a80462e..85075f2a1f 100644 --- a/src/renderer/components/+workloads-pods/pod-container-port.tsx +++ b/src/renderer/components/+workloads-pods/pod-container-port.tsx @@ -79,6 +79,10 @@ export class PodContainerPort extends React.Component { this.waiting = true; + if (!this.forwardPort) { + this.forwardPort = 0; + } + try { const response = await apiBase.post(`/pods/${pod.getNs()}/pod/${pod.getName()}/port-forward/${port.containerPort}/${this.forwardPort}`, {}); @@ -123,7 +127,8 @@ export class PodContainerPort extends React.Component {
{text} {" "} - to + + local port: { placeholder={"Random"} onChange={(value) => this.forwardPort = Number(value)} /> -
); } diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 9769db06b7..a7efbe41e3 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 "./+network-port-forwards/port-forward-dialog"; @observer export class App extends React.Component { @@ -231,6 +232,7 @@ export class App extends React.Component { +