feat: Add port forward button (#4167)

This commit is contained in:
Bruno Quaresma
2022-09-26 14:56:17 +00:00
committed by GitHub
parent 413bfb8d58
commit c37ecdb9ff
11 changed files with 248 additions and 6 deletions
+9
View File
@@ -14,6 +14,15 @@ should not be localhost.
> Access URL should be a external IP address or domain with DNS records pointing to Coder.
## Wildcard access URL
`CODER_WILDCARD_ACCESS_URL` is necessary for [port forwarding](../networking/port-forwarding.md#dashboard)
via the dashboard or running [coder_apps](../templates.md#coder-apps) on an absolute path. Set this to a wildcard
subdomain that resolves to Coder (e.g. `*.coder.example.com`).
> If you are providing TLS certificates directly to the Coder server, you must use a single certificate for the
> root and wildcard domains. Multi-certificate support [is planned](https://github.com/coder/coder/pull/4150).
## PostgreSQL Database
Coder uses a PostgreSQL database to store users, workspace metadata, and other deployment information.
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

+5 -1
View File
@@ -1,5 +1,9 @@
{
"versions": ["main", "v0.8.1", "v0.7.12"],
"versions": [
"main",
"v0.8.1",
"v0.7.12"
],
"routes": [
{
"title": "About",
+12 -1
View File
@@ -4,9 +4,10 @@ Port forwarding lets developers securely access processes on their Coder
workspace from a local machine. A common use case is testing web
applications in a browser.
There are two ways to forward ports in Coder:
There are three ways to forward ports in Coder:
- The `coder port-forward` command
- Dashboard
- SSH
The `coder port-forward` command is generally more performant.
@@ -21,6 +22,16 @@ coder port-forward myworkspace --tcp 8000:8080
For more examples, see `coder port-forward --help`.
## Dashboard
> To enable port forwarding via the dashboard, Coder must be configured with a
> [wildcard access URL](./admin/configure#wildcard-access-url).
Use the "Port forward" button in the dashboard to access ports
running on your workspace.
![Port forwarding in the UI](../images/port-forward-dashboard.png)
## SSH
First, [configure SSH](../ides.md#ssh-configuration) on your
+5
View File
@@ -495,3 +495,8 @@ export const getTemplateDAUs = async (
const response = await axios.get(`/api/v2/templates/${templateId}/daus`)
return response.data
}
export const getApplicationsHost = async (): Promise<TypesGen.GetAppHostResponse> => {
const response = await axios.get(`/api/v2/applications/host`)
return response.data
}
@@ -0,0 +1,155 @@
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import Popover from "@material-ui/core/Popover"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import OpenInNewOutlined from "@material-ui/icons/OpenInNewOutlined"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { Stack } from "components/Stack/Stack"
import { useRef, useState } from "react"
import { colors } from "theme/colors"
import { CodeExample } from "../CodeExample/CodeExample"
import { HelpTooltipLink, HelpTooltipLinksGroup, HelpTooltipText } from "../Tooltips/HelpTooltip"
export interface PortForwardButtonProps {
host: string
username: string
workspaceName: string
agentName: string
}
const EnabledView: React.FC<PortForwardButtonProps> = (props) => {
const { host, workspaceName, agentName, username } = props
const styles = useStyles()
const [port, setPort] = useState("3000")
const { location } = window
const urlExample = `${location.protocol}//${port}--${agentName}--${workspaceName}--${username}.${host}`
return (
<Stack direction="column" spacing={1}>
<HelpTooltipText>
Access ports running on the agent with the <strong>port, agent name, workspace name</strong>{" "}
and <strong>your username</strong> URL schema, as shown below.
</HelpTooltipText>
<CodeExample code={urlExample} />
<HelpTooltipText>Use the form to open applications in a new tab.</HelpTooltipText>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
label="Port"
type="number"
value={port}
className={styles.portField}
onChange={(e) => {
setPort(e.currentTarget.value)
}}
/>
<Link
underline="none"
href={urlExample}
target="_blank"
rel="noreferrer"
className={styles.openUrlButton}
>
<Button>Open URL</Button>
</Link>
</Stack>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://coder.com/docs/coder-oss/latest/networking/port-forward#dashboard">
Learn more about port forward
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</Stack>
)
}
const DisabledView: React.FC<PortForwardButtonProps> = () => {
return (
<Stack direction="column" spacing={1}>
<HelpTooltipText>
<strong>Your deployment does not have port forward enabled.</strong> See the docs for more
details.
</HelpTooltipText>
<HelpTooltipLinksGroup>
<HelpTooltipLink href="https://coder.com/docs/coder-oss/latest/networking/port-forwarding#dashboard">
Learn more about port forward
</HelpTooltipLink>
</HelpTooltipLinksGroup>
</Stack>
)
}
export const PortForwardButton: React.FC<PortForwardButtonProps> = (props) => {
const { host } = props
const anchorRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const id = isOpen ? "schedule-popover" : undefined
const styles = useStyles()
const onClose = () => {
setIsOpen(false)
}
return (
<>
<Button
startIcon={<OpenInNewOutlined />}
size="small"
ref={anchorRef}
onClick={() => {
setIsOpen(true)
}}
>
Port forward
</Button>
<Popover
classes={{ paper: styles.popoverPaper }}
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onClose={onClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<ChooseOne>
<Cond condition={host !== ""}>
<EnabledView {...props} />
</Cond>
<Cond condition={host === ""}>
<DisabledView {...props} />
</Cond>
</ChooseOne>
</Popover>
</>
)
}
const useStyles = makeStyles((theme) => ({
popoverPaper: {
padding: `${theme.spacing(2.5)}px ${theme.spacing(3.5)}px ${theme.spacing(3.5)}px`,
width: theme.spacing(46),
color: theme.palette.text.secondary,
marginTop: theme.spacing(0.25),
},
openUrlButton: {
flexShrink: 0,
},
portField: {
// The default border don't contrast well with the popover
"& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": {
borderColor: colors.gray[10],
},
},
}))
@@ -10,6 +10,7 @@ import { Skeleton } from "@material-ui/lab"
import useTheme from "@material-ui/styles/useTheme"
import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows"
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { PortForwardButton } from "components/PortForwardButton/PortForwardButton"
import { TableCellDataPrimary } from "components/TableCellData/TableCellData"
import { FC, useState } from "react"
import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace"
@@ -42,6 +43,7 @@ interface ResourcesProps {
canUpdateWorkspace: boolean
buildInfo?: BuildInfoResponse | undefined
hideSSHButton?: boolean
applicationsHost?: string
}
export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
@@ -51,6 +53,7 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
canUpdateWorkspace,
buildInfo,
hideSSHButton,
applicationsHost,
}) => {
const styles = useStyles()
const theme: Theme = useTheme()
@@ -150,6 +153,14 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
<div className={styles.accessLinks}>
{canUpdateWorkspace && agent.status === "connected" && (
<>
{applicationsHost !== undefined && (
<PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
{!hideSSHButton && (
<SSHButton
workspaceName={workspace.name}
@@ -46,6 +46,7 @@ export interface WorkspaceProps {
hideSSHButton?: boolean
workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>>
buildInfo?: TypesGen.BuildInfoResponse
applicationsHost?: string
}
/**
@@ -66,6 +67,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
workspaceErrors,
hideSSHButton,
buildInfo,
applicationsHost,
}) => {
const styles = useStyles()
const navigate = useNavigate()
@@ -140,6 +142,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
canUpdateWorkspace={canUpdateWorkspace}
buildInfo={buildInfo}
hideSSHButton={hideSSHButton}
applicationsHost={applicationsHost}
/>
)}
@@ -29,7 +29,6 @@ export const WorkspacePage: FC = () => {
const { t } = useTranslation("workspacePage")
const xServices = useContext(XServiceContext)
const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility)
const [workspaceState, workspaceSend] = useMachine(workspaceMachine)
const {
workspace,
@@ -43,13 +42,11 @@ export const WorkspacePage: FC = () => {
checkPermissionsError,
buildError,
cancellationError,
applicationsHost,
} = workspaceState.context
const canUpdateWorkspace = Boolean(permissions?.updateWorkspace)
const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine)
const [buildInfoState] = useActor(xServices.buildInfoXService)
const styles = useStyles()
/**
@@ -133,6 +130,7 @@ export const WorkspacePage: FC = () => {
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
}}
buildInfo={buildInfoState.context.buildInfo}
applicationsHost={applicationsHost}
/>
<DeleteDialog
entity="workspace"
+5
View File
@@ -168,4 +168,9 @@ export const handlers = [
rest.get("/api/v2/audit/count", (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ count: 1000 }))
}),
// Applications host
rest.get("/api/v2/applications/host", (req, res, ctx) => {
return res(ctx.status(200), ctx.json({ host: "dev.coder.com" }))
}),
]
@@ -1,3 +1,4 @@
import { getErrorMessage } from "api/errors"
import { assign, createMachine, send } from "xstate"
import * as API from "../../api/api"
import * as Types from "../../api/types"
@@ -61,6 +62,8 @@ export interface WorkspaceContext {
// permissions
permissions?: Permissions
checkPermissionsError?: Error | unknown
// applications
applicationsHost?: string
}
export type WorkspaceEvent =
@@ -139,6 +142,9 @@ export const workspaceMachine = createMachine(
checkPermissions: {
data: TypesGen.AuthorizationResponse
}
getApplicationsHost: {
data: TypesGen.GetAppHostResponse
}
},
},
initial: "idle",
@@ -391,6 +397,30 @@ export const workspaceMachine = createMachine(
},
},
},
applications: {
initial: "gettingApplicationsHost",
states: {
gettingApplicationsHost: {
invoke: {
src: "getApplicationsHost",
onDone: {
target: "success",
actions: ["assignApplicationsHost"],
},
onError: {
target: "error",
actions: ["displayApplicationsHostError"],
},
},
},
error: {
type: "final",
},
success: {
type: "final",
},
},
},
},
},
error: {
@@ -494,6 +524,14 @@ export const workspaceMachine = createMachine(
clearGetBuildsError: assign({
getBuildsError: (_) => undefined,
}),
// Applications
assignApplicationsHost: assign({
applicationsHost: (_, { data }) => data.host,
}),
displayApplicationsHostError: (_, { data }) => {
const message = getErrorMessage(data, "Error getting the applications host.")
displayError(message)
},
},
guards: {
moreBuildsAvailable,
@@ -603,6 +641,9 @@ export const workspaceMachine = createMachine(
throw Error("Cannot check permissions workspace id")
}
},
getApplicationsHost: async () => {
return API.getApplicationsHost()
},
},
},
)