chore: UI/UX for regions (#7283)

* chore: Allow regular users to query for all workspaces
* FE to add workspace proxy options to account settings
* WorkspaceProxy context syncs with coderd on region responses

---------

Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Steven Masley 2023-04-28 16:04:52 -05:00 committed by GitHub
parent c00f5e499a
commit 4a9d1c16c7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 983 additions and 167 deletions

View File

@ -618,6 +618,12 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionDisabled,
// Always allow websockets from the primary dashboard URL.
// Terminals are opened there and connect to the proxy.
OriginPatterns: []string{
s.DashboardURL.Host,
s.AccessURL.Host,
},
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{

View File

@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
@ -57,26 +58,31 @@ func (api *API) regions(rw http.ResponseWriter, r *http.Request) {
return
}
proxyHealth := api.ProxyHealth.HealthStatus()
for _, proxy := range proxies {
if proxy.Deleted {
continue
}
// Only add additional regions if the proxy health is enabled.
// If it is nil, it is because the moons feature flag is not on.
// By default, we still want to return the primary region.
if api.ProxyHealth != nil {
proxyHealth := api.ProxyHealth.HealthStatus()
for _, proxy := range proxies {
if proxy.Deleted {
continue
}
health, ok := proxyHealth[proxy.ID]
if !ok {
health.Status = proxyhealth.Unknown
}
health, ok := proxyHealth[proxy.ID]
if !ok {
health.Status = proxyhealth.Unknown
}
regions = append(regions, codersdk.Region{
ID: proxy.ID,
Name: proxy.Name,
DisplayName: proxy.DisplayName,
IconURL: proxy.Icon,
Healthy: health.Status == proxyhealth.Healthy,
PathAppURL: proxy.Url,
WildcardHostname: proxy.WildcardHostname,
})
regions = append(regions, codersdk.Region{
ID: proxy.ID,
Name: proxy.Name,
DisplayName: proxy.DisplayName,
IconURL: proxy.Icon,
Healthy: health.Status == proxyhealth.Healthy,
PathAppURL: proxy.Url,
WildcardHostname: proxy.WildcardHostname,
})
}
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.RegionsResponse{
@ -156,6 +162,20 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
return
}
if strings.ToLower(req.Name) == "primary" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: `The name "primary" is reserved for the primary region.`,
Detail: "Cannot name a workspace proxy 'primary'.",
Validations: []codersdk.ValidationError{
{
Field: "name",
Detail: "Reserved name",
},
},
})
return
}
id := uuid.New()
secret, err := cryptorand.HexString(64)
if err != nil {

View File

@ -38,6 +38,10 @@ const SSHKeysPage = lazy(
const TokensPage = lazy(
() => import("./pages/UserSettingsPage/TokensPage/TokensPage"),
)
const WorkspaceProxyPage = lazy(
() =>
import("./pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage"),
)
const CreateUserPage = lazy(
() => import("./pages/UsersPage/CreateUserPage/CreateUserPage"),
)
@ -272,6 +276,10 @@ export const AppRouter: FC = () => {
<Route index element={<TokensPage />} />
<Route path="new" element={<CreateTokenPage />} />
</Route>
<Route
path="workspace-proxies"
element={<WorkspaceProxyPage />}
/>
</Route>
<Route path="/@:username">

View File

@ -944,6 +944,14 @@ export const getFile = async (fileId: string): Promise<ArrayBuffer> => {
return response.data
}
export const getWorkspaceProxies =
async (): Promise<TypesGen.RegionsResponse> => {
const response = await axios.get<TypesGen.RegionsResponse>(
`/api/v2/regions`,
)
return response.data
}
export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
try {
const response = await axios.get(`/api/v2/appearance`)
@ -1292,3 +1300,13 @@ export const watchBuildLogsByBuildId = (
})
return socket
}
export const issueReconnectingPTYSignedToken = async (
params: TypesGen.IssueReconnectingPTYSignedTokenRequest,
): Promise<TypesGen.IssueReconnectingPTYSignedTokenResponse> => {
const response = await axios.post(
"/api/v2/applications/reconnecting-pty-signed-token",
params,
)
return response.data
}

View File

@ -1,17 +1,34 @@
import { Story } from "@storybook/react"
import {
MockPrimaryWorkspaceProxy,
MockWorkspaceProxies,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceApp,
} from "testHelpers/entities"
import { AppLink, AppLinkProps } from "./AppLink"
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
export default {
title: "components/AppLink",
component: AppLink,
}
const Template: Story<AppLinkProps> = (args) => <AppLink {...args} />
const Template: Story<AppLinkProps> = (args) => (
<ProxyContext.Provider
value={{
proxy: getPreferredProxy(MockWorkspaceProxies, MockPrimaryWorkspaceProxy),
proxies: MockWorkspaceProxies,
isLoading: false,
isFetched: true,
setProxy: () => {
return
},
}}
>
<AppLink {...args} />
</ProxyContext.Provider>
)
export const WithIcon = Template.bind({})
WithIcon.args = {

View File

@ -10,6 +10,7 @@ import * as TypesGen from "../../api/typesGenerated"
import { generateRandomString } from "../../utils/random"
import { BaseIcon } from "./BaseIcon"
import { ShareIcon } from "./ShareIcon"
import { useProxy } from "contexts/ProxyContext"
const Language = {
appTitle: (appName: string, identifier: string): string =>
@ -17,18 +18,16 @@ const Language = {
}
export interface AppLinkProps {
appsHost?: string
workspace: TypesGen.Workspace
app: TypesGen.WorkspaceApp
agent: TypesGen.WorkspaceAgent
}
export const AppLink: FC<AppLinkProps> = ({
appsHost,
app,
workspace,
agent,
}) => {
export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
const { proxy } = useProxy()
const preferredPathBase = proxy.preferredPathAppURL
const appsHost = proxy.preferredWildcardHostname
const styles = useStyles()
const username = workspace.owner_name
@ -43,14 +42,15 @@ export const AppLink: FC<AppLinkProps> = ({
// The backend redirects if the trailing slash isn't included, so we add it
// here to avoid extra roundtrips.
let href = `/@${username}/${workspace.name}.${
let href = `${preferredPathBase}/@${username}/${workspace.name}.${
agent.name
}/apps/${encodeURIComponent(appSlug)}/`
if (app.command) {
href = `/@${username}/${workspace.name}.${
href = `${preferredPathBase}/@${username}/${workspace.name}.${
agent.name
}/terminal?command=${encodeURIComponent(app.command)}`
}
if (appsHost && app.subdomain) {
const subdomain = `${appSlug}--${agent.name}--${workspace.name}--${username}`
href = `${window.location.protocol}//${appsHost}/`.replace("*", subdomain)

View File

@ -13,7 +13,6 @@ import { Outlet } from "react-router-dom"
import { dashboardContentBottomPadding } from "theme/constants"
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"
import { Navbar } from "../Navbar/Navbar"
import { DashboardProvider } from "./DashboardProvider"
export const DashboardLayout: FC = () => {
const styles = useStyles()
@ -28,7 +27,7 @@ export const DashboardLayout: FC = () => {
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
return (
<DashboardProvider>
<>
<ServiceBanner />
{canViewDeployment && <LicenseBanner />}
@ -57,7 +56,7 @@ export const DashboardLayout: FC = () => {
<DeploymentBanner />
</div>
</DashboardProvider>
</>
)
}

View File

@ -22,6 +22,24 @@ export const EntitledBadge: FC = () => {
)
}
export const HealthyBadge: FC = () => {
const styles = useStyles()
return (
<span className={combineClasses([styles.badge, styles.enabledBadge])}>
Healthy
</span>
)
}
export const NotHealthyBadge: FC = () => {
const styles = useStyles()
return (
<span className={combineClasses([styles.badge, styles.errorBadge])}>
Unhealthy
</span>
)
}
export const DisabledBadge: FC = () => {
const styles = useStyles()
return (
@ -92,6 +110,11 @@ const useStyles = makeStyles((theme) => ({
backgroundColor: theme.palette.success.dark,
},
errorBadge: {
border: `1px solid ${theme.palette.error.light}`,
backgroundColor: theme.palette.error.dark,
},
disabledBadge: {
border: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,

View File

@ -43,6 +43,7 @@ export const portForwardURL = (
const TooltipView: React.FC<PortForwardButtonProps> = (props) => {
const { host, workspaceName, agentName, agentId, username } = props
const styles = useStyles()
const [port, setPort] = useState("3000")
const urlExample = portForwardURL(

View File

@ -4,6 +4,8 @@ import { Navigate, useLocation } from "react-router"
import { Outlet } from "react-router-dom"
import { embedRedirect } from "../../utils/redirect"
import { FullScreenLoader } from "../Loader/FullScreenLoader"
import { DashboardProvider } from "components/Dashboard/DashboardProvider"
import { ProxyProvider } from "contexts/ProxyContext"
export const RequireAuth: FC = () => {
const [authState] = useAuth()
@ -21,6 +23,14 @@ export const RequireAuth: FC = () => {
) {
return <FullScreenLoader />
} else {
return <Outlet />
// Authenticated pages have access to some contexts for knowing enabled experiments
// and where to route workspace connections.
return (
<DashboardProvider>
<ProxyProvider>
<Outlet />
</ProxyProvider>
</DashboardProvider>
)
}
}

View File

@ -1,5 +1,7 @@
import { Story } from "@storybook/react"
import {
MockPrimaryWorkspaceProxy,
MockWorkspaceProxies,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceAgentConnecting,
@ -16,6 +18,8 @@ import {
MockWorkspaceApp,
} from "testHelpers/entities"
import { AgentRow, AgentRowProps } from "./AgentRow"
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
import { Region } from "api/typesGenerated"
export default {
title: "components/AgentRow",
@ -36,7 +40,35 @@ export default {
},
}
const Template: Story<AgentRowProps> = (args) => <AgentRow {...args} />
const Template: Story<AgentRowProps> = (args) => {
return TemplateFC(args, [], undefined)
}
const TemplateWithPortForward: Story<AgentRowProps> = (args) => {
return TemplateFC(args, MockWorkspaceProxies, MockPrimaryWorkspaceProxy)
}
const TemplateFC = (
args: AgentRowProps,
proxies: Region[],
selectedProxy?: Region,
) => {
return (
<ProxyContext.Provider
value={{
proxy: getPreferredProxy(proxies, selectedProxy),
proxies: proxies,
isLoading: false,
isFetched: true,
setProxy: () => {
return
},
}}
>
<AgentRow {...args} />
</ProxyContext.Provider>
)
}
const defaultAgentMetadata = [
{
@ -109,7 +141,6 @@ Example.args = {
'set -eux -o pipefail\n\n# install and start code-server\ncurl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/tmp/code-server --version 4.8.3\n/tmp/code-server/bin/code-server --auth none --port 13337 >/tmp/code-server.log 2>&1 &\n\n\nif [ ! -d ~/coder ]; then\n mkdir -p ~/coder\n\n git clone https://github.com/coder/coder ~/coder\nfi\n\nsudo service docker start\nDOTFILES_URI=" "\nrm -f ~/.personalize.log\nif [ -n "${DOTFILES_URI// }" ]; then\n coder dotfiles "$DOTFILES_URI" -y 2>&1 | tee -a ~/.personalize.log\nfi\nif [ -x ~/personalize ]; then\n ~/personalize 2>&1 | tee -a ~/.personalize.log\nelif [ -f ~/personalize ]; then\n echo "~/personalize is not executable, skipping..." | tee -a ~/.personalize.log\nfi\n',
},
workspace: MockWorkspace,
applicationsHost: "",
showApps: true,
storybookAgentMetadata: defaultAgentMetadata,
}
@ -149,7 +180,6 @@ BunchOfApps.args = {
],
},
workspace: MockWorkspace,
applicationsHost: "",
showApps: true,
}
@ -223,10 +253,9 @@ Off.args = {
agent: MockWorkspaceAgentOff,
}
export const ShowingPortForward = Template.bind({})
export const ShowingPortForward = TemplateWithPortForward.bind({})
ShowingPortForward.args = {
...Example.args,
applicationsHost: "https://coder.com",
}
export const Outdated = Template.bind({})

View File

@ -43,11 +43,11 @@ import { AgentMetadata } from "./AgentMetadata"
import { AgentVersion } from "./AgentVersion"
import { AgentStatus } from "./AgentStatus"
import Collapse from "@material-ui/core/Collapse"
import { useProxy } from "contexts/ProxyContext"
export interface AgentRowProps {
agent: WorkspaceAgent
workspace: Workspace
applicationsHost: string | undefined
showApps: boolean
hideSSHButton?: boolean
sshPrefix?: string
@ -61,7 +61,6 @@ export interface AgentRowProps {
export const AgentRow: FC<AgentRowProps> = ({
agent,
workspace,
applicationsHost,
showApps,
hideSSHButton,
hideVSCodeDesktopButton,
@ -96,6 +95,7 @@ export const AgentRow: FC<AgentRowProps> = ({
const hasStartupFeatures =
Boolean(agent.startup_logs_length) ||
Boolean(logsMachine.context.startupLogs?.length)
const { proxy } = useProxy()
const [showStartupLogs, setShowStartupLogs] = useState(
agent.lifecycle_state !== "ready" && hasStartupFeatures,
@ -228,7 +228,6 @@ export const AgentRow: FC<AgentRowProps> = ({
{agent.apps.map((app) => (
<AppLink
key={app.slug}
appsHost={applicationsHost}
app={app}
agent={agent}
workspace={workspace}
@ -249,15 +248,16 @@ export const AgentRow: FC<AgentRowProps> = ({
sshPrefix={sshPrefix}
/>
)}
{applicationsHost !== undefined && applicationsHost !== "" && (
<PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentId={agent.id}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
{proxy.preferredWildcardHostname &&
proxy.preferredWildcardHostname !== "" && (
<PortForwardButton
host={proxy.preferredWildcardHostname}
workspaceName={workspace.name}
agentId={agent.id}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
</div>
)}

View File

@ -3,6 +3,7 @@ import { Story } from "@storybook/react"
import { MockWorkspace, MockWorkspaceResource } from "testHelpers/entities"
import { AgentRow } from "./AgentRow"
import { ResourceCard, ResourceCardProps } from "./ResourceCard"
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
export default {
title: "components/ResourceCard",
@ -15,15 +16,26 @@ export const Example = Template.bind({})
Example.args = {
resource: MockWorkspaceResource,
agentRow: (agent) => (
<AgentRow
showApps
key={agent.id}
agent={agent}
workspace={MockWorkspace}
applicationsHost=""
serverVersion=""
onUpdateAgent={action("updateAgent")}
/>
<ProxyContext.Provider
value={{
proxy: getPreferredProxy([], undefined),
proxies: [],
isLoading: false,
isFetched: true,
setProxy: () => {
return
},
}}
>
<AgentRow
showApps
key={agent.id}
agent={agent}
workspace={MockWorkspace}
serverVersion=""
onUpdateAgent={action("updateAgent")}
/>
</ProxyContext.Provider>
),
}
@ -70,14 +82,25 @@ BunchOfMetadata.args = {
],
},
agentRow: (agent) => (
<AgentRow
showApps
key={agent.id}
agent={agent}
workspace={MockWorkspace}
applicationsHost=""
serverVersion=""
onUpdateAgent={action("updateAgent")}
/>
<ProxyContext.Provider
value={{
proxy: getPreferredProxy([], undefined),
proxies: [],
isLoading: false,
isFetched: true,
setProxy: () => {
return
},
}}
>
<AgentRow
showApps
key={agent.id}
agent={agent}
workspace={MockWorkspace}
serverVersion=""
onUpdateAgent={action("updateAgent")}
/>
</ProxyContext.Provider>
),
}

View File

@ -9,6 +9,8 @@ import { NavLink } from "react-router-dom"
import { combineClasses } from "utils/combineClasses"
import AccountIcon from "@material-ui/icons/Person"
import SecurityIcon from "@material-ui/icons/LockOutlined"
import PublicIcon from "@material-ui/icons/Public"
import { useDashboard } from "components/Dashboard/DashboardProvider"
const SidebarNavItem: FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
@ -41,6 +43,7 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
const styles = useStyles()
const dashboard = useDashboard()
return (
<nav className={styles.sidebar}>
@ -76,6 +79,14 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
>
Tokens
</SidebarNavItem>
{dashboard.experiments.includes("moons") && (
<SidebarNavItem
href="workspace-proxies"
icon={<SidebarNavItemIcon icon={PublicIcon} />}
>
Workspace Proxy
</SidebarNavItem>
)}
</nav>
)
}

View File

@ -27,6 +27,7 @@ export const TerminalLink: FC<React.PropsWithChildren<TerminalLinkProps>> = ({
userName = "me",
workspaceName,
}) => {
// Always use the primary for the terminal link. This is a relative link.
const href = `/@${userName}/${workspaceName}${
agentName ? `.${agentName}` : ""
}/terminal`

View File

@ -6,6 +6,7 @@ import * as Mocks from "../../testHelpers/entities"
import { Workspace, WorkspaceErrors, WorkspaceProps } from "./Workspace"
import { withReactContext } from "storybook-react-context"
import EventSource from "eventsourcemock"
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
export default {
title: "components/Workspace",
@ -22,7 +23,21 @@ export default {
],
}
const Template: Story<WorkspaceProps> = (args) => <Workspace {...args} />
const Template: Story<WorkspaceProps> = (args) => (
<ProxyContext.Provider
value={{
proxy: getPreferredProxy([], undefined),
proxies: [],
isLoading: false,
isFetched: true,
setProxy: () => {
return
},
}}
>
<Workspace {...args} />
</ProxyContext.Provider>
)
export const Running = Template.bind({})
Running.args = {

View File

@ -59,7 +59,6 @@ export interface WorkspaceProps {
hideVSCodeDesktopButton?: boolean
workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>>
buildInfo?: TypesGen.BuildInfoResponse
applicationsHost?: string
sshPrefix?: string
template?: TypesGen.Template
quota_budget?: number
@ -92,7 +91,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
hideSSHButton,
hideVSCodeDesktopButton,
buildInfo,
applicationsHost,
sshPrefix,
template,
quota_budget,
@ -246,7 +244,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
key={agent.id}
agent={agent}
workspace={workspace}
applicationsHost={applicationsHost}
sshPrefix={sshPrefix}
showApps={canUpdateWorkspace}
hideSSHButton={hideSSHButton}

View File

@ -0,0 +1,53 @@
import {
MockPrimaryWorkspaceProxy,
MockWorkspaceProxies,
MockHealthyWildWorkspaceProxy,
} from "testHelpers/entities"
import { getPreferredProxy } from "./ProxyContext"
describe("ProxyContextGetURLs", () => {
it.each([
["empty", [], undefined, "", ""],
// Primary has no path app URL. Uses relative links
[
"primary",
[MockPrimaryWorkspaceProxy],
MockPrimaryWorkspaceProxy,
"",
MockPrimaryWorkspaceProxy.wildcard_hostname,
],
[
"regions selected",
MockWorkspaceProxies,
MockHealthyWildWorkspaceProxy,
MockHealthyWildWorkspaceProxy.path_app_url,
MockHealthyWildWorkspaceProxy.wildcard_hostname,
],
// Primary is the default if none selected
[
"no selected",
[MockPrimaryWorkspaceProxy],
undefined,
"",
MockPrimaryWorkspaceProxy.wildcard_hostname,
],
[
"regions no select primary default",
MockWorkspaceProxies,
undefined,
"",
MockPrimaryWorkspaceProxy.wildcard_hostname,
],
// This should never happen, when there is no primary
["no primary", [MockHealthyWildWorkspaceProxy], undefined, "", ""],
])(
`%p`,
(_, regions, selected, preferredPathAppURL, preferredWildcardHostname) => {
const preferred = getPreferredProxy(regions, selected)
expect(preferred.preferredPathAppURL).toBe(preferredPathAppURL)
expect(preferred.preferredWildcardHostname).toBe(
preferredWildcardHostname,
)
},
)
})

View File

@ -0,0 +1,206 @@
import { useQuery } from "@tanstack/react-query"
import { getApplicationsHost, getWorkspaceProxies } from "api/api"
import { Region } from "api/typesGenerated"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import {
createContext,
FC,
PropsWithChildren,
useContext,
useState,
} from "react"
interface ProxyContextValue {
proxy: PreferredProxy
proxies?: Region[]
// isfetched is true when the proxy api call is complete.
isFetched: boolean
// isLoading is true if the proxy is in the process of being fetched.
isLoading: boolean
error?: Error | unknown
setProxy: (selectedProxy: Region) => void
}
interface PreferredProxy {
// selectedProxy is the proxy the user has selected.
// Do not use the fields 'path_app_url' or 'wildcard_hostname' from this
// object. Use the preferred fields.
selectedProxy: Region | undefined
// PreferredPathAppURL is the URL of the proxy or it is the empty string
// to indicate using relative paths. To add a path to this:
// PreferredPathAppURL + "/path/to/app"
preferredPathAppURL: string
// PreferredWildcardHostname is a hostname that includes a wildcard.
preferredWildcardHostname: string
}
export const ProxyContext = createContext<ProxyContextValue | undefined>(
undefined,
)
/**
* ProxyProvider interacts with local storage to indicate the preferred workspace proxy.
*/
export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
// Try to load the preferred proxy from local storage.
let savedProxy = loadPreferredProxy()
if (!savedProxy) {
// If no preferred proxy is saved, then default to using relative paths
// and no subdomain support until the proxies are properly loaded.
// This is the same as a user not selecting any proxy.
savedProxy = getPreferredProxy([])
}
const [proxy, setProxy] = useState<PreferredProxy>(savedProxy)
const dashboard = useDashboard()
const experimentEnabled = dashboard?.experiments.includes("moons")
const queryKey = ["get-proxies"]
const {
data: proxiesResp,
error: proxiesError,
isLoading: proxiesLoading,
isFetched: proxiesFetched,
} = useQuery({
queryKey,
queryFn: getWorkspaceProxies,
// This onSuccess ensures the local storage is synchronized with the
// proxies returned by coderd. If the selected proxy is not in the list,
// then the user selection is removed.
onSuccess: (resp) => {
setAndSaveProxy(proxy.selectedProxy, resp.regions)
},
enabled: experimentEnabled,
})
const setAndSaveProxy = (
selectedProxy?: Region,
// By default the proxies come from the api call above.
// Allow the caller to override this if they have a more up
// to date list of proxies.
proxies: Region[] = proxiesResp?.regions || [],
) => {
if (!proxies) {
throw new Error(
"proxies are not yet loaded, so selecting a proxy makes no sense. How did you get here?",
)
}
const preferred = getPreferredProxy(proxies, selectedProxy)
// Save to local storage to persist the user's preference across reloads
// and other tabs.
savePreferredProxy(preferred)
// Set the state for the current context.
setProxy(preferred)
}
// ******************************* //
// ** This code can be removed **
// ** when the experimental is **
// ** dropped ** //
const appHostQueryKey = ["get-application-host"]
const {
data: applicationHostResult,
error: appHostError,
isLoading: appHostLoading,
isFetched: appHostFetched,
} = useQuery({
queryKey: appHostQueryKey,
queryFn: getApplicationsHost,
enabled: !experimentEnabled,
})
return (
<ProxyContext.Provider
value={{
proxy: experimentEnabled
? proxy
: {
...getPreferredProxy([]),
preferredWildcardHostname: applicationHostResult?.host || "",
},
proxies: experimentEnabled ? proxiesResp?.regions : [],
isLoading: experimentEnabled ? proxiesLoading : appHostLoading,
isFetched: experimentEnabled ? proxiesFetched : appHostFetched,
error: experimentEnabled ? proxiesError : appHostError,
// A function that takes the new proxies and selected proxy and updates
// the state with the appropriate urls.
setProxy: setAndSaveProxy,
}}
>
{children}
</ProxyContext.Provider>
)
}
export const useProxy = (): ProxyContextValue => {
const context = useContext(ProxyContext)
if (!context) {
throw new Error("useProxy should be used inside of <ProxyProvider />")
}
return context
}
/**
* getURLs is a helper function to calculate the urls to use for a given proxy configuration. By default, it is
* assumed no proxy is configured and relative paths should be used.
* Exported for testing.
*
* @param proxies Is the list of proxies returned by coderd. If this is empty, default behavior is used.
* @param selectedProxy Is the proxy the user has selected. If this is undefined, default behavior is used.
*/
export const getPreferredProxy = (
proxies: Region[],
selectedProxy?: Region,
): PreferredProxy => {
// By default we set the path app to relative and disable wildcard hostnames.
// We will set these values if we find a proxy we can use that supports them.
let pathAppURL = ""
let wildcardHostname = ""
// If a proxy is selected, make sure it is in the list of proxies. If it is not
// we should default to the primary.
selectedProxy = proxies.find(
(proxy) => selectedProxy && proxy.id === selectedProxy.id,
)
if (!selectedProxy) {
// If no proxy is selected, default to the primary proxy.
selectedProxy = proxies.find((proxy) => proxy.name === "primary")
}
// Only use healthy proxies.
if (selectedProxy && selectedProxy.healthy) {
// By default use relative links for the primary proxy.
// This is the default, and we should not change it.
if (selectedProxy.name !== "primary") {
pathAppURL = selectedProxy.path_app_url
}
wildcardHostname = selectedProxy.wildcard_hostname
}
// TODO: @emyrk Should we notify the user if they had an unhealthy proxy selected?
return {
selectedProxy: selectedProxy,
// Trim trailing slashes to be consistent
preferredPathAppURL: pathAppURL.replace(/\/$/, ""),
preferredWildcardHostname: wildcardHostname,
}
}
// Local storage functions
export const savePreferredProxy = (saved: PreferredProxy): void => {
window.localStorage.setItem("preferred-proxy", JSON.stringify(saved))
}
const loadPreferredProxy = (): PreferredProxy | undefined => {
const str = localStorage.getItem("preferred-proxy")
if (!str) {
return undefined
}
return JSON.parse(str)
}

View File

@ -2,13 +2,19 @@ import { waitFor } from "@testing-library/react"
import "jest-canvas-mock"
import WS from "jest-websocket-mock"
import { rest } from "msw"
import { Route, Routes } from "react-router-dom"
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"
import {
MockPrimaryWorkspaceProxy,
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceProxies,
} from "testHelpers/entities"
import { TextDecoder, TextEncoder } from "util"
import { ReconnectingPTYRequest } from "../../api/types"
import { history, render } from "../../testHelpers/renderHelpers"
import { server } from "../../testHelpers/server"
import TerminalPage, { Language } from "./TerminalPage"
import { Route, Routes } from "react-router-dom"
import { ProxyContext } from "contexts/ProxyContext"
Object.defineProperty(window, "matchMedia", {
writable: true,
@ -29,11 +35,28 @@ Object.defineProperty(window, "TextEncoder", {
})
const renderTerminal = () => {
// @emyrk using renderWithAuth would be best here, but I was unable to get it to work.
return render(
<Routes>
<Route
path="/:username/:workspace/terminal"
element={<TerminalPage renderer="dom" />}
element={
<ProxyContext.Provider
value={{
proxy: {
selectedProxy: MockPrimaryWorkspaceProxy,
preferredPathAppURL: "",
preferredWildcardHostname: "",
},
proxies: MockWorkspaceProxies,
isFetched: true,
isLoading: false,
setProxy: jest.fn(),
}}
>
<TerminalPage renderer="dom" />
</ProxyContext.Provider>
}
/>
</Routes>,
)

View File

@ -14,6 +14,7 @@ import "xterm/css/xterm.css"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
import { pageTitle } from "../../utils/page"
import { terminalMachine } from "../../xServices/terminal/terminalXService"
import { useProxy } from "contexts/ProxyContext"
export const Language = {
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
@ -56,6 +57,7 @@ const TerminalPage: FC<
> = ({ renderer }) => {
const navigate = useNavigate()
const styles = useStyles()
const { proxy } = useProxy()
const { username, workspace: workspaceName } = useParams()
const xtermRef = useRef<HTMLDivElement>(null)
const [terminal, setTerminal] = useState<XTerm.Terminal | null>(null)
@ -76,6 +78,7 @@ const TerminalPage: FC<
workspaceName: workspaceNameParts?.[0],
username: username,
command: command,
baseURL: proxy.preferredPathAppURL,
},
actions: {
readMessage: (_, event) => {
@ -97,14 +100,18 @@ const TerminalPage: FC<
workspaceAgentError,
workspaceAgent,
websocketError,
applicationsHost,
} = terminalState.context
const reloading = useReloading(isDisconnected)
// handleWebLink handles opening of URLs in the terminal!
const handleWebLink = useCallback(
(uri: string) => {
if (!workspaceAgent || !workspace || !username || !applicationsHost) {
if (
!workspaceAgent ||
!workspace ||
!username ||
!proxy.preferredWildcardHostname
) {
return
}
@ -132,7 +139,7 @@ const TerminalPage: FC<
}
open(
portForwardURL(
applicationsHost,
proxy.preferredWildcardHostname,
parseInt(url.port),
workspaceAgent.name,
workspace.name,
@ -143,7 +150,7 @@ const TerminalPage: FC<
open(uri)
}
},
[workspaceAgent, workspace, username, applicationsHost],
[workspaceAgent, workspace, username, proxy.preferredWildcardHostname],
)
// Create the terminal!

View File

@ -0,0 +1,63 @@
import { FC, PropsWithChildren } from "react"
import { Section } from "components/SettingsLayout/Section"
import { WorkspaceProxyView } from "./WorkspaceProxyView"
import makeStyles from "@material-ui/core/styles/makeStyles"
import { displayError } from "components/GlobalSnackbar/utils"
import { useProxy } from "contexts/ProxyContext"
export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
const styles = useStyles()
const description =
"Workspace proxies are used to reduce the latency of connections to a" +
"workspace. To get the best experience, choose the workspace proxy that is" +
"closest located to you."
const {
proxies,
error: proxiesError,
isFetched: proxiesFetched,
isLoading: proxiesLoading,
proxy,
setProxy,
} = useProxy()
return (
<Section
title="Workspace Proxies"
className={styles.section}
description={description}
layout="fluid"
>
<WorkspaceProxyView
proxies={proxies}
isLoading={proxiesLoading}
hasLoaded={proxiesFetched}
getWorkspaceProxiesError={proxiesError}
preferredProxy={proxy.selectedProxy}
onSelect={(proxy) => {
if (!proxy.healthy) {
displayError("Please select a healthy workspace proxy.")
return
}
setProxy(proxy)
}}
/>
</Section>
)
}
const useStyles = makeStyles((theme) => ({
section: {
"& code": {
background: theme.palette.divider,
fontSize: 12,
padding: "2px 4px",
color: theme.palette.text.primary,
borderRadius: 2,
},
},
}))
export default WorkspaceProxyPage

View File

@ -0,0 +1,78 @@
import { Region } from "api/typesGenerated"
import { AvatarData } from "components/AvatarData/AvatarData"
import { Avatar } from "components/Avatar/Avatar"
import { useClickableTableRow } from "hooks/useClickableTableRow"
import TableCell from "@material-ui/core/TableCell"
import TableRow from "@material-ui/core/TableRow"
import { FC } from "react"
import {
HealthyBadge,
NotHealthyBadge,
} from "components/DeploySettingsLayout/Badges"
import { makeStyles } from "@material-ui/core/styles"
import { combineClasses } from "utils/combineClasses"
export const ProxyRow: FC<{
proxy: Region
onSelectRegion: (proxy: Region) => void
preferred: boolean
}> = ({ proxy, onSelectRegion, preferred }) => {
const styles = useStyles()
const clickable = useClickableTableRow(() => {
onSelectRegion(proxy)
})
return (
<TableRow
key={proxy.name}
data-testid={`${proxy.name}`}
{...clickable}
// Make sure to include our classname here.
className={combineClasses({
[clickable.className]: true,
[styles.preferredrow]: preferred,
})}
>
<TableCell>
<AvatarData
title={
proxy.display_name && proxy.display_name.length > 0
? proxy.display_name
: proxy.name
}
avatar={
proxy.icon_url !== "" && (
<Avatar src={proxy.icon_url} variant="square" fitImage />
)
}
/>
</TableCell>
<TableCell>{proxy.path_app_url}</TableCell>
<TableCell>
<ProxyStatus proxy={proxy} />
</TableCell>
</TableRow>
)
}
const ProxyStatus: FC<{
proxy: Region
}> = ({ proxy }) => {
let icon = <NotHealthyBadge />
if (proxy.healthy) {
icon = <HealthyBadge />
}
return icon
}
const useStyles = makeStyles((theme) => ({
preferredrow: {
// TODO: What is the best way to show what proxy is currently being used?
backgroundColor: theme.palette.secondary.main,
outline: `3px solid ${theme.palette.secondary.light}`,
outlineOffset: -3,
},
}))

View File

@ -0,0 +1,80 @@
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { Stack } from "components/Stack/Stack"
import { TableEmpty } from "components/TableEmpty/TableEmpty"
import { TableLoader } from "components/TableLoader/TableLoader"
import { FC } from "react"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Region } from "api/typesGenerated"
import { ProxyRow } from "./WorkspaceProxyRow"
export interface WorkspaceProxyViewProps {
proxies?: Region[]
getWorkspaceProxiesError?: Error | unknown
isLoading: boolean
hasLoaded: boolean
onSelect: (proxy: Region) => void
preferredProxy?: Region
selectProxyError?: Error | unknown
}
export const WorkspaceProxyView: FC<
React.PropsWithChildren<WorkspaceProxyViewProps>
> = ({
proxies,
getWorkspaceProxiesError,
isLoading,
hasLoaded,
onSelect,
selectProxyError,
preferredProxy,
}) => {
return (
<Stack>
{Boolean(getWorkspaceProxiesError) && (
<AlertBanner severity="error" error={getWorkspaceProxiesError} />
)}
{Boolean(selectProxyError) && (
<AlertBanner severity="error" error={selectProxyError} />
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="40%">Proxy</TableCell>
<TableCell width="30%">URL</TableCell>
<TableCell width="10%">Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={hasLoaded && proxies?.length === 0}>
<TableEmpty message="No workspace proxies found" />
</Cond>
<Cond>
{proxies?.map((proxy) => (
<ProxyRow
key={proxy.id}
proxy={proxy}
onSelectRegion={onSelect}
preferred={
preferredProxy ? proxy.id === preferredProxy.id : false
}
/>
))}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</Stack>
)
}

View File

@ -0,0 +1,76 @@
import { Story } from "@storybook/react"
import {
makeMockApiError,
MockWorkspaceProxies,
MockPrimaryWorkspaceProxy,
MockHealthyWildWorkspaceProxy,
} from "testHelpers/entities"
import {
WorkspaceProxyView,
WorkspaceProxyViewProps,
} from "./WorkspaceProxyView"
export default {
title: "components/WorkspaceProxyView",
component: WorkspaceProxyView,
args: {
onRegenerateClick: { action: "Submit" },
},
}
const Template: Story<WorkspaceProxyViewProps> = (
args: WorkspaceProxyViewProps,
) => <WorkspaceProxyView {...args} />
export const PrimarySelected = Template.bind({})
PrimarySelected.args = {
isLoading: false,
hasLoaded: true,
proxies: MockWorkspaceProxies,
preferredProxy: MockPrimaryWorkspaceProxy,
onSelect: () => {
return Promise.resolve()
},
}
export const Example = Template.bind({})
Example.args = {
isLoading: false,
hasLoaded: true,
proxies: MockWorkspaceProxies,
preferredProxy: MockHealthyWildWorkspaceProxy,
onSelect: () => {
return Promise.resolve()
},
}
export const Loading = Template.bind({})
Loading.args = {
...Example.args,
isLoading: true,
hasLoaded: false,
}
export const Empty = Template.bind({})
Empty.args = {
...Example.args,
proxies: [],
}
export const WithProxiesError = Template.bind({})
WithProxiesError.args = {
...Example.args,
hasLoaded: false,
getWorkspaceProxiesError: makeMockApiError({
message: "Failed to get proxies.",
}),
}
export const WithSelectProxyError = Template.bind({})
WithSelectProxyError.args = {
...Example.args,
hasLoaded: false,
selectProxyError: makeMockApiError({
message: "Failed to select proxy.",
}),
}

View File

@ -57,7 +57,6 @@ export const WorkspaceReadyPage = ({
getBuildsError,
buildError,
cancellationError,
applicationsHost,
sshPrefix,
permissions,
missedParameters,
@ -153,7 +152,6 @@ export const WorkspaceReadyPage = ({
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
}}
buildInfo={buildInfo}
applicationsHost={applicationsHost}
sshPrefix={sshPrefix}
template={template}
quota_budget={quotaState.context.quota?.budget}

View File

@ -68,9 +68,54 @@ export const MockTokens: TypesGen.APIKeyWithOwner[] = [
},
]
export const MockPrimaryWorkspaceProxy: TypesGen.Region = {
id: "4aa23000-526a-481f-a007-0f20b98b1e12",
name: "primary",
display_name: "Default",
icon_url: "/emojis/1f60e.png",
healthy: true,
path_app_url: "https://coder.com",
wildcard_hostname: "*.coder.com",
}
export const MockHealthyWildWorkspaceProxy: TypesGen.Region = {
id: "5e2c1ab7-479b-41a9-92ce-aa85625de52c",
name: "haswildcard",
display_name: "Subdomain Supported",
icon_url: "/emojis/1f319.png",
healthy: true,
path_app_url: "https://external.com",
wildcard_hostname: "*.external.com",
}
export const MockWorkspaceProxies: TypesGen.Region[] = [
MockPrimaryWorkspaceProxy,
MockHealthyWildWorkspaceProxy,
{
id: "8444931c-0247-4171-842a-569d9f9cbadb",
name: "unhealthy",
display_name: "Unhealthy",
icon_url: "/emojis/1f92e.png",
healthy: false,
path_app_url: "https://unhealthy.coder.com",
wildcard_hostname: "*unhealthy..coder.com",
},
{
id: "26e84c16-db24-4636-a62d-aa1a4232b858",
name: "nowildcard",
display_name: "No wildcard",
icon_url: "/emojis/1f920.png",
healthy: true,
path_app_url: "https://cowboy.coder.com",
wildcard_hostname: "",
},
]
export const MockBuildInfo: TypesGen.BuildInfoResponse = {
external_url: "file:///mock-url",
version: "v99.999.9999+c9cdf14",
dashboard_url: "https:///mock-url",
workspace_proxy: false,
}
export const MockSupportLinks: TypesGen.LinkConfig[] = [

View File

@ -15,7 +15,15 @@ export const handlers = [
rest.get("/api/v2/insights/daus", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockDeploymentDAUResponse))
}),
// Workspace proxies
rest.get("/api/v2/regions", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
regions: M.MockWorkspaceProxies,
}),
)
}),
// build info
rest.get("/api/v2/buildinfo", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockBuildInfo))

View File

@ -10,7 +10,8 @@ export interface TerminalContext {
workspaceAgentError?: Error | unknown
websocket?: WebSocket
websocketError?: Error | unknown
applicationsHost?: string
websocketURL?: string
websocketURLError?: Error | unknown
// Assigned by connecting!
// The workspace agent is entirely optional. If the agent is omitted the
@ -20,6 +21,8 @@ export interface TerminalContext {
workspaceName?: string
reconnection?: string
command?: string
// If baseURL is not.....
baseURL?: string
}
export type TerminalEvent =
@ -35,7 +38,7 @@ export type TerminalEvent =
| { type: "DISCONNECT" }
export const terminalMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOhmWVygHk0o8csAvMrAex1gGIJuYcrgBunANZCqDJi3Y1u8JCAAOnWFgU5EoAB6IA7AE4AzOSMBGAEwBWCyYAsANgs2bVgDQgAnohNGbcgsnEIcHCwAOBwAGCKMHAF8Er1RMXEISMikwaloZZjYORV50NE40chUCMgAzcoxKHPy5Ip4dVXVNLm1lfQQIiLMIpxMbQYjYo2jXL18EawtyAwGwk1HTMdGklPRsfGJSCioaHCgAdXLxWBU8AGMwfkFhHDFJRuQLtCub+-a1DS07T69icVnILisDhspiccQ2sz8y3INmiqNGsMcBmiNm2IFSewyh2yuVOn2+dwepXKlWqyDqmHeZOuFL+nUBvUQILBEKhMLhowRCAMTkCIzWDkcEyMBhsBlx+PSByy7xO50uzPuAEEYDhkI8cEJRBJiUyfmBtWBdayAd0gZyjCFyFZbKjnC4jKYLIK7GYYqirCYBk5olYDIlknjdorMkccqrTRSLbqSmgyhUqrV6oz1Wak8hrV1uHb5g6nE6XdE3RYPSYvT5EWCA2s7E4sbZhfKo-sY0JbtwDbdVfrDS9jeQ+zgB-nlP9Cz09IhIcLyCZIUYotEDCZNxEDN7peY1ms4gYrNNBp20t2ieP+2BB7QU2maZmGROpwX2QuEEuy6uHOuMRbjue71ggdiLPY4phiYMoWNYl4EkqFDvveqAQLwZwAEoAJIACoAKKfraHI-gYkTkCGAFYmG0TbnWcw2A4YKmGskJno44RWIh0Y3qhg6QLwWEEZqAAixFFqRobViusKngYMpWEGgpQmWUFsSEcSOHRPHXsq-Hobwok4UQADCdAAHIWQRpl4RJ84gH0SmtuQDgTNWMJTDKJgqUEq4RNCljTOs3ERgqekUBAWCwAZgnmVZNl2TObIkd+zplrEYanvY0SwrCESCs6BiuaidGrgGTgekYSQRjgnAQHA7ThYSyrHHkjAFPI3RKKAs5fo5iAxIEXHMSMErWNiTiFa4K6ldi1ayu4FjhjsV4tbGJJql8GpgPZxYWNMDiubBdh2Ju7pGIK-jFW6rYiq4bZOLp63EvGOaJjq069Slknfq4jrOii51udiMpXbC5ATKi1ghNEljNs9yG9neD6nHtUlnhErmgqeIyosKazejNZ7+qMi3BiYiM9rek5oZA6NpeR0ROmGpgnhT0qCmNSxHhKW4BiGERUzeUUxSj6EMwN4HM5ukLLXBViRKGNhXVj64etM0JOG5YTC1kktORlp7hA4CtK2DYEALQQ8M5FKQd27OJTNVAA */
/** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOljGQFcAHAYggHscxLSLVNdCSz2VWnQDaABgC6iUHWawsyLK2kgAHogAcAVgCM5MQGYdAJgMaALOYDsV8zq0AaEAE9ExgGwHyATmPf3VmI6Yub+5gYAvhFO3Nj4xJyC1PTkMMgA6sxoANawdHgAxuxpijhQmTl5hWBMrOy4AG7M2cXUFbn5ReJSSCCy8orKveoI5qbkOhq23hozflruxuZOrghGXuZaRsFiWlbeWuYa7lEx6HF8iZTJdKltWR3Vd8il5Q9VRQzoaFnkdARkABmWQwz3aHzA3RU-QUShwKhGYy8k2msw080WyxcbmMYnIoW8hO8ZkMYisOlOIFivASAmer3BnTAAEEYDhkLU2ORGs1Whl3kzWWB2VDejDBvDhogdAYrFoJuYxJjdmJvCENCs3Fo8e4glpjFptWSDhpKdT4vwKCVcG9KoK2Rzvr9-kCQWCBdUhSLJNC5LChqARotNQgpniNAYtAdjFYDL4pidolTzjTLXyGWAAEZEZgFFrIACqACUADKc+o4JotZ45vPUYsl0UyP0ShGIWVWchGA27Akzbwh4LjDQmAk6AmBbxmlMWq7WsrpLO1-MNr5oH5oP4A5DAzA13Mr0tNvotuFthBWYyDo56ULGewWHTk0zGac8Wd0gqsNgFV7l7mVry5BfjgP7IMe4pnlKobmO45D3mG7ihFMBgBIOhgaPoizar4xIRv4b4XLSFAgWBNprhuW6unupFgL+EGngGaiIMG2IIPYtj4psMYaGIxh+Do9iEamVy0b+kAMOkRYAJIACoAKIMQMUGBogdiYZs3Z+N444GqYg42F4kY6LqmxWLMUaREm5qXJ+350agEAMEW8nMgAIkp-qSqpow6N45BIRYkYRgsBxyoOASdnG2gyu43jcUhwkfiR9niU5bnSUQADCADyAByeXyVlsmea20F+Zho6EksgU6WIGpsZMuj6OZfimLB0bmEltkUBAWCwGJjkMLlBVFSVPpiox3nMexV6Na+lI4MwEBwCoNnEWAvrKUxIwALRjCGu1yuQRpiCEBpyrshLdRt1zCFtXnnu4IabCdZ1nWMezalGFLWTOPVJMI7p2tUD1lT5hyDgYXjvR9KrxSZXV-e+AN3SkaSMk8862o8RRgypM1DiGL4+FY7iGvqniXvYSNnCjt1COj9wg0UlA0AURSwPAk3bdNQbQ-o3YLCYHGwcTRwBeE-GYjToRWDdab0jamNFF6yD4ztiD+PKgTdtYljuAEBjE3G+J+HFI7ah45IK3O1AZtmB71qWGt84gezofe+j2Lq7h+XxSFWXTRGK4NNqu09fFYUssyLPxCyOI1hjyh4CzW1b3jy8jIeialjkR9BhzweTiwBPqOm2Asg5TOYXbqmY6xISEtt0n1A155ABc+aheiGCYfhGAnthWIO33kOSxwRn3vgBFEURAA */
createMachine(
{
id: "terminalState",
@ -48,12 +51,12 @@ export const terminalMachine =
getWorkspace: {
data: TypesGen.Workspace
}
getApplicationsHost: {
data: TypesGen.AppHostResponse
}
getWorkspaceAgent: {
data: TypesGen.WorkspaceAgent
}
getWebsocketURL: {
data: string
}
connect: {
data: WebSocket
}
@ -64,27 +67,6 @@ export const terminalMachine =
setup: {
type: "parallel",
states: {
getApplicationsHost: {
initial: "gettingApplicationsHost",
states: {
gettingApplicationsHost: {
invoke: {
src: "getApplicationsHost",
id: "getApplicationsHost",
onDone: {
actions: [
"assignApplicationsHost",
"clearApplicationsHostError",
],
target: "success",
},
},
},
success: {
type: "final",
},
},
},
getWorkspace: {
initial: "gettingWorkspace",
states: {
@ -123,7 +105,7 @@ export const terminalMachine =
onDone: [
{
actions: ["assignWorkspaceAgent", "clearWorkspaceAgentError"],
target: "connecting",
target: "gettingWebSocketURL",
},
],
onError: [
@ -134,6 +116,24 @@ export const terminalMachine =
],
},
},
gettingWebSocketURL: {
invoke: {
src: "getWebsocketURL",
id: "getWebsocketURL",
onDone: [
{
actions: ["assignWebsocketURL", "clearWebsocketURLError"],
target: "connecting",
},
],
onError: [
{
actions: "assignWebsocketURLError",
target: "disconnected",
},
],
},
},
connecting: {
invoke: {
src: "connect",
@ -187,9 +187,6 @@ export const terminalMachine =
context.workspaceName,
)
},
getApplicationsHost: async () => {
return API.getApplicationsHost()
},
getWorkspaceAgent: async (context) => {
if (!context.workspace || !context.workspaceName) {
throw new Error("workspace or workspace name is not set")
@ -213,17 +210,60 @@ export const terminalMachine =
}
return agent
},
getWebsocketURL: async (context) => {
if (!context.workspaceAgent) {
throw new Error("workspace agent is not set")
}
if (!context.reconnection) {
throw new Error("reconnection ID is not set")
}
let baseURL = context.baseURL || ""
if (!baseURL) {
baseURL = `${location.protocol}//${location.host}`
}
const query = new URLSearchParams({
reconnect: context.reconnection,
})
if (context.command) {
query.set("command", context.command)
}
const url = new URL(baseURL)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (!url.pathname.endsWith("/")) {
url.pathname + "/"
}
url.pathname += `api/v2/workspaceagents/${context.workspaceAgent.id}/pty`
url.search = "?" + query.toString()
// If the URL is just the primary API, we don't need a signed token to
// connect.
if (!context.baseURL) {
return url.toString()
}
// Do ticket issuance and set the query parameter.
const tokenRes = await API.issueReconnectingPTYSignedToken({
url: url.toString(),
agentID: context.workspaceAgent.id,
})
query.set("coder_signed_app_token_23db1dde", tokenRes.signed_token)
url.search = "?" + query.toString()
return url.toString()
},
connect: (context) => (send) => {
return new Promise<WebSocket>((resolve, reject) => {
if (!context.workspaceAgent) {
return reject("workspace agent is not set")
}
const proto = location.protocol === "https:" ? "wss:" : "ws:"
const commandQuery = context.command
? `&command=${encodeURIComponent(context.command)}`
: ""
const url = `${proto}//${location.host}/api/v2/workspaceagents/${context.workspaceAgent.id}/pty?reconnect=${context.reconnection}${commandQuery}`
const socket = new WebSocket(url)
if (!context.websocketURL) {
return reject("websocket URL is not set")
}
const socket = new WebSocket(context.websocketURL)
socket.binaryType = "arraybuffer"
socket.addEventListener("open", () => {
resolve(socket)
@ -262,13 +302,6 @@ export const terminalMachine =
...context,
workspaceError: undefined,
})),
assignApplicationsHost: assign({
applicationsHost: (_, { data }) => data.host,
}),
clearApplicationsHostError: assign((context) => ({
...context,
applicationsHostError: undefined,
})),
assignWorkspaceAgent: assign({
workspaceAgent: (_, event) => event.data,
}),
@ -289,6 +322,16 @@ export const terminalMachine =
...context,
webSocketError: undefined,
})),
assignWebsocketURL: assign({
websocketURL: (context, event) => event.data ?? context.websocketURL,
}),
assignWebsocketURLError: assign({
websocketURLError: (_, event) => event.data,
}),
clearWebsocketURLError: assign((context: TerminalContext) => ({
...context,
websocketURLError: undefined,
})),
sendMessage: (context, event) => {
if (!context.websocket) {
throw new Error("websocket doesn't exist")

View File

@ -74,8 +74,6 @@ export interface WorkspaceContext {
// permissions
permissions?: Permissions
checkPermissionsError?: Error | unknown
// applications
applicationsHost?: string
// debug
createBuildLogLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"]
// SSH Config
@ -189,9 +187,6 @@ export const workspaceMachine = createMachine(
checkPermissions: {
data: TypesGen.AuthorizationResponse
}
getApplicationsHost: {
data: TypesGen.AppHostResponse
}
getSSHPrefix: {
data: TypesGen.SSHConfigResponse
}
@ -504,30 +499,6 @@ export const workspaceMachine = createMachine(
},
},
},
applications: {
initial: "gettingApplicationsHost",
states: {
gettingApplicationsHost: {
invoke: {
src: "getApplicationsHost",
onDone: {
target: "success",
actions: ["assignApplicationsHost"],
},
onError: {
target: "error",
actions: ["displayApplicationsHostError"],
},
},
},
error: {
type: "final",
},
success: {
type: "final",
},
},
},
sshConfig: {
initial: "gettingSshConfig",
states: {
@ -660,17 +631,6 @@ export const workspaceMachine = createMachine(
clearGetBuildsError: assign({
getBuildsError: (_) => undefined,
}),
// Applications
assignApplicationsHost: assign({
applicationsHost: (_, { data }) => data.host,
}),
displayApplicationsHostError: (_, { data }) => {
const message = getErrorMessage(
data,
"Error getting the applications host.",
)
displayError(message)
},
// SSH
assignSSHPrefix: assign({
sshPrefix: (_, { data }) => data.hostname_prefix,
@ -880,9 +840,6 @@ export const workspaceMachine = createMachine(
checks: permissionsToCheck(workspace, template),
})
},
getApplicationsHost: async () => {
return API.getApplicationsHost()
},
scheduleBannerMachine: workspaceScheduleBannerMachine,
getSSHPrefix: async () => {
return API.getDeploymentSSHConfig()

View File

@ -67,6 +67,7 @@ export default defineConfig({
api: path.resolve(__dirname, "./src/api"),
components: path.resolve(__dirname, "./src/components"),
hooks: path.resolve(__dirname, "./src/hooks"),
contexts: path.resolve(__dirname, "./src/contexts"),
i18n: path.resolve(__dirname, "./src/i18n"),
pages: path.resolve(__dirname, "./src/pages"),
testHelpers: path.resolve(__dirname, "./src/testHelpers"),