Skip to content

Commit 1443aac

Browse files
committed
feat: add port forward dropdown component
1 parent 7aad88c commit 1443aac

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) => `${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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy