mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: Add port forward button (#4167)
This commit is contained in:
@@ -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
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"versions": ["main", "v0.8.1", "v0.7.12"],
|
||||
"versions": [
|
||||
"main",
|
||||
"v0.8.1",
|
||||
"v0.7.12"
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"title": "About",
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## SSH
|
||||
|
||||
First, [configure SSH](../ides.md#ssh-configuration) on your
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user