mirror of https://github.com/coder/coder.git
615 lines
19 KiB
TypeScript
615 lines
19 KiB
TypeScript
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
|
import CloseIcon from "@mui/icons-material/Close";
|
|
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
|
|
import LockIcon from "@mui/icons-material/Lock";
|
|
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
|
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
|
import SensorsIcon from "@mui/icons-material/Sensors";
|
|
import LoadingButton from "@mui/lab/LoadingButton";
|
|
import Button from "@mui/material/Button";
|
|
import CircularProgress from "@mui/material/CircularProgress";
|
|
import FormControl from "@mui/material/FormControl";
|
|
import Link from "@mui/material/Link";
|
|
import MenuItem from "@mui/material/MenuItem";
|
|
import Select from "@mui/material/Select";
|
|
import Stack from "@mui/material/Stack";
|
|
import TextField from "@mui/material/TextField";
|
|
import { type FormikContextType, useFormik } from "formik";
|
|
import type { FC } from "react";
|
|
import { useQuery, useMutation } from "react-query";
|
|
import * as Yup from "yup";
|
|
import { getAgentListeningPorts } from "api/api";
|
|
import {
|
|
deleteWorkspacePortShare,
|
|
upsertWorkspacePortShare,
|
|
workspacePortShares,
|
|
} from "api/queries/workspaceportsharing";
|
|
import {
|
|
type Template,
|
|
type WorkspaceAgent,
|
|
type WorkspaceAgentListeningPort,
|
|
type WorkspaceAgentPortShareLevel,
|
|
type UpsertWorkspaceAgentPortShareRequest,
|
|
type WorkspaceAgentPortShareProtocol,
|
|
WorkspaceAppSharingLevels,
|
|
} from "api/typesGenerated";
|
|
import {
|
|
HelpTooltipLink,
|
|
HelpTooltipText,
|
|
HelpTooltipTitle,
|
|
} from "components/HelpTooltip/HelpTooltip";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "components/Popover/Popover";
|
|
import { type ClassName, useClassName } from "hooks/useClassName";
|
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
|
import { docs } from "utils/docs";
|
|
import { getFormHelpers } from "utils/formUtils";
|
|
import { portForwardURL } from "utils/portForward";
|
|
|
|
export interface PortForwardButtonProps {
|
|
host: string;
|
|
username: string;
|
|
workspaceName: string;
|
|
workspaceID: string;
|
|
agent: WorkspaceAgent;
|
|
template: Template;
|
|
}
|
|
|
|
export const PortForwardButton: FC<PortForwardButtonProps> = (props) => {
|
|
const { agent } = props;
|
|
const { entitlements, experiments } = useDashboard();
|
|
const paper = useClassName(classNames.paper, []);
|
|
|
|
const portsQuery = useQuery({
|
|
queryKey: ["portForward", agent.id],
|
|
queryFn: () => getAgentListeningPorts(agent.id),
|
|
enabled: agent.status === "connected",
|
|
refetchInterval: 5_000,
|
|
});
|
|
|
|
return (
|
|
<Popover>
|
|
<PopoverTrigger>
|
|
<Button
|
|
disabled={!portsQuery.data}
|
|
size="small"
|
|
variant="text"
|
|
endIcon={<KeyboardArrowDown />}
|
|
css={{ fontSize: 13, padding: "8px 12px" }}
|
|
startIcon={
|
|
portsQuery.data ? (
|
|
<div>
|
|
<span css={styles.portCount}>
|
|
{portsQuery.data.ports.length}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<CircularProgress size={10} />
|
|
)
|
|
}
|
|
>
|
|
Open ports
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent horizontal="right" classes={{ paper }}>
|
|
<PortForwardPopoverView
|
|
{...props}
|
|
listeningPorts={portsQuery.data?.ports}
|
|
portSharingExperimentEnabled={experiments.includes("shared-ports")}
|
|
portSharingControlsEnabled={
|
|
entitlements.features.control_shared_ports.enabled
|
|
}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
const getValidationSchema = (): Yup.AnyObjectSchema =>
|
|
Yup.object({
|
|
port: Yup.number().required().min(9).max(65535),
|
|
share_level: Yup.string().required().oneOf(WorkspaceAppSharingLevels),
|
|
});
|
|
|
|
interface PortForwardPopoverViewProps extends PortForwardButtonProps {
|
|
listeningPorts?: WorkspaceAgentListeningPort[];
|
|
portSharingExperimentEnabled: boolean;
|
|
portSharingControlsEnabled: boolean;
|
|
}
|
|
|
|
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
|
|
|
|
export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
|
host,
|
|
workspaceName,
|
|
workspaceID,
|
|
agent,
|
|
template,
|
|
username,
|
|
listeningPorts,
|
|
portSharingExperimentEnabled,
|
|
portSharingControlsEnabled,
|
|
}) => {
|
|
const theme = useTheme();
|
|
|
|
const sharedPortsQuery = useQuery({
|
|
...workspacePortShares(workspaceID),
|
|
enabled: agent.status === "connected",
|
|
});
|
|
const sharedPorts = sharedPortsQuery.data?.shares || [];
|
|
|
|
const upsertSharedPortMutation = useMutation(
|
|
upsertWorkspacePortShare(workspaceID),
|
|
);
|
|
|
|
const deleteSharedPortMutation = useMutation(
|
|
deleteWorkspacePortShare(workspaceID),
|
|
);
|
|
|
|
// share port form
|
|
const {
|
|
mutateAsync: upsertWorkspacePortShareForm,
|
|
isLoading: isSubmitting,
|
|
error: submitError,
|
|
} = useMutation(upsertWorkspacePortShare(workspaceID));
|
|
const validationSchema = getValidationSchema();
|
|
// TODO: do partial here
|
|
const form: FormikContextType<
|
|
Optional<UpsertWorkspaceAgentPortShareRequest, "port">
|
|
> = useFormik<Optional<UpsertWorkspaceAgentPortShareRequest, "port">>({
|
|
initialValues: {
|
|
agent_name: agent.name,
|
|
port: undefined,
|
|
protocol: "http",
|
|
share_level: "authenticated",
|
|
},
|
|
validationSchema,
|
|
onSubmit: async (values) => {
|
|
// we need port to be optional in the initialValues so it appears empty instead of 0.
|
|
// because of this we need to reset the form to clear the port field manually.
|
|
form.resetForm();
|
|
await form.setFieldValue("port", "");
|
|
|
|
const port = Number(values.port);
|
|
await upsertWorkspacePortShareForm({
|
|
...values,
|
|
port,
|
|
});
|
|
await sharedPortsQuery.refetch();
|
|
},
|
|
});
|
|
const getFieldHelpers = getFormHelpers(form, submitError);
|
|
|
|
// filter out shared ports that are not from this agent
|
|
const filteredSharedPorts = sharedPorts.filter(
|
|
(port) => port.agent_name === agent.name,
|
|
);
|
|
// we don't want to show listening ports if it's a shared port
|
|
const filteredListeningPorts = listeningPorts?.filter((port) => {
|
|
for (let i = 0; i < filteredSharedPorts.length; i++) {
|
|
if (filteredSharedPorts[i].port === port.port) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
// only disable the form if shared port controls are entitled and the template doesn't allow sharing ports
|
|
const canSharePorts =
|
|
portSharingExperimentEnabled &&
|
|
!(portSharingControlsEnabled && template.max_port_share_level === "owner");
|
|
const canSharePortsPublic =
|
|
canSharePorts && template.max_port_share_level === "public";
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
css={{
|
|
padding: 20,
|
|
}}
|
|
>
|
|
<Stack
|
|
direction="row"
|
|
justifyContent="space-between"
|
|
alignItems="start"
|
|
>
|
|
<HelpTooltipTitle>Listening ports</HelpTooltipTitle>
|
|
<HelpTooltipLink href={docs("/networking/port-forwarding#dashboard")}>
|
|
Learn more
|
|
</HelpTooltipLink>
|
|
</Stack>
|
|
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
|
|
{filteredListeningPorts?.length === 0
|
|
? "No open ports were detected."
|
|
: "The listening ports are exclusively accessible to you."}
|
|
</HelpTooltipText>
|
|
<form
|
|
css={styles.newPortForm}
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
const port = Number(formData.get("portNumber"));
|
|
const url = portForwardURL(
|
|
host,
|
|
port,
|
|
agent.name,
|
|
workspaceName,
|
|
username,
|
|
);
|
|
window.open(url, "_blank");
|
|
}}
|
|
>
|
|
<input
|
|
aria-label="Port number"
|
|
name="portNumber"
|
|
type="number"
|
|
placeholder="Connect to port..."
|
|
min={9}
|
|
max={65535}
|
|
required
|
|
css={styles.newPortInput}
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="small"
|
|
variant="text"
|
|
css={{
|
|
paddingLeft: 12,
|
|
paddingRight: 12,
|
|
minWidth: 0,
|
|
}}
|
|
>
|
|
<OpenInNewOutlined
|
|
css={{
|
|
flexShrink: 0,
|
|
width: 14,
|
|
height: 14,
|
|
color: theme.palette.text.primary,
|
|
}}
|
|
/>
|
|
</Button>
|
|
</form>
|
|
<div
|
|
css={{
|
|
paddingTop: 10,
|
|
}}
|
|
>
|
|
{filteredListeningPorts?.map((port) => {
|
|
const url = portForwardURL(
|
|
host,
|
|
port.port,
|
|
agent.name,
|
|
workspaceName,
|
|
username,
|
|
);
|
|
const label =
|
|
port.process_name !== "" ? port.process_name : port.port;
|
|
return (
|
|
<Stack
|
|
key={port.port}
|
|
direction="row"
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
>
|
|
<Link
|
|
underline="none"
|
|
css={styles.portLink}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<SensorsIcon css={{ width: 14, height: 14 }} />
|
|
{label}
|
|
</Link>
|
|
<Stack
|
|
direction="row"
|
|
gap={2}
|
|
justifyContent="flex-end"
|
|
alignItems="center"
|
|
>
|
|
<Link
|
|
underline="none"
|
|
css={styles.portLink}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<span css={styles.portNumber}>{port.port}</span>
|
|
</Link>
|
|
{canSharePorts && (
|
|
<Button
|
|
size="small"
|
|
variant="text"
|
|
onClick={async () => {
|
|
await upsertSharedPortMutation.mutateAsync({
|
|
agent_name: agent.name,
|
|
port: port.port,
|
|
protocol: "http",
|
|
share_level: "authenticated",
|
|
});
|
|
await sharedPortsQuery.refetch();
|
|
}}
|
|
>
|
|
Share
|
|
</Button>
|
|
)}
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
{portSharingExperimentEnabled && (
|
|
<div
|
|
css={{
|
|
padding: 20,
|
|
borderTop: `1px solid ${theme.palette.divider}`,
|
|
}}
|
|
>
|
|
<HelpTooltipTitle>Shared Ports</HelpTooltipTitle>
|
|
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
|
|
{canSharePorts
|
|
? "Ports can be shared with other Coder users or with the public."
|
|
: "This workspace template does not allow sharing ports. Contact a template administrator to enable port sharing."}
|
|
</HelpTooltipText>
|
|
{canSharePorts && (
|
|
<div>
|
|
{filteredSharedPorts?.map((share) => {
|
|
const url = portForwardURL(
|
|
host,
|
|
share.port,
|
|
agent.name,
|
|
workspaceName,
|
|
username,
|
|
);
|
|
const label = share.port;
|
|
return (
|
|
<Stack
|
|
key={share.port}
|
|
direction="row"
|
|
justifyContent="space-between"
|
|
alignItems="center"
|
|
>
|
|
<Link
|
|
underline="none"
|
|
css={styles.portLink}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
{share.share_level === "public" ? (
|
|
<LockOpenIcon css={{ width: 14, height: 14 }} />
|
|
) : (
|
|
<LockIcon css={{ width: 14, height: 14 }} />
|
|
)}
|
|
{label}
|
|
</Link>
|
|
<FormControl size="small" css={styles.protocolFormControl}>
|
|
<Select
|
|
css={styles.shareLevelSelect}
|
|
value={share.protocol}
|
|
onChange={async (event) => {
|
|
await upsertSharedPortMutation.mutateAsync({
|
|
agent_name: agent.name,
|
|
port: share.port,
|
|
protocol: event.target
|
|
.value as WorkspaceAgentPortShareProtocol,
|
|
share_level: share.share_level,
|
|
});
|
|
await sharedPortsQuery.refetch();
|
|
}}
|
|
>
|
|
<MenuItem value="http">HTTP</MenuItem>
|
|
<MenuItem value="https">HTTPS</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<Stack direction="row" justifyContent="flex-end">
|
|
<FormControl
|
|
size="small"
|
|
css={styles.shareLevelFormControl}
|
|
>
|
|
<Select
|
|
css={styles.shareLevelSelect}
|
|
value={share.share_level}
|
|
onChange={async (event) => {
|
|
await upsertSharedPortMutation.mutateAsync({
|
|
agent_name: agent.name,
|
|
port: share.port,
|
|
protocol: share.protocol,
|
|
share_level: event.target
|
|
.value as WorkspaceAgentPortShareLevel,
|
|
});
|
|
await sharedPortsQuery.refetch();
|
|
}}
|
|
>
|
|
<MenuItem value="authenticated">
|
|
Authenticated
|
|
</MenuItem>
|
|
<MenuItem
|
|
value="public"
|
|
disabled={!canSharePortsPublic}
|
|
>
|
|
Public
|
|
</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<Button
|
|
size="small"
|
|
variant="text"
|
|
css={styles.deleteButton}
|
|
onClick={async () => {
|
|
await deleteSharedPortMutation.mutateAsync({
|
|
agent_name: agent.name,
|
|
port: share.port,
|
|
});
|
|
await sharedPortsQuery.refetch();
|
|
}}
|
|
>
|
|
<CloseIcon
|
|
css={{
|
|
width: 14,
|
|
height: 14,
|
|
color: theme.palette.text.primary,
|
|
}}
|
|
/>
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
);
|
|
})}
|
|
<form onSubmit={form.handleSubmit}>
|
|
<Stack
|
|
direction="column"
|
|
gap={2}
|
|
justifyContent="flex-end"
|
|
sx={{
|
|
marginTop: 2,
|
|
}}
|
|
>
|
|
<TextField
|
|
{...getFieldHelpers("port")}
|
|
disabled={isSubmitting}
|
|
label="Port"
|
|
size="small"
|
|
variant="outlined"
|
|
type="number"
|
|
value={form.values.port}
|
|
/>
|
|
<TextField
|
|
{...getFieldHelpers("protocol")}
|
|
disabled={isSubmitting}
|
|
fullWidth
|
|
select
|
|
value={form.values.protocol}
|
|
label="Protocol"
|
|
>
|
|
<MenuItem value="http">HTTP</MenuItem>
|
|
<MenuItem value="https">HTTPS</MenuItem>
|
|
</TextField>
|
|
<TextField
|
|
{...getFieldHelpers("share_level")}
|
|
disabled={isSubmitting}
|
|
fullWidth
|
|
select
|
|
value={form.values.share_level}
|
|
label="Sharing Level"
|
|
>
|
|
<MenuItem value="authenticated">Authenticated</MenuItem>
|
|
<MenuItem value="public" disabled={!canSharePortsPublic}>
|
|
Public
|
|
</MenuItem>
|
|
</TextField>
|
|
<LoadingButton
|
|
variant="contained"
|
|
type="submit"
|
|
loading={isSubmitting}
|
|
disabled={!form.isValid}
|
|
>
|
|
Share Port
|
|
</LoadingButton>
|
|
</Stack>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const classNames = {
|
|
paper: (css, theme) => css`
|
|
padding: 0;
|
|
width: 404px;
|
|
color: ${theme.palette.text.secondary};
|
|
margin-top: 4px;
|
|
`,
|
|
} satisfies Record<string, ClassName>;
|
|
|
|
const styles = {
|
|
portCount: (theme) => ({
|
|
fontSize: 12,
|
|
fontWeight: 500,
|
|
height: 20,
|
|
minWidth: 20,
|
|
padding: "0 4px",
|
|
borderRadius: "50%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: theme.palette.action.selected,
|
|
}),
|
|
|
|
portLink: (theme) => ({
|
|
color: theme.palette.text.primary,
|
|
fontSize: 14,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
fontWeight: 500,
|
|
minWidth: 80,
|
|
}),
|
|
|
|
portNumber: (theme) => ({
|
|
marginLeft: "auto",
|
|
color: theme.palette.text.secondary,
|
|
fontSize: 13,
|
|
fontWeight: 400,
|
|
}),
|
|
|
|
shareLevelSelect: () => ({
|
|
boxShadow: "none",
|
|
".MuiOutlinedInput-notchedOutline": { border: 0 },
|
|
"&.MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": {
|
|
border: 0,
|
|
},
|
|
"&.MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
|
border: 0,
|
|
},
|
|
}),
|
|
|
|
deleteButton: () => ({
|
|
minWidth: 30,
|
|
padding: 0,
|
|
}),
|
|
|
|
newPortForm: (theme) => ({
|
|
border: `1px solid ${theme.palette.divider}`,
|
|
borderRadius: "4px",
|
|
marginTop: 8,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
"&:focus-within": {
|
|
borderColor: theme.palette.primary.main,
|
|
},
|
|
}),
|
|
|
|
newPortInput: (theme) => ({
|
|
fontSize: 14,
|
|
height: 34,
|
|
padding: "0 12px",
|
|
background: "none",
|
|
border: 0,
|
|
outline: "none",
|
|
color: theme.palette.text.primary,
|
|
appearance: "textfield",
|
|
display: "block",
|
|
width: "100%",
|
|
}),
|
|
sharedPortLink: () => ({
|
|
minWidth: 80,
|
|
}),
|
|
protocolFormControl: () => ({
|
|
minWidth: 90,
|
|
}),
|
|
shareLevelFormControl: () => ({
|
|
minWidth: 140,
|
|
}),
|
|
} satisfies Record<string, Interpolation<Theme>>;
|