mirror of https://github.com/coder/coder.git
feat: add port sharing frontend (#12119)
This commit is contained in:
parent
0021c2f906
commit
b342bd7869
|
@ -156,7 +156,7 @@ func (api *API) deleteWorkspaceAgentPortShare(rw http.ResponseWriter, r *http.Re
|
|||
}
|
||||
|
||||
func convertPortShares(shares []database.WorkspaceAgentPortShare) []codersdk.WorkspaceAgentPortShare {
|
||||
var converted []codersdk.WorkspaceAgentPortShare
|
||||
converted := []codersdk.WorkspaceAgentPortShare{}
|
||||
for _, share := range shares {
|
||||
converted = append(converted, convertPortShare(share))
|
||||
}
|
||||
|
|
|
@ -1163,6 +1163,39 @@ export const getAgentListeningPorts = async (
|
|||
return response.data;
|
||||
};
|
||||
|
||||
export const getWorkspaceAgentSharedPorts = async (
|
||||
workspaceID: string,
|
||||
): Promise<TypesGen.WorkspaceAgentPortShares> => {
|
||||
const response = await axios.get(
|
||||
`/api/v2/workspaces/${workspaceID}/port-share`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const upsertWorkspaceAgentSharedPort = async (
|
||||
workspaceID: string,
|
||||
req: TypesGen.UpsertWorkspaceAgentPortShareRequest,
|
||||
): Promise<TypesGen.WorkspaceAgentPortShares> => {
|
||||
const response = await axios.post(
|
||||
`/api/v2/workspaces/${workspaceID}/port-share`,
|
||||
req,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteWorkspaceAgentSharedPort = async (
|
||||
workspaceID: string,
|
||||
req: TypesGen.DeleteWorkspaceAgentPortShareRequest,
|
||||
): Promise<TypesGen.WorkspaceAgentPortShares> => {
|
||||
const response = await axios.delete(
|
||||
`/api/v2/workspaces/${workspaceID}/port-share`,
|
||||
{
|
||||
data: req,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
// getDeploymentSSHConfig is used by the VSCode-Extension.
|
||||
export const getDeploymentSSHConfig =
|
||||
async (): Promise<TypesGen.SSHConfigResponse> => {
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import {
|
||||
deleteWorkspaceAgentSharedPort,
|
||||
getWorkspaceAgentSharedPorts,
|
||||
upsertWorkspaceAgentSharedPort,
|
||||
} from "api/api";
|
||||
import {
|
||||
DeleteWorkspaceAgentPortShareRequest,
|
||||
UpsertWorkspaceAgentPortShareRequest,
|
||||
} from "api/typesGenerated";
|
||||
|
||||
export const workspacePortShares = (workspaceId: string) => {
|
||||
return {
|
||||
queryKey: ["sharedPorts", workspaceId],
|
||||
queryFn: () => getWorkspaceAgentSharedPorts(workspaceId),
|
||||
};
|
||||
};
|
||||
|
||||
export const upsertWorkspacePortShare = (workspaceId: string) => {
|
||||
return {
|
||||
mutationFn: async (options: UpsertWorkspaceAgentPortShareRequest) => {
|
||||
await upsertWorkspaceAgentSharedPort(workspaceId, options);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteWorkspacePortShare = (workspaceId: string) => {
|
||||
return {
|
||||
mutationFn: async (options: DeleteWorkspaceAgentPortShareRequest) => {
|
||||
await deleteWorkspaceAgentSharedPort(workspaceId, options);
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,4 +1,8 @@
|
|||
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
|
||||
import {
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
} from "testHelpers/entities";
|
||||
import { AgentRow, AgentRowProps } from "./AgentRow";
|
||||
import { DisplayAppNameMap } from "./AppLink/AppLink";
|
||||
import { screen } from "@testing-library/react";
|
||||
|
@ -80,6 +84,7 @@ describe.each<{
|
|||
const props: AgentRowProps = {
|
||||
agent: MockWorkspaceAgent,
|
||||
workspace: MockWorkspace,
|
||||
template: MockTemplate,
|
||||
showApps: false,
|
||||
serverVersion: "",
|
||||
serverAPIVersion: "",
|
||||
|
|
|
@ -15,6 +15,7 @@ import AutoSizer from "react-virtualized-auto-sizer";
|
|||
import { FixedSizeList as List, ListOnScrollProps } from "react-window";
|
||||
import * as API from "api/api";
|
||||
import type {
|
||||
Template,
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
WorkspaceAgentLogSource,
|
||||
|
@ -59,6 +60,7 @@ export interface AgentRowProps {
|
|||
serverVersion: string;
|
||||
serverAPIVersion: string;
|
||||
onUpdateAgent: () => void;
|
||||
template: Template;
|
||||
storybookLogs?: LineWithID[];
|
||||
storybookAgentMetadata?: WorkspaceAgentMetadata[];
|
||||
}
|
||||
|
@ -66,6 +68,7 @@ export interface AgentRowProps {
|
|||
export const AgentRow: FC<AgentRowProps> = ({
|
||||
agent,
|
||||
workspace,
|
||||
template,
|
||||
showApps,
|
||||
showBuiltinApps = true,
|
||||
hideSSHButton,
|
||||
|
@ -220,6 +223,8 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||
workspaceName={workspace.name}
|
||||
agent={agent}
|
||||
username={workspace.owner_name}
|
||||
workspaceID={workspace.id}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -2,6 +2,8 @@ import { PortForwardButton } from "./PortForwardButton";
|
|||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
MockListeningPortsResponse,
|
||||
MockSharedPortsResponse,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
} from "testHelpers/entities";
|
||||
|
||||
|
@ -17,15 +19,18 @@ export default meta;
|
|||
type Story = StoryObj<typeof PortForwardButton>;
|
||||
|
||||
export const Example: Story = {
|
||||
args: {
|
||||
storybook: {
|
||||
portsQueryData: MockListeningPortsResponse,
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["portForward", MockWorkspaceAgent.id],
|
||||
data: MockListeningPortsResponse,
|
||||
},
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: MockSharedPortsResponse,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
storybook: {},
|
||||
},
|
||||
};
|
||||
export const Loading: Story = {};
|
||||
|
|
|
@ -3,20 +3,22 @@ import Link from "@mui/material/Link";
|
|||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { type FC } from "react";
|
||||
import { useQuery, useMutation } from "react-query";
|
||||
import { docs } from "utils/docs";
|
||||
import { getAgentListeningPorts } from "api/api";
|
||||
import type {
|
||||
WorkspaceAgent,
|
||||
WorkspaceAgentListeningPort,
|
||||
WorkspaceAgentListeningPortsResponse,
|
||||
import {
|
||||
WorkspaceAppSharingLevels,
|
||||
type Template,
|
||||
type WorkspaceAgent,
|
||||
type WorkspaceAgentListeningPort,
|
||||
type WorkspaceAgentPortShareLevel,
|
||||
UpsertWorkspaceAgentPortShareRequest,
|
||||
} from "api/typesGenerated";
|
||||
import { portForwardURL } from "utils/portForward";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import {
|
||||
HelpTooltipLink,
|
||||
HelpTooltipLinksGroup,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
|
@ -26,48 +28,62 @@ import {
|
|||
PopoverTrigger,
|
||||
} from "components/Popover/Popover";
|
||||
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Select from "@mui/material/Select";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import SensorsIcon from "@mui/icons-material/Sensors";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import {
|
||||
deleteWorkspacePortShare,
|
||||
upsertWorkspacePortShare,
|
||||
workspacePortShares,
|
||||
} from "api/queries/workspaceportsharing";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import * as Yup from "yup";
|
||||
import { FormikContextType, useFormik } from "formik";
|
||||
import { getFormHelpers } from "utils/formUtils";
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
|
||||
export interface PortForwardButtonProps {
|
||||
host: string;
|
||||
username: string;
|
||||
workspaceName: string;
|
||||
workspaceID: string;
|
||||
agent: WorkspaceAgent;
|
||||
|
||||
/**
|
||||
* Only for use in Storybook
|
||||
*/
|
||||
storybook?: {
|
||||
portsQueryData?: WorkspaceAgentListeningPortsResponse;
|
||||
};
|
||||
template: Template;
|
||||
}
|
||||
|
||||
export const PortForwardButton: FC<PortForwardButtonProps> = (props) => {
|
||||
const { agent, storybook } = 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: !storybook && agent.status === "connected",
|
||||
enabled: agent.status === "connected",
|
||||
refetchInterval: 5_000,
|
||||
});
|
||||
|
||||
const data = storybook ? storybook.portsQueryData : portsQuery.data;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
disabled={!data}
|
||||
disabled={!portsQuery.data}
|
||||
size="small"
|
||||
variant="text"
|
||||
endIcon={<KeyboardArrowDown />}
|
||||
css={{ fontSize: 13, padding: "8px 12px" }}
|
||||
startIcon={
|
||||
data ? (
|
||||
portsQuery.data ? (
|
||||
<div>
|
||||
<span css={styles.portCount}>{data.ports.length}</span>
|
||||
<span css={styles.portCount}>
|
||||
{portsQuery.data.ports.length}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<CircularProgress size={10} />
|
||||
|
@ -78,74 +94,136 @@ export const PortForwardButton: FC<PortForwardButtonProps> = (props) => {
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent horizontal="right" classes={{ paper }}>
|
||||
<PortForwardPopoverView {...props} ports={data?.ports} />
|
||||
<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(0).max(65535),
|
||||
share_level: Yup.string().required().oneOf(WorkspaceAppSharingLevels),
|
||||
});
|
||||
|
||||
interface PortForwardPopoverViewProps extends PortForwardButtonProps {
|
||||
ports?: WorkspaceAgentListeningPort[];
|
||||
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,
|
||||
ports,
|
||||
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,
|
||||
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,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
}}
|
||||
>
|
||||
<HelpTooltipTitle>Forwarded ports</HelpTooltipTitle>
|
||||
<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 }}>
|
||||
{ports?.length === 0
|
||||
{filteredListeningPorts?.length === 0
|
||||
? "No open ports were detected."
|
||||
: "The forwarded ports are exclusively accessible to you."}
|
||||
: "The listening ports are exclusively accessible to you."}
|
||||
</HelpTooltipText>
|
||||
<div css={{ marginTop: 12 }}>
|
||||
{ports?.map((port) => {
|
||||
const url = portForwardURL(
|
||||
host,
|
||||
port.port,
|
||||
agent.name,
|
||||
workspaceName,
|
||||
username,
|
||||
);
|
||||
const label =
|
||||
port.process_name !== "" ? port.process_name : port.port;
|
||||
return (
|
||||
<Link
|
||||
underline="none"
|
||||
css={styles.portLink}
|
||||
key={port.port}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<OpenInNewOutlined css={{ width: 14, height: 14 }} />
|
||||
{label}
|
||||
<span css={styles.portNumber}>{port.port}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div css={{ padding: 20 }}>
|
||||
<HelpTooltipTitle>Forward port</HelpTooltipTitle>
|
||||
<HelpTooltipText css={{ color: theme.palette.text.secondary }}>
|
||||
Access ports running on the agent:
|
||||
</HelpTooltipText>
|
||||
|
||||
<form
|
||||
css={styles.newPortForm}
|
||||
onSubmit={(e) => {
|
||||
|
@ -166,7 +244,7 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
|||
aria-label="Port number"
|
||||
name="portNumber"
|
||||
type="number"
|
||||
placeholder="Type a port number..."
|
||||
placeholder="Connect to port..."
|
||||
min={0}
|
||||
max={65535}
|
||||
required
|
||||
|
@ -192,13 +270,215 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
|
|||
/>
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipLink href={docs("/networking/port-forwarding#dashboard")}>
|
||||
Learn more
|
||||
</HelpTooltipLink>
|
||||
</HelpTooltipLinksGroup>
|
||||
<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,
|
||||
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>
|
||||
<Stack direction="row" justifyContent="flex-end">
|
||||
<FormControl size="small">
|
||||
<Select
|
||||
css={styles.shareLevelSelect}
|
||||
value={share.share_level}
|
||||
onChange={async (event) => {
|
||||
await upsertSharedPortMutation.mutateAsync({
|
||||
agent_name: agent.name,
|
||||
port: share.port,
|
||||
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("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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -232,8 +512,8 @@ const styles = {
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
fontWeight: 500,
|
||||
}),
|
||||
|
||||
|
@ -244,10 +524,26 @@ const styles = {
|
|||
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: 16,
|
||||
marginTop: 8,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
"&:focus-within": {
|
||||
|
|
|
@ -2,6 +2,9 @@ import { PortForwardPopoverView } from "./PortForwardButton";
|
|||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
MockListeningPortsResponse,
|
||||
MockSharedPortsResponse,
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
} from "testHelpers/entities";
|
||||
|
||||
|
@ -24,6 +27,10 @@ const meta: Meta<typeof PortForwardPopoverView> = {
|
|||
],
|
||||
args: {
|
||||
agent: MockWorkspaceAgent,
|
||||
template: MockTemplate,
|
||||
workspaceID: MockWorkspace.id,
|
||||
portSharingExperimentEnabled: true,
|
||||
portSharingControlsEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -32,12 +39,82 @@ type Story = StoryObj<typeof PortForwardPopoverView>;
|
|||
|
||||
export const WithPorts: Story = {
|
||||
args: {
|
||||
ports: MockListeningPortsResponse.ports,
|
||||
listeningPorts: MockListeningPortsResponse.ports,
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: MockSharedPortsResponse,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
ports: [],
|
||||
listeningPorts: [],
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: { shares: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoPortSharingExperiment: Story = {
|
||||
args: {
|
||||
listeningPorts: MockListeningPortsResponse.ports,
|
||||
portSharingExperimentEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const AGPLPortSharing: Story = {
|
||||
args: {
|
||||
listeningPorts: MockListeningPortsResponse.ports,
|
||||
portSharingControlsEnabled: false,
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: MockSharedPortsResponse,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const EnterprisePortSharingControlsOwner: Story = {
|
||||
args: {
|
||||
listeningPorts: MockListeningPortsResponse.ports,
|
||||
template: {
|
||||
...MockTemplate,
|
||||
max_port_share_level: "owner",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EnterprisePortSharingControlsAuthenticated: Story = {
|
||||
args: {
|
||||
listeningPorts: MockListeningPortsResponse.ports,
|
||||
template: {
|
||||
...MockTemplate,
|
||||
max_port_share_level: "authenticated",
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: ["sharedPorts", MockWorkspace.id],
|
||||
data: {
|
||||
shares: MockSharedPortsResponse.shares.filter((share) => {
|
||||
return share.share_level === "authenticated";
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import {
|
||||
MockListeningPortsResponse,
|
||||
MockTemplate,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
} from "testHelpers/entities";
|
||||
import { renderComponent } from "testHelpers/renderHelpers";
|
||||
import { PortForwardPopoverView } from "./PortForwardButton";
|
||||
import { QueryClientProvider, QueryClient } from "react-query";
|
||||
|
||||
describe("Port Forward Popover View", () => {
|
||||
it("renders component", async () => {
|
||||
renderComponent(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<PortForwardPopoverView
|
||||
agent={MockWorkspaceAgent}
|
||||
template={MockTemplate}
|
||||
workspaceID={MockWorkspace.id}
|
||||
listeningPorts={MockListeningPortsResponse.ports}
|
||||
portSharingExperimentEnabled
|
||||
portSharingControlsEnabled
|
||||
host="host"
|
||||
username="username"
|
||||
workspaceName="workspaceName"
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(MockListeningPortsResponse.ports[0].port),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(MockListeningPortsResponse.ports[0].process_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -1,6 +1,10 @@
|
|||
import { type Interpolation, type Theme } from "@emotion/react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import type { Template, UpdateTemplateMeta } from "api/typesGenerated";
|
||||
import {
|
||||
WorkspaceAppSharingLevels,
|
||||
type Template,
|
||||
type UpdateTemplateMeta,
|
||||
} from "api/typesGenerated";
|
||||
import { type FormikContextType, type FormikTouched, useFormik } from "formik";
|
||||
import { type FC } from "react";
|
||||
import {
|
||||
|
@ -27,6 +31,7 @@ import {
|
|||
HelpTooltipTrigger,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import { EnterpriseBadge } from "components/Badges/Badges";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
|
||||
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
|
||||
const MAX_DESCRIPTION_MESSAGE =
|
||||
|
@ -43,6 +48,8 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
|||
allow_user_cancel_workspace_jobs: Yup.boolean(),
|
||||
icon: iconValidator,
|
||||
require_active_version: Yup.boolean(),
|
||||
deprecation_message: Yup.string(),
|
||||
max_port_sharing_level: Yup.string().oneOf(WorkspaceAppSharingLevels),
|
||||
});
|
||||
|
||||
export interface TemplateSettingsForm {
|
||||
|
@ -54,6 +61,8 @@ export interface TemplateSettingsForm {
|
|||
// Helpful to show field errors on Storybook
|
||||
initialTouched?: FormikTouched<UpdateTemplateMeta>;
|
||||
accessControlEnabled: boolean;
|
||||
portSharingExperimentEnabled: boolean;
|
||||
portSharingControlsEnabled: boolean;
|
||||
}
|
||||
|
||||
export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
||||
|
@ -64,6 +73,8 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
isSubmitting,
|
||||
initialTouched,
|
||||
accessControlEnabled,
|
||||
portSharingExperimentEnabled,
|
||||
portSharingControlsEnabled,
|
||||
}) => {
|
||||
const validationSchema = getValidationSchema();
|
||||
const form: FormikContextType<UpdateTemplateMeta> =
|
||||
|
@ -80,6 +91,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
require_active_version: template.require_active_version,
|
||||
deprecation_message: template.deprecation_message,
|
||||
disable_everyone_group_access: false,
|
||||
max_port_share_level: template.max_port_share_level,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
|
@ -257,6 +269,46 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
{portSharingExperimentEnabled && (
|
||||
<FormSection
|
||||
title="Port Sharing"
|
||||
description="Shared ports with the Public sharing level can be accessed by anyone,
|
||||
while ports with the Authenticated sharing level can only be accessed
|
||||
by authenticated Coder users. Ports with the Owner sharing level can
|
||||
only be accessed by the workspace owner."
|
||||
>
|
||||
<FormFields>
|
||||
<TextField
|
||||
{...getFieldHelpers("max_port_share_level", {
|
||||
helperText:
|
||||
"The maximum level of port sharing allowed for workspaces.",
|
||||
})}
|
||||
disabled={isSubmitting || !portSharingControlsEnabled}
|
||||
fullWidth
|
||||
select
|
||||
value={
|
||||
portSharingControlsEnabled
|
||||
? form.values.max_port_share_level
|
||||
: "public"
|
||||
}
|
||||
label="Maximum Port Sharing Level"
|
||||
>
|
||||
<MenuItem value="owner">Owner</MenuItem>
|
||||
<MenuItem value="authenticated">Authenticated</MenuItem>
|
||||
<MenuItem value="public">Public</MenuItem>
|
||||
</TextField>
|
||||
{!portSharingControlsEnabled && (
|
||||
<Stack direction="row">
|
||||
<EnterpriseBadge />
|
||||
<span css={styles.optionHelperText}>
|
||||
Enterprise license required to control max port sharing level.
|
||||
</span>
|
||||
</Stack>
|
||||
)}
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
|
||||
</HorizontalForm>
|
||||
);
|
||||
|
|
|
@ -18,8 +18,11 @@ export const TemplateSettingsPage: FC = () => {
|
|||
const orgId = useOrganizationId();
|
||||
const { template } = useTemplateSettings();
|
||||
const queryClient = useQueryClient();
|
||||
const { entitlements } = useDashboard();
|
||||
const { entitlements, experiments } = useDashboard();
|
||||
const accessControlEnabled = entitlements.features.access_control.enabled;
|
||||
const sharedPortsExperimentEnabled = experiments.includes("shared-ports");
|
||||
const sharedPortControlsEnabled =
|
||||
entitlements.features.control_shared_ports.enabled;
|
||||
|
||||
const {
|
||||
mutate: updateTemplate,
|
||||
|
@ -67,6 +70,8 @@ export const TemplateSettingsPage: FC = () => {
|
|||
});
|
||||
}}
|
||||
accessControlEnabled={accessControlEnabled}
|
||||
sharedPortsExperimentEnabled={sharedPortsExperimentEnabled}
|
||||
sharedPortControlsEnabled={sharedPortControlsEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -13,6 +13,8 @@ export interface TemplateSettingsPageViewProps {
|
|||
typeof TemplateSettingsForm
|
||||
>["initialTouched"];
|
||||
accessControlEnabled: boolean;
|
||||
sharedPortsExperimentEnabled: boolean;
|
||||
sharedPortControlsEnabled: boolean;
|
||||
}
|
||||
|
||||
export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
||||
|
@ -23,6 +25,8 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
|||
submitError,
|
||||
initialTouched,
|
||||
accessControlEnabled,
|
||||
sharedPortsExperimentEnabled,
|
||||
sharedPortControlsEnabled,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
|
@ -38,6 +42,8 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
|||
onCancel={onCancel}
|
||||
error={submitError}
|
||||
accessControlEnabled={accessControlEnabled}
|
||||
portSharingExperimentEnabled={sharedPortsExperimentEnabled}
|
||||
portSharingControlsEnabled={sharedPortControlsEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -245,6 +245,7 @@ export const Workspace: FC<WorkspaceProps> = ({
|
|||
key={agent.id}
|
||||
agent={agent}
|
||||
workspace={workspace}
|
||||
template={template}
|
||||
sshPrefix={sshPrefix}
|
||||
showApps={permissions.updateWorkspace}
|
||||
showBuiltinApps={permissions.updateWorkspace}
|
||||
|
|
|
@ -490,7 +490,7 @@ export const MockTemplate: TypesGen.Template = {
|
|||
require_active_version: false,
|
||||
deprecated: false,
|
||||
deprecation_message: "",
|
||||
max_port_share_level: "owner",
|
||||
max_port_share_level: "public",
|
||||
};
|
||||
|
||||
export const MockTemplateVersionFiles: TemplateVersionFiles = {
|
||||
|
@ -3239,12 +3239,35 @@ export const MockHealth: TypesGen.HealthcheckReport = {
|
|||
export const MockListeningPortsResponse: TypesGen.WorkspaceAgentListeningPortsResponse =
|
||||
{
|
||||
ports: [
|
||||
{ process_name: "web", network: "", port: 3000 },
|
||||
{ process_name: "go", network: "", port: 8080 },
|
||||
{ process_name: "webb", network: "", port: 3000 },
|
||||
{ process_name: "gogo", network: "", port: 8080 },
|
||||
{ process_name: "", network: "", port: 8081 },
|
||||
],
|
||||
};
|
||||
|
||||
export const MockSharedPortsResponse: TypesGen.WorkspaceAgentPortShares = {
|
||||
shares: [
|
||||
{
|
||||
workspace_id: MockWorkspace.id,
|
||||
agent_name: "a-workspace-agent",
|
||||
port: 4000,
|
||||
share_level: "authenticated",
|
||||
},
|
||||
{
|
||||
workspace_id: MockWorkspace.id,
|
||||
agent_name: "a-workspace-agent",
|
||||
port: 8080,
|
||||
share_level: "authenticated",
|
||||
},
|
||||
{
|
||||
workspace_id: MockWorkspace.id,
|
||||
agent_name: "a-workspace-agent",
|
||||
port: 8081,
|
||||
share_level: "public",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DeploymentHealthUnhealthy: TypesGen.HealthcheckReport = {
|
||||
healthy: false,
|
||||
severity: "ok",
|
||||
|
|
Loading…
Reference in New Issue