mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: Add port forward button (#4167)
This commit is contained in:
@@ -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