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

cherry-pick of hackweek work plus merge conflicts/build errors

added a route to get all port forwards

    Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

    Added Forwarded Ports to cluster dashboard

    Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

    working port-forward page (open, edit, remove)

    Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

    added local storage to the port-forward store

    Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

    automatically restore port-forward after pod is restarted

    Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

    start port-forwards using random local port by default, rearranged pod and service port-forward UI

    Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>
This commit is contained in:
Jim Ehrismann 2021-07-26 23:38:32 -04:00
parent ef52e4e9e2
commit ff7084ec81
18 changed files with 914 additions and 6 deletions

View File

@ -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";

View File

@ -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())
};

View File

@ -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<PortForwardsRouteParams>(portForwardsRoute.path);

View File

@ -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

View File

@ -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;

View File

@ -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";

View File

@ -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
}
}

View File

@ -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<Props> {
render() {
return (
<div className="PortForwardDetails">
</div>
);
}
}

View File

@ -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;
}
}
}
}

View File

@ -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<DialogProps> {
}
const dialogState = observable.object({
isOpen: false,
data: null as PortForwardItem,
});
@observer
export class PortForwardDialog extends Component<Props> {
@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 (
<>
<div className="flex gaps align-center">
<div className="input-container flex align-center">
<div className="current-port" data-testid="current-port">
Current port: {this.currentPort}
</div>
<Input
required autoFocus
iconLeft="import_export"
placeholder="Desired port"
trim
validators={isNumber}
onChange={v => this.desiredPort = +v}
/>
</div>
</div>
<div className="warning" data-testid="warning">
<Icon material="warning"/>
Current port-forwarding will be removed and a new one will be started
</div>
</>
);
}
render() {
const { className, ...dialogProps } = this.props;
const resourceName = this.portForward ? this.portForward.getName() : "";
const header = (
<h5>
Change Port <span>{resourceName}</span>
</h5>
);
return (
<Dialog
{...dialogProps}
isOpen={dialogState.isOpen}
className={cssNames("PortForwardDialog", className)}
onOpen={this.onOpen}
onClose={this.onClose}
close={this.close}
>
<Wizard header={header} done={this.close}>
<WizardStep
contentClass="flex gaps column"
next={this.changePort}
nextLabel="Change Port"
disabledNext={!this.ready}
>
{this.renderContents()}
</WizardStep>
</Wizard>
</Dialog>
);
}
}

View File

@ -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<Props> {
@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 (
<>
<MenuItem onClick={this.openInBrowser}>
<Icon material="open_in_browser" interactive={toolbar} tooltip="Open in browser"/>
<span className="title">Open</span>
</MenuItem>
<MenuItem onClick={() => PortForwardDialog.open(portForward)}>
<Icon material="edit" tooltip="Change port" interactive={toolbar}/>
<span className="title">Edit</span>
</MenuItem>
</>
);
}
render() {
const { className, ...menuProps } = this.props;
return (
<MenuActions
{...menuProps}
className={cssNames("PortForwardMenu", className)}
removeAction={this.remove}
>
{this.renderContent()}
</MenuActions>
);
}
}

View File

@ -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<PortForwardItem> {
private storage = createStorage<ForwardedPort[] | undefined>("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<PortForwardsResult>(`/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<PortForwardResult>(`/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();

View File

@ -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;
}
}
}

View File

@ -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<PortForwardsRouteParams> {
}
@observer
export class PortForwards extends React.Component<Props> {
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 (
<div>
<>Stop forwarding from <b>{forwardPorts}</b>?</>
</div>
);
}
render() {
return (
<>
<ItemListLayout
isConfigurable
tableId="port_forwards"
className="PortForwards" store={portForwardStore}
sortingCallbacks={{
[columnId.name]: item => 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 => (
<PortForwardMenu
portForward={pf}
removeConfirmationMessage={this.renderRemoveDialogMessage([pf])}
/>
)}
customizeRemoveDialog={selectedItems => ({
message: this.renderRemoveDialogMessage(selectedItems)
})}
detailsItem={this.selectedPortForward}
onDetails={this.showDetails}
/>
<PortForwardDetails
portForward={this.selectedPortForward}
hideDetails={this.hideDetails}
/>
</>
);
}
}

View File

@ -75,6 +75,10 @@ export class ServicePortComponent extends React.Component<Props> {
this.waiting = true;
if (!this.forwardPort) {
this.forwardPort = 0;
}
try {
const response = await apiBase.post<PortForwardResult>(`/pods/${service.getNs()}/service/${service.getName()}/port-forward/${port.port}/${this.forwardPort}`, {});
@ -117,7 +121,8 @@ export class ServicePortComponent extends React.Component<Props> {
<div className={cssNames("ServicePortComponent", { waiting: this.waiting })}>
{port.toString()}
{" "}
<text>to</text>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop":"Forward"} </Button>
<text> local port:</text>
<Input className={"portInput"}
type="number"
min="0"
@ -127,7 +132,6 @@ export class ServicePortComponent extends React.Component<Props> {
placeholder={"Random"}
onChange={(value) => this.forwardPort = Number(value)}
/>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop":"Forward"} </Button>
</div>
);
}

View File

@ -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;
}

View File

@ -79,6 +79,10 @@ export class PodContainerPort extends React.Component<Props> {
this.waiting = true;
if (!this.forwardPort) {
this.forwardPort = 0;
}
try {
const response = await apiBase.post<PortForwardResult>(`/pods/${pod.getNs()}/pod/${pod.getName()}/port-forward/${port.containerPort}/${this.forwardPort}`, {});
@ -123,7 +127,8 @@ export class PodContainerPort extends React.Component<Props> {
<div className={cssNames("PodContainerPort", { waiting: this.waiting })}>
{text}
{" "}
<text>to</text>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop":"Forward"} </Button>
<text> local port:</text>
<Input className={"portInput"}
type="number"
min="0"
@ -133,7 +138,6 @@ export class PodContainerPort extends React.Component<Props> {
placeholder={"Random"}
onChange={(value) => this.forwardPort = Number(value)}
/>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop":"Forward"} </Button>
</div>
);
}

View File

@ -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 {
<StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/>
<CronJobTriggerDialog/>
<PortForwardDialog/>
<CommandContainer clusterId={App.clusterId}/>
</ErrorBoundary>
</Router>