diff --git a/coderd/workspaceagentportshare.go b/coderd/workspaceagentportshare.go index 545a417368a2c..730089e445431 100644 --- a/coderd/workspaceagentportshare.go +++ b/coderd/workspaceagentportshare.go @@ -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)) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0a2e421ecc891..3a454e4389cd3 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1163,6 +1163,39 @@ export const getAgentListeningPorts = async ( return response.data; }; +export const getWorkspaceAgentSharedPorts = async ( + workspaceID: string, +): Promise => { + const response = await axios.get( + `/api/v2/workspaces/${workspaceID}/port-share`, + ); + return response.data; +}; + +export const upsertWorkspaceAgentSharedPort = async ( + workspaceID: string, + req: TypesGen.UpsertWorkspaceAgentPortShareRequest, +): Promise => { + 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 => { + 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 => { diff --git a/site/src/api/queries/workspaceportsharing.ts b/site/src/api/queries/workspaceportsharing.ts new file mode 100644 index 0000000000000..7f921f90d7999 --- /dev/null +++ b/site/src/api/queries/workspaceportsharing.ts @@ -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); + }, + }; +}; diff --git a/site/src/modules/resources/AgentRow.test.tsx b/site/src/modules/resources/AgentRow.test.tsx index 77260083279f4..23907798f28ba 100644 --- a/site/src/modules/resources/AgentRow.test.tsx +++ b/site/src/modules/resources/AgentRow.test.tsx @@ -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: "", diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index b20ad6649dfaf..9ddecff973f5c 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -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 = ({ agent, workspace, + template, showApps, showBuiltinApps = true, hideSSHButton, @@ -220,6 +223,8 @@ export const AgentRow: FC = ({ workspaceName={workspace.name} agent={agent} username={workspace.owner_name} + workspaceID={workspace.id} + template={template} /> )} diff --git a/site/src/modules/resources/PortForwardButton.stories.tsx b/site/src/modules/resources/PortForwardButton.stories.tsx index 8ff4321c9135e..6d09f321c4cd6 100644 --- a/site/src/modules/resources/PortForwardButton.stories.tsx +++ b/site/src/modules/resources/PortForwardButton.stories.tsx @@ -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; 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 = {}; diff --git a/site/src/modules/resources/PortForwardButton.tsx b/site/src/modules/resources/PortForwardButton.tsx index 40a9cc11dc624..62fed379b2667 100644 --- a/site/src/modules/resources/PortForwardButton.tsx +++ b/site/src/modules/resources/PortForwardButton.tsx @@ -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 = (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 ( - + ); }; +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 = Pick, K> & Omit; + export const PortForwardPopoverView: FC = ({ 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 + > = useFormik>({ + 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 ( <>
- Forwarded ports + + Listening ports + + Learn more + + - {ports?.length === 0 + {filteredListeningPorts?.length === 0 ? "No open ports were detected." - : "The forwarded ports are exclusively accessible to you."} - -
- {ports?.map((port) => { - const url = portForwardURL( - host, - port.port, - agent.name, - workspaceName, - username, - ); - const label = - port.process_name !== "" ? port.process_name : port.port; - return ( - - - {label} - {port.port} - - ); - })} -
-
- -
- Forward port - - Access ports running on the agent: + : "The listening ports are exclusively accessible to you."} -
{ @@ -166,7 +244,7 @@ export const PortForwardPopoverView: FC = ({ 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 = ({ /> - - - - Learn more - - +
+ {filteredListeningPorts?.map((port) => { + const url = portForwardURL( + host, + port.port, + agent.name, + workspaceName, + username, + ); + const label = + port.process_name !== "" ? port.process_name : port.port; + return ( + + + + {label} + + + + {port.port} + + {canSharePorts && ( + + )} + + + ); + })} +
+ {portSharingExperimentEnabled && ( +
+ Shared Ports + + {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."} + + {canSharePorts && ( +
+ {filteredSharedPorts?.map((share) => { + const url = portForwardURL( + host, + share.port, + agent.name, + workspaceName, + username, + ); + const label = share.port; + return ( + + + {share.share_level === "public" ? ( + + ) : ( + + )} + {label} + + + + + + + + + ); + })} +
+ + + + Authenticated + + Public + + + + Share Port + + +
+
+ )} +
+ )} ); }; @@ -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": { diff --git a/site/src/modules/resources/PortForwardPopoverView.stories.tsx b/site/src/modules/resources/PortForwardPopoverView.stories.tsx index 95b5e6a770580..94e101e66b91d 100644 --- a/site/src/modules/resources/PortForwardPopoverView.stories.tsx +++ b/site/src/modules/resources/PortForwardPopoverView.stories.tsx @@ -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 = { ], args: { agent: MockWorkspaceAgent, + template: MockTemplate, + workspaceID: MockWorkspace.id, + portSharingExperimentEnabled: true, + portSharingControlsEnabled: true, }, }; @@ -32,12 +39,82 @@ type Story = StoryObj; 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"; + }), + }, + }, + ], }, }; diff --git a/site/src/modules/resources/PortForwardPopoverView.test.tsx b/site/src/modules/resources/PortForwardPopoverView.test.tsx new file mode 100644 index 0000000000000..c04cd74b3724c --- /dev/null +++ b/site/src/modules/resources/PortForwardPopoverView.test.tsx @@ -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( + + + , + ); + + expect( + screen.getByText(MockListeningPortsResponse.ports[0].port), + ).toBeInTheDocument(); + + expect( + screen.getByText(MockListeningPortsResponse.ports[0].process_name), + ).toBeInTheDocument(); + }); +}); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 5845538f61ee7..3c540c952214b 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -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; accessControlEnabled: boolean; + portSharingExperimentEnabled: boolean; + portSharingControlsEnabled: boolean; } export const TemplateSettingsForm: FC = ({ @@ -64,6 +73,8 @@ export const TemplateSettingsForm: FC = ({ isSubmitting, initialTouched, accessControlEnabled, + portSharingExperimentEnabled, + portSharingControlsEnabled, }) => { const validationSchema = getValidationSchema(); const form: FormikContextType = @@ -80,6 +91,7 @@ export const TemplateSettingsForm: FC = ({ 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 = ({ + {portSharingExperimentEnabled && ( + + + + Owner + Authenticated + Public + + {!portSharingControlsEnabled && ( + + + + Enterprise license required to control max port sharing level. + + + )} + + + )} + ); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx index 572b788aacbab..44cd7eae4747f 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx @@ -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} /> ); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx index 5eac1759d5ca2..46222b8de2ec1 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx @@ -13,6 +13,8 @@ export interface TemplateSettingsPageViewProps { typeof TemplateSettingsForm >["initialTouched"]; accessControlEnabled: boolean; + sharedPortsExperimentEnabled: boolean; + sharedPortControlsEnabled: boolean; } export const TemplateSettingsPageView: FC = ({ @@ -23,6 +25,8 @@ export const TemplateSettingsPageView: FC = ({ submitError, initialTouched, accessControlEnabled, + sharedPortsExperimentEnabled, + sharedPortControlsEnabled, }) => { return ( <> @@ -38,6 +42,8 @@ export const TemplateSettingsPageView: FC = ({ onCancel={onCancel} error={submitError} accessControlEnabled={accessControlEnabled} + portSharingExperimentEnabled={sharedPortsExperimentEnabled} + portSharingControlsEnabled={sharedPortControlsEnabled} /> ); diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 31efc004c2aa7..37d6771d4ffb9 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -245,6 +245,7 @@ export const Workspace: FC = ({ key={agent.id} agent={agent} workspace={workspace} + template={template} sshPrefix={sshPrefix} showApps={permissions.updateWorkspace} showBuiltinApps={permissions.updateWorkspace} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index ce4f9d836aa0e..dcaf18aa546e5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -489,7 +489,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 = { @@ -3238,12 +3238,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",