8000 feat: port forwarding dropdown by code-asher · Pull Request #1824 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

feat: port forwarding dropdown #1824

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: add port forward dropdown component
  • Loading branch information
code-asher committed May 27, 2022
commit d50d77f67847ca32e64f4bc3927965e406bd35ef
11 changes: 11 additions & 0 deletions site/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,14 @@ export interface ReconnectingPTYRequest {
export type WorkspaceBuildTransition = "start" | "stop" | "delete"

export type Message = { message: string }

export interface NetstatPort {
name: string
port: number
}

export interface NetstatResponse {
readonly ports?: NetstatPort[]
readonly error?: string
readonly took?: number
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Story } from "@storybook/react"
import React from "react"
import { PortForwardDropdown, PortForwardDropdownProps } from "./PortForwardDropdown"

export default {
title: "components/PortForwardDropdown",
component: PortForwardDropdown,
}

const Template: Story<PortForwardDropdownProps> = (args: PortForwardDropdownProps) => (
<PortForwardDropdown anchorEl={document.body} urlFormatter={urlFormatter} open {...args} />
)

const urlFormatter = (port: number | string): string => {
return `https://${port}--user--workspace.coder.com`
}

export const Error = Template.bind({})
Error.args = {
netstat: {
error: "Unable to get listening ports",
},
}

export const Loading = Template.bind({})
Loading.args = {}

export const None = Template.bind({})
None.args = {
netstat: {
ports: [],
},
}

export const Excluded = Template.bind({})
Excluded.args = {
netstat: {
ports: [
{
name: "sshd",
port: 22,
},
],
},
}

export const Single = Template.bind({})
Single.args = {
netstat: {
ports: [
{
name: "code-server",
port: 8080,
},
],
},
}

export const Multiple = Template.bind({})
Multiple.args = {
netstat: {
ports: [
{
name: "code-server",
port: 8080,
},
{
name: "coder",
port: 8000,
},
{
name: "coder",
port: 3000,
},
{
name: "node",
port: 8001,
},
{
name: "sshd",
port: 22,
},
],
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { screen } from "@testing-library/react"
import React from "react"
import { render } from "../../testHelpers/renderHelpers"
import { Language, PortForwardDropdown } from "./PortForwardDropdown"

const urlFormatter = (port: number | string): string => {
return `https://${port}--user--workspace.coder.com`
}

describe("PortForwardDropdown", () => {
it("skips known non-http ports", async () => {
// When
const netstat = {
ports: [
{
name: "sshd",
port: 22,
},
{
name: "code-server",
port: 8080,
},
],
}
render(<PortForwardDropdown urlFormatter={urlFormatter} open netstat={netstat} anchorEl={document.body} />)

// Then
let portNameElement = await screen.queryByText(Language.portListing(22, "sshd"))
expect(portNameElement).toBeNull()
portNameElement = await screen.findByText(Language.portListing(8080, "code-server"))
expect(portNameElement).toBeDefined()
})
})
162 changes: 162 additions & 0 deletions site/src/components/PortForwardDropdown/PortForwardDropdown.tsx
6855
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import Button from "@material-ui/core/Button"
import CircularProgress from "@material-ui/core/CircularProgress"
import Link from "@material-ui/core/Link"
import Popover, { PopoverProps } from "@material-ui/core/Popover"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import Typography from "@material-ui/core/Typography"
import OpenInNewIcon from "@material-ui/icons/OpenInNew"
import Alert from "@material-ui/lab/Alert"
import React, { useState } from "react"
import { NetstatPort, NetstatResponse } from "../../api/types"
import { CodeExample } from "../CodeExample/CodeExample"
import { Stack } from "../Stack/Stack"

export const Language = {
title: "Port forward",
automaticPortText:
"Here are the applications we detected are listening on ports in this resource. Click to open them in a new tab.",
manualPortText:
"You can manually port forward this resource by typing the port and your username in the URL like below.",
formPortText: "Or you can use the following form to open the port in a new tab.",
portListing: (port: number, name: string): string => `${port} (${name})`,
portInputLabel: "Port",
formButtonText: "Open URL",
}

export type PortForwardDropdownProps = Pick<PopoverProps, "onClose" | "open" | "anchorEl"> & {
/**
* The netstat response to render. Undefined is taken to mean "loading".
*/
netstat?: NetstatResponse
/**
* Given a port return the URL for accessing that port.
*/
urlFormatter: (port: number | string) => string
}

const portFilter = ({ port }: NetstatPort): boolean => {
if (port === 443 || port === 80) {
// These are standard HTTP ports.
return true
} else if (port <= 1023) {
// Assume a privileged port is probably not being used for HTTP. This will
// catch things like sshd.
return false
}
return true
}

export const PortForwardDropdown: React.FC<PortForwardDropdownProps> = ({ netstat, open, urlFormatter, ...rest }) => {
const styles = useStyles()
const [port, setPort] = useState<number | string>(3000)
const ports = netstat?.ports?.filter(portFilter)

return (
<Popover
open={!!open}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
{...rest}
>
<div className={styles.root}>
<Typography variant="h6" className={styles.title}>
{Language.title}
</Typography>

<Typography className={styles.paragraph}>{Language.automaticPortText}</Typography>

{typeof netstat === "undefined" && (
<div className={styles.loader}>
<CircularProgress size="1rem" />
</div>
)}

{netstat?.error && <Alert severity="error">{netstat.error}</Alert>}

{ports && ports.length > 0 && (
<div className={styles.ports}>
{ports.map(({ port, name }) => (
<Link className={styles.portLink} key={port} href={urlFormatter(port)} target="_blank">
<OpenInNewIcon />
{Language.portListing(port, name)}
</Link>
))}
</div>
)}

{ports && ports.length === 0 && <Alert severity="info">No HTTP ports were detected.</Alert>}

<Typography className={styles.paragraph}>{Language.manualPortText}</Typography>

<CodeExample code={urlFormatter(port)} />

<Typography className={styles.paragraph}>{Language.formPortText}</Typography>

<Stack direction="row">
<TextField
className={styles.textField}
onChange={(event) => setPort(event.target.value)}
value={port}
autoFocus
label={Language.portInputLabel}
variant="outlined"
/>
<Button component={Link} href={urlFormatter(port)} target="_blank" className={styles.linkButton}>
{Language.formButtonText}
</Button>
</Stack>
</div>
</Popover>
)
}

const useStyles = makeStyles((theme) => ({
root: {
padding: `${theme.spacing(3)}px`,
maxWidth: 500,
},
title: {
fontWeight: 600,
},
ports: {
margin: `${theme.spacing(2)}px 0`,
},
portLink: {
alignItems: "center",
color: theme.palette.text.secondary,
display: "flex",

"& svg": {
width: 16,
height: 16,
marginRight: theme.spacing(1.5),
},
},
loader: {
margin: `${theme.spacing(2)}px 0`,
textAlign: "center",
},
paragraph: {
color: theme.palette.text.secondary,
margin: `${theme.spacing(2)}px 0`,
},
textField: {
flex: 1,
margin: 0,
},
linkButton: {
color: "inherit",
flex: 1,

"&:hover": {
textDecoration: "none",
},
},
}))
0