Skip to content

Commit 93f0422

Browse files
committed
feat: add port forward dropdown component
1 parent 7aad88c commit 93f0422

File tree

4 files changed

+291
-0
lines changed

4 files changed

+291
-0
lines changed

site/src/api/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,14 @@ export interface ReconnectingPTYRequest {
1414
export type WorkspaceBuildTransition = "start" | "stop" | "delete"
1515

1616
export type Message = { message: string }
17+
18+
export interface NetstatPort {
19+
name: string
20+
port: number
21+
}
22+
23+
export interface NetstatResponse {
24+
readonly ports?: NetstatPort[]
25+
readonly error?: string
26+
readonly took?: number
27+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Story } from "@storybook/react"
2+
import React from "react"
3+
import { PortForwardDropdown, PortForwardDropdownProps } from "./PortForwardDropdown"
4+
5+
export default {
6+
title: "components/PortForwardDropdown",
7+
component: PortForwardDropdown,
8+
}
9+
10+
const Template: Story<PortForwardDropdownProps> = (args: PortForwardDropdownProps) => (
11+
<PortForwardDropdown anchorEl={null} urlFormatter={urlFormatter} open {...args} />
12+
)
13+
14+
const urlFormatter = (port: number | string): string => {
15+
return `https://${port}--user--workspace.coder.com`
16+
}
17+
18+
export const Error = Template.bind({})
19+
Error.args = {
20+
netstat: {
21+
error: "Unable to get listening ports",
22+
},
23+
}
24+
25+
export const Loading = Template.bind({})
26+
Loading.args = {}
27+
28+
export const None = Template.bind({})
29+
None.args = {
30+
netstat: {
31+
ports: [],
32+
},
33+
}
34+
35+
export const Excluded = Template.bind({})
36+
Excluded.args = {
37+
netstat: {
38+
ports: [
39+
{
40+
name: "sshd",
41+
port: 22,
42+
},
43+
],
44+
},
45+
}
46+
47+
export const Single = Template.bind({})
48+
Single.args = {
49+
netstat: {
50+
ports: [
51+
{
52+
name: "code-server",
53+
port: 8080,
54+
},
55+
],
56+
},
57+
}
58+
59+
export const Multiple = Template.bind({})
60+
Multiple.args = {
61+
netstat: {
62+
ports: [
63+
{
64+
name: "code-server",
65+
port: 8080,
66+
},
67+
{
68+
name: "coder",
69+
port: 8000,
70+
},
71+
{
72+
name: "coder",
73+
port: 3000,
74+
},
75+
{
76+
name: "node",
77+
port: 8001,
78+
},
79+
{
80+
name: "sshd",
81+
port: 22,
82+
},
83+
],
84+
},
85+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { screen } from "@testing-library/react"
2+
import React from "react"
3+
import { render } from "../../testHelpers/renderHelpers"
4+
import { PortForwardDropdown } from "./PortForwardDropdown"
5+
6+
const urlFormatter = (port: number | string): string => {
7+
return `https://${port}--user--workspace.coder.com`
8+
}
9+
10+
describe("PortForwardDropdown", () => {
11+
it("skips known non-http ports", async () => {
12+
// When
13+
const netstat = {
14+
ports: [
15+
{
16+
name: "sshd",
17+
port: 22,
18+
},
19+
{
20+
name: "code-server",
21+
port: 8080,
22+
},
23+
],
24+
}
25+
render(<PortForwardDropdown urlFormatter={urlFormatter} open netstat={netstat} anchorEl={null} />)
26+
27+
// Then
28+
let portNameElement = await screen.findByText("sshd")
29+
expect(portNameElement).not.toBeDefined()
30+
31+
portNameElement = await screen.findByText("code-server")
32+
expect(portNameElement).toBeDefined()
33+
})
34+
})
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import Button from "@material-ui/core/Button"
2+
import CircularProgress from "@material-ui/core/CircularProgress"
3+
import Link from "@material-ui/core/Link"
4+
import Popover, { PopoverProps } from "@material-ui/core/Popover"
5+
import { makeStyles } from "@material-ui/core/styles"
6+
import TextField from "@material-ui/core/TextField"
7+
import Typography from "@material-ui/core/Typography"
8+
import OpenInNewIcon from "@material-ui/icons/OpenInNew"
9+
import Alert from "@material-ui/lab/Alert"
10+
import React, { useState } from "react"
11+
import { NetstatPort, NetstatResponse } from "../../api/types"
12+
import { CodeExample } from "../CodeExample/CodeExample"
13+
import { Stack } from "../Stack/Stack"
14+
15+
const Language = {
16+
title: "Port forward",
17+
automaticPortText:
18+
"Here are the applications we detected are listening on ports in this resource. Click to open them in a new tab.",
19+
manualPortText:
20+
"You can manually port forward this resource by typing the port and your username in the URL like below.",
21+
formPortText: "Or you can use the following form to open the port in a new tab.",
22+
portInputLabel: "Port",
23+
formButtonText: "Open URL",
24+
}
25+
26+
export type PortForwardDropdownProps = Pick<PopoverProps, "onClose" | "open" | "anchorEl"> & {
27+
/**
28+
* The netstat response to render. Undefined is taken to mean "loading".
29+
*/
30+
netstat?: NetstatResponse
31+
/**
32+
* Given a port return the URL for accessing that port.
33+
*/
34+
urlFormatter: (port: number | string) => string
35+
}
36+
37+
const portFilter = ({ port }: NetstatPort): boolean => {
38+
if (port === 443 || port === 80) {
39+
// These are standard HTTP ports.
40+
return true
41+
} else if (port <= 1023) {
42+
// Assume a privileged port is probably not being used for HTTP. This will
43+
// catch things like sshd.
44+
return false
45+
}
46+
return true
47+
}
48+
49+
export const PortForwardDropdown: React.FC<PortForwardDropdownProps> = ({ netstat, open, urlFormatter, ...rest }) => {
50+
const styles = useStyles()
51+
const [port, setPort] = useState<number | string>(3000)
52+
const ports = netstat?.ports?.filter(portFilter)
53+
54+
return (
55+
<Popover
56+
open={!!open}
57+
transformOrigin={{
58+
vertical: "top",
59+
horizontal: "center",
60+
}}
61+
anchorOrigin={{
62+
vertical: "bottom",
63+
horizontal: "center",
64+
}}
65+
{...rest}
66+
>
67+
<div className={styles.root}>
68+
<Typography variant="h6" className={styles.title}>
69+
{Language.title}
70+
</Typography>
71+
72+
<Typography className={styles.paragraph}>{Language.automaticPortText}</Typography>
73+
74+
{typeof netstat === "undefined" && (
75+
<div className={styles.loader}>
76+
<CircularProgress size="1rem" />
77+
</div>
78+
)}
79+
80+
{netstat?.error && <Alert severity="error">{netstat.error}</Alert>}
81+
82+
{ports && ports.length > 0 && (
83+
<div className={styles.ports}>
84+
{ports.map(({ port, name }) => (
85+
<Link className={styles.portLink} key={port} href={urlFormatter(port)} target="_blank">
86+
<OpenInNewIcon />
87+
{port} ({name})
88+
</Link>
89+
))}
90+
</div>
91+
)}
92+
93+
{ports && ports.length === 0 && <Alert severity="info">No HTTP ports were detected.</Alert>}
94+
95+
<Typography className={styles.paragraph}>{Language.manualPortText}</Typography>
96+
97+
<CodeExample code={urlFormatter(port)} />
98+
99+
<Typography className={styles.paragraph}>{Language.formPortText}</Typography>
100+
101+
<Stack direction="row">
102+
<TextField
103+
className={styles.textField}
104+
onChange={(event) => setPort(event.target.value)}
105+
value={port}
106+
autoFocus
107+
label={Language.portInputLabel}
108+
variant="outlined"
109+
/>
110+
<Button component={Link} href={urlFormatter(port)} target="_blank" className={styles.linkButton}>
111+
{Language.formButtonText}
112+
</Button>
113+
</Stack>
114+
</div>
115+
</Popover>
116+
)
117+
}
118+
119+
const useStyles = makeStyles((theme) => ({
120+
root: {
121+
padding: `${theme.spacing(3)}px`,
122+
maxWidth: 500,
123+
},
124+
title: {
125+
fontWeight: 600,
126+
},
127+
ports: {
128+
margin: `${theme.spacing(2)}px 0`,
129+
},
130+
portLink: {
131+
alignItems: "center",
132+
color: theme.palette.text.secondary,
133+
display: "flex",
134+
135+
"& svg": {
136+
width: 16,
137+
height: 16,
138+
marginRight: theme.spacing(1.5),
139+
},
140+
},
141+
loader: {
142+
margin: `${theme.spacing(2)}px 0`,
143+
textAlign: "center",
144+
},
145+
paragraph: {
146+
color: theme.palette.text.secondary,
147+
margin: `${theme.spacing(2)}px 0`,
148+
},
149+
textField: {
150+
flex: 1,
151+
margin: 0,
152+
},
153+
linkButton: {
154+
color: "inherit",
155+
flex: 1,
156+
157+
"&:hover": {
158+
textDecoration: "none",
159+
},
160+
},
161+
}))

0 commit comments

Comments
 (0)