Feature/port forward dashboard (#3922)

* Added ability to add custom port on pod and service port forwarding (#3295)

* Added ability to add custom port on pod and service port forwarding

Signed-off-by: rdeepc <12953177+rdeepc@users.noreply.github.com>

* Added ability to add custom port on pod and service port forwarding

- pod-container-port fixed init async and active port checking
- service-port-component fixed init async and active port checking
- port-forward-route promise and response fix

Signed-off-by: rdeepc <12953177+rdeepc@users.noreply.github.com>

* Added ability to add custom port on pod and service port forwarding

Signed-off-by: rdeepc <12953177+rdeepc@users.noreply.github.com>

* - Added Custom Port Selection for port forwarding
- Implemented Random Port if custom port is not provided

Signed-off-by: rdeepc <12953177+rdeepc@users.noreply.github.com>

Co-authored-by: Saumya Shovan Roy <saumyashovanroy@gmail.com>

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

* refactor

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

* more refactoring, don't always open port-forwards in browser, refined reused port-forward dialog

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

* removed unimplemented forwarded port details page,modified logging

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

* addressed some review comments

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

* made port and forwardPort query params and cleaned up port-forward routing paths

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

* address more review comments and change dashboard tab name to 'Port Forwarding'

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

* changed port and forwardPort fields to be Numbers

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

* removed extraneous reset() call, reorder field declarations

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

* port-forward now gets the bundled kubectl path without going through the 'ensureKubectl' hoops

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

* more cleanup/tweaking

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

* fix bug where port-forward info did not update on pod details page when different pod (in same deployment?) is clicked

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

Co-authored-by: Saumya Shovan Roy (Deep) <12953177+rdeepc@users.noreply.github.com>
Co-authored-by: Saumya Shovan Roy <saumyashovanroy@gmail.com>
This commit is contained in:
authored and GitHub committed 2021-10-07 15:51:26 -04:00
1 parent e10c13cdf4
commit 07c8177a97
22 files changed
+1137 -73

No files matched your search

+24
View File
@@ -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";
@@ -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;
} }
}
@@ -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<DialogProps> {
}
interface PortForwardDialogOpenOptions {
openInBrowser: boolean
}
const dialogState = observable.object({
isOpen: false,
data: null as ForwardedPort,
openInBrowser: false
});
@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: 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 (
<>
<div className="flex gaps align-center">
<div className="input-container flex align-center">
<div className="current-port" data-testid="current-port">
Local port to forward from:
</div>
<Input className="portInput"
type="number"
min="0"
max="65535"
value={this.desiredPort === 0 ? "" : String(this.desiredPort)}
placeholder={"Random"}
onChange={this.changePort}
/>
</div>
</div>
</>
);
}
render() {
const { className, ...dialogProps } = this.props;
const resourceName = this.portForward?.name ?? "";
const header = (
<h5>
Port Forwarding for <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.startPortForward}
nextLabel={this.currentPort === 0 ? "Start" : "Restart"}
disabledNext={!this.ready}
>
{this.renderContents()}
</WizardStep>
</Wizard>
</Dialog>
);
}
}
@@ -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,
];
}
}
@@ -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<PortForwardItem> {
private storage = createStorage<ForwardedPort[] | undefined>("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<number> {
let response: PortForwardResult;
try {
response = await apiBase.post<PortForwardResult>(`/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<number> {
let response: PortForwardResult;
try {
response = await apiBase.get<PortForwardResult>(`/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<number> {
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<ForwardedPort[]> {
try {
const response = await apiBase.get<PortForwardsResult>(`/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();