Skip to content

Commit d50d77f

Browse files
committed
feat: add port forward dropdown component
1 parent d1496ed commit d50d77f

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={document.body} 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { screen } from "@testing-library/react"
2+
import React from "react"
3+
import { render } from "../../testHelpers/renderHelpers"
4+
import { Language, 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={document.body} />)
26+
27+
// Then
28+
let portNameElement = await screen.queryByText(Language.portListing(22, "sshd"))
29+
expect(portNameElement).toBeNull()
30+
portNameElement = await screen.findByText(Language.portListing(8080, "code-server"))
31+
expect(portNameElement).toBeDefined()
32+
})
33+
})
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
export 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+
portListing: (port: number, name: string): string => `${port} (${name})`,
23+
portInputLabel: "Port",
24+
formButtonText: "Open URL",
25+
}
26+
27+
export type PortForwardDropdownProps = Pick<PopoverProps, "onClose" | "open" | "anchorEl"> & {
28+
/**
29+
* The netstat response to render. Undefined is taken to mean "loading".
30+
*/
31+
netstat?: NetstatResponse
32+
/**
33+
* Given a port return the URL for accessing that port.
34+
*/
35+
urlFormatter: (port: number | string) => string
36+
}
37+
38+
const portFilter = ({ port }: NetstatPort): boolean => {
39+
if (port === 443 || port === 80) {
40+
// These are standard HTTP ports.
41+
return true
42+
} else if (port <= 1023) {
43+
// Assume a privileged port is probably not being used for HTTP. This will
44+
// catch things like sshd.
45+
return false
46+
}
47+
return true
48+
}
49+
50+
export const PortForwardDropdown: React.FC<PortForwardDropdownProps> = ({ netstat, open, urlFormatter, ...rest }) => {
51+
const styles = useStyles()
52+
const [port, setPort] = useState<number | string>(3000)
53+
const ports = netstat?.ports?.filter(portFilter)
54+
55+
return (
56+
<Popover
57+
open={!!open}
58+
transformOrigin={{
59+
vertical: "top",
60+
horizontal: "center",
61+
}}
62+
anchorOrigin={{
63+
vertical: "bottom",
64+
horizontal: "center",
65+
}}
66+
{...rest}
67+
>
68+
<div className={styles.root}>
69+
<Typography variant="h6" className={styles.title}>
70+
{Language.title}
71+
</Typography>
72+
73+
<Typography className={styles.paragraph}>{Language.automaticPortText}</Typography>
74+
75+
{typeof netstat === "undefined" && (
76+
<div className={styles.loader}>
77+
<CircularProgress size="1rem" />
78+
</div>
79+
)}
80+
81+
{netstat?.error && <Alert severity="error">{netstat.error}</Alert>}
82+
83+
{ports && ports.length > 0 && (
84+
<div className={styles.ports}>
85+
{ports.map(({ port, name }) => (
86+
<Link className={styles.portLink} key={port} href={urlFormatter(port)} target="_blank">
87+
<OpenInNewIcon />
88+
{Language.portListing(port, name)}
89+
</Link>
90+
))}
91+
</div>
92+
)}
93+
94+
{ports && ports.length === 0 && <Alert severity="info">No HTTP ports were detected.</Alert>}
95+
96+
<Typography className={styles.paragraph}>{Language.manualPortText}</Typography>
97+
98+
<CodeExample code={urlFormatter(port)} />
99+
100+
<Typography className={styles.paragraph}>{Language.formPortText}</Typography>
101+
102+
<Stack direction="row">
103+
<TextField
104+
className={styles.textField}
105+
onChange={(event) => setPort(event.target.value)}
106+
value={port}
107+
autoFocus
108+
label={Language.portInputLabel}
109+
variant="outlined"
110+
/>
111+
<Button component={Link} href={urlFormatter(port)} target="_blank" className={styles.linkButton}>
112+
{Language.formButtonText}
113+
</Button>
114+
</Stack>
115+
</div>
116+
</Popover>
117+
)
118+
}
119+
120+
const useStyles = makeStyles((theme) => ({
121+
root: {
122+
padding: `${theme.spacing(3)}px`,
123+
maxWidth: 500,
124+
},
125+
title: {
126+
fontWeight: 600,
127+
},
128+
ports: {
129+
margin: `${theme.spacing(2)}px 0`,
130+
},
131+
portLink: {
132+
alignItems: "center",
133+
color: theme.palette.text.secondary,
134+
display: "flex",
135+
136+
"& svg": {
137+
width: 16,
138+
height: 16,
139+
marginRight: theme.spacing(1.5),
140+
},
141+
},
142+
loader: {
143+
margin: `${theme.spacing(2)}px 0`,
144+
textAlign: "center",
145+
},
146+
paragraph: {
147+
color: theme.palette.text.secondary,
148+
margin: `${theme.spacing(2)}px 0`,
149+
},
150+
textField: {
151+
flex: 1,
152+
margin: 0,
153+
},
154+
linkButton: {
155+
color: "inherit",
156+
flex: 1,
157+
158+
"&:hover": {
159+
textDecoration: "none",
160+
},
161+
},
162+
}))

0 commit comments

Comments
 (0)