mirror of https://github.com/coder/coder.git
feat: add listening ports protocol selector (#12915)
This commit is contained in:
parent
49689162bb
commit
3ab5a51ec2
|
@ -16,7 +16,7 @@ import Stack from "@mui/material/Stack";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { type FormikContextType, useFormik } from "formik";
|
import { type FormikContextType, useFormik } from "formik";
|
||||||
import type { FC } from "react";
|
import { useState, type FC } from "react";
|
||||||
import { useQuery, useMutation } from "react-query";
|
import { useQuery, useMutation } from "react-query";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import { getAgentListeningPorts } from "api/api";
|
import { getAgentListeningPorts } from "api/api";
|
||||||
|
@ -48,7 +48,11 @@ import { type ClassName, useClassName } from "hooks/useClassName";
|
||||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
import { docs } from "utils/docs";
|
import { docs } from "utils/docs";
|
||||||
import { getFormHelpers } from "utils/formUtils";
|
import { getFormHelpers } from "utils/formUtils";
|
||||||
import { portForwardURL } from "utils/portForward";
|
import {
|
||||||
|
getWorkspaceListeningPortsProtocol,
|
||||||
|
portForwardURL,
|
||||||
|
saveWorkspaceListeningPortsProtocol,
|
||||||
|
} from "utils/portForward";
|
||||||
|
|
||||||
export interface PortForwardButtonProps {
|
export interface PortForwardButtonProps {
|
||||||
host: string;
|
host: string;
|
||||||
|
@ -135,6 +139,9 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||||
portSharingControlsEnabled,
|
portSharingControlsEnabled,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const [listeningPortProtocol, setListeningPortProtocol] = useState(
|
||||||
|
getWorkspaceListeningPortsProtocol(workspaceID),
|
||||||
|
);
|
||||||
|
|
||||||
const sharedPortsQuery = useQuery({
|
const sharedPortsQuery = useQuery({
|
||||||
...workspacePortShares(workspaceID),
|
...workspacePortShares(workspaceID),
|
||||||
|
@ -189,15 +196,9 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||||
(port) => port.agent_name === agent.name,
|
(port) => port.agent_name === agent.name,
|
||||||
);
|
);
|
||||||
// we don't want to show listening ports if it's a shared port
|
// we don't want to show listening ports if it's a shared port
|
||||||
const filteredListeningPorts = listeningPorts?.filter((port) => {
|
const filteredListeningPorts = (listeningPorts ?? []).filter((port) =>
|
||||||
for (let i = 0; i < filteredSharedPorts.length; i++) {
|
filteredSharedPorts.every((sharedPort) => sharedPort.port !== port.port),
|
||||||
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
|
// only disable the form if shared port controls are entitled and the template doesn't allow sharing ports
|
||||||
const canSharePorts =
|
const canSharePorts =
|
||||||
portSharingExperimentEnabled &&
|
portSharingExperimentEnabled &&
|
||||||
|
@ -224,95 +225,117 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<header
|
<Stack
|
||||||
css={(theme) => ({
|
direction="column"
|
||||||
|
css={{
|
||||||
padding: 20,
|
padding: 20,
|
||||||
paddingBottom: 10,
|
}}
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
background: theme.palette.background.paper,
|
|
||||||
// For some reason the Share button label has a higher z-index than
|
|
||||||
// the header. Probably some tricky stuff from MUI.
|
|
||||||
zIndex: 1,
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
direction="row"
|
direction="row"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
alignItems="start"
|
alignItems="start"
|
||||||
>
|
>
|
||||||
<HelpTooltipTitle>Listening ports</HelpTooltipTitle>
|
<HelpTooltipTitle>Listening Ports</HelpTooltipTitle>
|
||||||
<HelpTooltipLink
|
<HelpTooltipLink
|
||||||
href={docs("/networking/port-forwarding#dashboard")}
|
href={docs("/networking/port-forwarding#dashboard")}
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
</HelpTooltipLink>
|
</HelpTooltipLink>
|
||||||
</Stack>
|
</Stack>
|
||||||
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
|
<Stack direction="column" gap={1}>
|
||||||
{filteredListeningPorts?.length === 0
|
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
|
||||||
? "No open ports were detected."
|
The listening ports are exclusively accessible to you. Selecting
|
||||||
: "The listening ports are exclusively accessible to you."}
|
HTTP/S will change the protocol for all listening ports.
|
||||||
</HelpTooltipText>
|
</HelpTooltipText>
|
||||||
<form
|
<Stack
|
||||||
css={styles.newPortForm}
|
direction="row"
|
||||||
onSubmit={(e) => {
|
gap={2}
|
||||||
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={{
|
css={{
|
||||||
paddingLeft: 12,
|
paddingBottom: 8,
|
||||||
paddingRight: 12,
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OpenInNewOutlined
|
<FormControl size="small" css={styles.protocolFormControl}>
|
||||||
css={{
|
<Select
|
||||||
flexShrink: 0,
|
css={styles.listeningPortProtocol}
|
||||||
width: 14,
|
value={listeningPortProtocol}
|
||||||
height: 14,
|
onChange={async (event) => {
|
||||||
color: theme.palette.text.primary,
|
const selectedProtocol = event.target.value as
|
||||||
|
| "http"
|
||||||
|
| "https";
|
||||||
|
setListeningPortProtocol(selectedProtocol);
|
||||||
|
saveWorkspaceListeningPortsProtocol(
|
||||||
|
workspaceID,
|
||||||
|
selectedProtocol,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="http">HTTP</MenuItem>
|
||||||
|
<MenuItem value="https">HTTPS</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<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,
|
||||||
|
listeningPortProtocol,
|
||||||
|
);
|
||||||
|
window.open(url, "_blank");
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</Button>
|
<input
|
||||||
</form>
|
aria-label="Port number"
|
||||||
</header>
|
name="portNumber"
|
||||||
<div
|
type="number"
|
||||||
css={{
|
placeholder="Connect to port..."
|
||||||
padding: 20,
|
min={9}
|
||||||
paddingTop: 0,
|
max={65535}
|
||||||
}}
|
required
|
||||||
>
|
css={styles.newPortInput}
|
||||||
{filteredListeningPorts?.map((port) => {
|
/>
|
||||||
|
<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>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
{filteredListeningPorts.length === 0 && (
|
||||||
|
<HelpTooltipText css={styles.noPortText}>
|
||||||
|
No open ports were detected.
|
||||||
|
</HelpTooltipText>
|
||||||
|
)}
|
||||||
|
{filteredListeningPorts.map((port) => {
|
||||||
const url = portForwardURL(
|
const url = portForwardURL(
|
||||||
host,
|
host,
|
||||||
port.port,
|
port.port,
|
||||||
agent.name,
|
agent.name,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
username,
|
username,
|
||||||
|
listeningPortProtocol,
|
||||||
);
|
);
|
||||||
const label =
|
const label =
|
||||||
port.process_name !== "" ? port.process_name : port.port;
|
port.process_name !== "" ? port.process_name : port.port;
|
||||||
|
@ -323,22 +346,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
>
|
>
|
||||||
<Link
|
<Stack direction="row" gap={3}>
|
||||||
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
|
<Link
|
||||||
underline="none"
|
underline="none"
|
||||||
css={styles.portLink}
|
css={styles.portLink}
|
||||||
|
@ -346,8 +354,25 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
<span css={styles.portNumber}>{port.port}</span>
|
<SensorsIcon css={{ width: 14, height: 14 }} />
|
||||||
|
{port.port}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
underline="none"
|
||||||
|
css={styles.portLink}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
</Stack>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
gap={2}
|
||||||
|
justifyContent="flex-end"
|
||||||
|
alignItems="center"
|
||||||
|
>
|
||||||
{canSharePorts && (
|
{canSharePorts && (
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
|
@ -356,7 +381,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||||
await upsertSharedPortMutation.mutateAsync({
|
await upsertSharedPortMutation.mutateAsync({
|
||||||
agent_name: agent.name,
|
agent_name: agent.name,
|
||||||
port: port.port,
|
port: port.port,
|
||||||
protocol: "http",
|
protocol: listeningPortProtocol,
|
||||||
share_level: "authenticated",
|
share_level: "authenticated",
|
||||||
});
|
});
|
||||||
await sharedPortsQuery.refetch();
|
await sharedPortsQuery.refetch();
|
||||||
|
@ -369,7 +394,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
{portSharingExperimentEnabled && (
|
{portSharingExperimentEnabled && (
|
||||||
<div
|
<div
|
||||||
|
@ -393,7 +418,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
||||||
agent.name,
|
agent.name,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
username,
|
username,
|
||||||
share.protocol === "https",
|
share.protocol,
|
||||||
);
|
);
|
||||||
const label = share.port;
|
const label = share.port;
|
||||||
return (
|
return (
|
||||||
|
@ -619,6 +644,22 @@ const styles = {
|
||||||
"&:focus-within": {
|
"&:focus-within": {
|
||||||
borderColor: theme.palette.primary.main,
|
borderColor: theme.palette.primary.main,
|
||||||
},
|
},
|
||||||
|
width: "100%",
|
||||||
|
}),
|
||||||
|
|
||||||
|
listeningPortProtocol: (theme) => ({
|
||||||
|
boxShadow: "none",
|
||||||
|
".MuiOutlinedInput-notchedOutline": { border: 0 },
|
||||||
|
"&.MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": {
|
||||||
|
border: 0,
|
||||||
|
},
|
||||||
|
"&.MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": {
|
||||||
|
border: 0,
|
||||||
|
},
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
borderRadius: "4px",
|
||||||
|
marginTop: 8,
|
||||||
|
minWidth: "100px",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
newPortInput: (theme) => ({
|
newPortInput: (theme) => ({
|
||||||
|
@ -633,6 +674,12 @@ const styles = {
|
||||||
display: "block",
|
display: "block",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
}),
|
}),
|
||||||
|
noPortText: (theme) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingBottom: 10,
|
||||||
|
textAlign: "center",
|
||||||
|
}),
|
||||||
sharedPortLink: () => ({
|
sharedPortLink: () => ({
|
||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -3261,7 +3261,7 @@ export const MockHealth: TypesGen.HealthcheckReport = {
|
||||||
export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse =
|
export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse =
|
||||||
{
|
{
|
||||||
ports: [
|
ports: [
|
||||||
{ process_name: "webb", network: "", port: 3000 },
|
{ process_name: "webb", network: "", port: 30000 },
|
||||||
{ process_name: "gogo", network: "", port: 8080 },
|
{ process_name: "gogo", network: "", port: 8080 },
|
||||||
{ process_name: "", network: "", port: 8081 },
|
{ process_name: "", network: "", port: 8081 },
|
||||||
],
|
],
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
|
import type { WorkspaceAgentPortShareProtocol } from "api/typesGenerated";
|
||||||
|
|
||||||
export const portForwardURL = (
|
export const portForwardURL = (
|
||||||
host: string,
|
host: string,
|
||||||
port: number,
|
port: number,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
workspaceName: string,
|
workspaceName: string,
|
||||||
username: string,
|
username: string,
|
||||||
https = false,
|
protocol: WorkspaceAgentPortShareProtocol,
|
||||||
): string => {
|
): string => {
|
||||||
const { location } = window;
|
const { location } = window;
|
||||||
const suffix = https ? "s" : "";
|
const suffix = protocol === "https" ? "s" : "";
|
||||||
|
|
||||||
const subdomain = `${port}${suffix}--${agentName}--${workspaceName}--${username}`;
|
const subdomain = `${port}${suffix}--${agentName}--${workspaceName}--${username}`;
|
||||||
return `${location.protocol}//${host}`.replace("*", subdomain);
|
return `${location.protocol}//${host}`.replace("*", subdomain);
|
||||||
|
@ -56,9 +58,28 @@ export const openMaybePortForwardedURL = (
|
||||||
agentName,
|
agentName,
|
||||||
workspaceName,
|
workspaceName,
|
||||||
username,
|
username,
|
||||||
|
url.protocol.replace(":", "") as WorkspaceAgentPortShareProtocol,
|
||||||
) + url.pathname,
|
) + url.pathname,
|
||||||
);
|
);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
open(uri);
|
open(uri);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const saveWorkspaceListeningPortsProtocol = (
|
||||||
|
workspaceID: string,
|
||||||
|
protocol: WorkspaceAgentPortShareProtocol,
|
||||||
|
) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
`listening-ports-protocol-workspace-${workspaceID}`,
|
||||||
|
protocol,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getWorkspaceListeningPortsProtocol = (
|
||||||
|
workspaceID: string,
|
||||||
|
): WorkspaceAgentPortShareProtocol => {
|
||||||
|
return (localStorage.getItem(
|
||||||
|
`listening-ports-protocol-workspace-${workspaceID}`,
|
||||||
|
) || "http") as WorkspaceAgentPortShareProtocol;
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in New Issue