feat: add port sharing frontend (#12119)

This commit is contained in:
Garrett Delfosse 2024-02-20 13:26:34 -05:00 committed by GitHub
parent 0021c2f906
commit b342bd7869
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 670 additions and 92 deletions

View File

@ -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))
}

View File

@ -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> => {

View File

@ -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);
},
};
};

View File

@ -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: "",

View File

@ -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>

View File

@ -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 = {};

View File

@ -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": {

View File

@ -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";
}),
},
},
],
},
};

View File

@ -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();
});
});

View File

@ -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>
);

View File

@ -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}
/>
</>
);

View File

@ -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}
/>
</>
);

View File

@ -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}

View File

@ -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",