mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
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:
@ -618,6 +618,12 @@ func (s *Server) workspaceAgentPTY(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
|
||||||
CompressionMode: websocket.CompressionDisabled,
|
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 {
|
if err != nil {
|
||||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -57,26 +58,31 @@ func (api *API) regions(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyHealth := api.ProxyHealth.HealthStatus()
|
// Only add additional regions if the proxy health is enabled.
|
||||||
for _, proxy := range proxies {
|
// If it is nil, it is because the moons feature flag is not on.
|
||||||
if proxy.Deleted {
|
// By default, we still want to return the primary region.
|
||||||
continue
|
if api.ProxyHealth != nil {
|
||||||
}
|
proxyHealth := api.ProxyHealth.HealthStatus()
|
||||||
|
for _, proxy := range proxies {
|
||||||
|
if proxy.Deleted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
health, ok := proxyHealth[proxy.ID]
|
health, ok := proxyHealth[proxy.ID]
|
||||||
if !ok {
|
if !ok {
|
||||||
health.Status = proxyhealth.Unknown
|
health.Status = proxyhealth.Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
regions = append(regions, codersdk.Region{
|
regions = append(regions, codersdk.Region{
|
||||||
ID: proxy.ID,
|
ID: proxy.ID,
|
||||||
Name: proxy.Name,
|
Name: proxy.Name,
|
||||||
DisplayName: proxy.DisplayName,
|
DisplayName: proxy.DisplayName,
|
||||||
IconURL: proxy.Icon,
|
IconURL: proxy.Icon,
|
||||||
Healthy: health.Status == proxyhealth.Healthy,
|
Healthy: health.Status == proxyhealth.Healthy,
|
||||||
PathAppURL: proxy.Url,
|
PathAppURL: proxy.Url,
|
||||||
WildcardHostname: proxy.WildcardHostname,
|
WildcardHostname: proxy.WildcardHostname,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.RegionsResponse{
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.RegionsResponse{
|
||||||
@ -156,6 +162,20 @@ func (api *API) postWorkspaceProxy(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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()
|
id := uuid.New()
|
||||||
secret, err := cryptorand.HexString(64)
|
secret, err := cryptorand.HexString(64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -38,6 +38,10 @@ const SSHKeysPage = lazy(
|
|||||||
const TokensPage = lazy(
|
const TokensPage = lazy(
|
||||||
() => import("./pages/UserSettingsPage/TokensPage/TokensPage"),
|
() => import("./pages/UserSettingsPage/TokensPage/TokensPage"),
|
||||||
)
|
)
|
||||||
|
const WorkspaceProxyPage = lazy(
|
||||||
|
() =>
|
||||||
|
import("./pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage"),
|
||||||
|
)
|
||||||
const CreateUserPage = lazy(
|
const CreateUserPage = lazy(
|
||||||
() => import("./pages/UsersPage/CreateUserPage/CreateUserPage"),
|
() => import("./pages/UsersPage/CreateUserPage/CreateUserPage"),
|
||||||
)
|
)
|
||||||
@ -272,6 +276,10 @@ export const AppRouter: FC = () => {
|
|||||||
<Route index element={<TokensPage />} />
|
<Route index element={<TokensPage />} />
|
||||||
<Route path="new" element={<CreateTokenPage />} />
|
<Route path="new" element={<CreateTokenPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route
|
||||||
|
path="workspace-proxies"
|
||||||
|
element={<WorkspaceProxyPage />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/@:username">
|
<Route path="/@:username">
|
||||||
|
@ -944,6 +944,14 @@ export const getFile = async (fileId: string): Promise<ArrayBuffer> => {
|
|||||||
return response.data
|
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> => {
|
export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/v2/appearance`)
|
const response = await axios.get(`/api/v2/appearance`)
|
||||||
@ -1292,3 +1300,13 @@ export const watchBuildLogsByBuildId = (
|
|||||||
})
|
})
|
||||||
return socket
|
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
|
||||||
|
}
|
||||||
|
@ -1,17 +1,34 @@
|
|||||||
import { Story } from "@storybook/react"
|
import { Story } from "@storybook/react"
|
||||||
import {
|
import {
|
||||||
|
MockPrimaryWorkspaceProxy,
|
||||||
|
MockWorkspaceProxies,
|
||||||
MockWorkspace,
|
MockWorkspace,
|
||||||
MockWorkspaceAgent,
|
MockWorkspaceAgent,
|
||||||
MockWorkspaceApp,
|
MockWorkspaceApp,
|
||||||
} from "testHelpers/entities"
|
} from "testHelpers/entities"
|
||||||
import { AppLink, AppLinkProps } from "./AppLink"
|
import { AppLink, AppLinkProps } from "./AppLink"
|
||||||
|
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "components/AppLink",
|
title: "components/AppLink",
|
||||||
component: 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({})
|
export const WithIcon = Template.bind({})
|
||||||
WithIcon.args = {
|
WithIcon.args = {
|
||||||
|
@ -10,6 +10,7 @@ import * as TypesGen from "../../api/typesGenerated"
|
|||||||
import { generateRandomString } from "../../utils/random"
|
import { generateRandomString } from "../../utils/random"
|
||||||
import { BaseIcon } from "./BaseIcon"
|
import { BaseIcon } from "./BaseIcon"
|
||||||
import { ShareIcon } from "./ShareIcon"
|
import { ShareIcon } from "./ShareIcon"
|
||||||
|
import { useProxy } from "contexts/ProxyContext"
|
||||||
|
|
||||||
const Language = {
|
const Language = {
|
||||||
appTitle: (appName: string, identifier: string): string =>
|
appTitle: (appName: string, identifier: string): string =>
|
||||||
@ -17,18 +18,16 @@ const Language = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AppLinkProps {
|
export interface AppLinkProps {
|
||||||
appsHost?: string
|
|
||||||
workspace: TypesGen.Workspace
|
workspace: TypesGen.Workspace
|
||||||
app: TypesGen.WorkspaceApp
|
app: TypesGen.WorkspaceApp
|
||||||
agent: TypesGen.WorkspaceAgent
|
agent: TypesGen.WorkspaceAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppLink: FC<AppLinkProps> = ({
|
export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
|
||||||
appsHost,
|
const { proxy } = useProxy()
|
||||||
app,
|
const preferredPathBase = proxy.preferredPathAppURL
|
||||||
workspace,
|
const appsHost = proxy.preferredWildcardHostname
|
||||||
agent,
|
|
||||||
}) => {
|
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const username = workspace.owner_name
|
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
|
// The backend redirects if the trailing slash isn't included, so we add it
|
||||||
// here to avoid extra roundtrips.
|
// here to avoid extra roundtrips.
|
||||||
let href = `/@${username}/${workspace.name}.${
|
let href = `${preferredPathBase}/@${username}/${workspace.name}.${
|
||||||
agent.name
|
agent.name
|
||||||
}/apps/${encodeURIComponent(appSlug)}/`
|
}/apps/${encodeURIComponent(appSlug)}/`
|
||||||
if (app.command) {
|
if (app.command) {
|
||||||
href = `/@${username}/${workspace.name}.${
|
href = `${preferredPathBase}/@${username}/${workspace.name}.${
|
||||||
agent.name
|
agent.name
|
||||||
}/terminal?command=${encodeURIComponent(app.command)}`
|
}/terminal?command=${encodeURIComponent(app.command)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appsHost && app.subdomain) {
|
if (appsHost && app.subdomain) {
|
||||||
const subdomain = `${appSlug}--${agent.name}--${workspace.name}--${username}`
|
const subdomain = `${appSlug}--${agent.name}--${workspace.name}--${username}`
|
||||||
href = `${window.location.protocol}//${appsHost}/`.replace("*", subdomain)
|
href = `${window.location.protocol}//${appsHost}/`.replace("*", subdomain)
|
||||||
|
@ -13,7 +13,6 @@ import { Outlet } from "react-router-dom"
|
|||||||
import { dashboardContentBottomPadding } from "theme/constants"
|
import { dashboardContentBottomPadding } from "theme/constants"
|
||||||
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"
|
import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"
|
||||||
import { Navbar } from "../Navbar/Navbar"
|
import { Navbar } from "../Navbar/Navbar"
|
||||||
import { DashboardProvider } from "./DashboardProvider"
|
|
||||||
|
|
||||||
export const DashboardLayout: FC = () => {
|
export const DashboardLayout: FC = () => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
@ -28,7 +27,7 @@ export const DashboardLayout: FC = () => {
|
|||||||
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
|
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardProvider>
|
<>
|
||||||
<ServiceBanner />
|
<ServiceBanner />
|
||||||
{canViewDeployment && <LicenseBanner />}
|
{canViewDeployment && <LicenseBanner />}
|
||||||
|
|
||||||
@ -57,7 +56,7 @@ export const DashboardLayout: FC = () => {
|
|||||||
|
|
||||||
<DeploymentBanner />
|
<DeploymentBanner />
|
||||||
</div>
|
</div>
|
||||||
</DashboardProvider>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 = () => {
|
export const DisabledBadge: FC = () => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
return (
|
return (
|
||||||
@ -92,6 +110,11 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
backgroundColor: theme.palette.success.dark,
|
backgroundColor: theme.palette.success.dark,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
errorBadge: {
|
||||||
|
border: `1px solid ${theme.palette.error.light}`,
|
||||||
|
backgroundColor: theme.palette.error.dark,
|
||||||
|
},
|
||||||
|
|
||||||
disabledBadge: {
|
disabledBadge: {
|
||||||
border: `1px solid ${theme.palette.divider}`,
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
backgroundColor: theme.palette.background.paper,
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
@ -43,6 +43,7 @@ export const portForwardURL = (
|
|||||||
|
|
||||||
const TooltipView: React.FC<PortForwardButtonProps> = (props) => {
|
const TooltipView: React.FC<PortForwardButtonProps> = (props) => {
|
||||||
const { host, workspaceName, agentName, agentId, username } = props
|
const { host, workspaceName, agentName, agentId, username } = props
|
||||||
|
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
const [port, setPort] = useState("3000")
|
const [port, setPort] = useState("3000")
|
||||||
const urlExample = portForwardURL(
|
const urlExample = portForwardURL(
|
||||||
|
@ -4,6 +4,8 @@ import { Navigate, useLocation } from "react-router"
|
|||||||
import { Outlet } from "react-router-dom"
|
import { Outlet } from "react-router-dom"
|
||||||
import { embedRedirect } from "../../utils/redirect"
|
import { embedRedirect } from "../../utils/redirect"
|
||||||
import { FullScreenLoader } from "../Loader/FullScreenLoader"
|
import { FullScreenLoader } from "../Loader/FullScreenLoader"
|
||||||
|
import { DashboardProvider } from "components/Dashboard/DashboardProvider"
|
||||||
|
import { ProxyProvider } from "contexts/ProxyContext"
|
||||||
|
|
||||||
export const RequireAuth: FC = () => {
|
export const RequireAuth: FC = () => {
|
||||||
const [authState] = useAuth()
|
const [authState] = useAuth()
|
||||||
@ -21,6 +23,14 @@ export const RequireAuth: FC = () => {
|
|||||||
) {
|
) {
|
||||||
return <FullScreenLoader />
|
return <FullScreenLoader />
|
||||||
} else {
|
} 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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { Story } from "@storybook/react"
|
import { Story } from "@storybook/react"
|
||||||
import {
|
import {
|
||||||
|
MockPrimaryWorkspaceProxy,
|
||||||
|
MockWorkspaceProxies,
|
||||||
MockWorkspace,
|
MockWorkspace,
|
||||||
MockWorkspaceAgent,
|
MockWorkspaceAgent,
|
||||||
MockWorkspaceAgentConnecting,
|
MockWorkspaceAgentConnecting,
|
||||||
@ -16,6 +18,8 @@ import {
|
|||||||
MockWorkspaceApp,
|
MockWorkspaceApp,
|
||||||
} from "testHelpers/entities"
|
} from "testHelpers/entities"
|
||||||
import { AgentRow, AgentRowProps } from "./AgentRow"
|
import { AgentRow, AgentRowProps } from "./AgentRow"
|
||||||
|
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
|
||||||
|
import { Region } from "api/typesGenerated"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "components/AgentRow",
|
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 = [
|
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',
|
'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,
|
workspace: MockWorkspace,
|
||||||
applicationsHost: "",
|
|
||||||
showApps: true,
|
showApps: true,
|
||||||
storybookAgentMetadata: defaultAgentMetadata,
|
storybookAgentMetadata: defaultAgentMetadata,
|
||||||
}
|
}
|
||||||
@ -149,7 +180,6 @@ BunchOfApps.args = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
workspace: MockWorkspace,
|
workspace: MockWorkspace,
|
||||||
applicationsHost: "",
|
|
||||||
showApps: true,
|
showApps: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,10 +253,9 @@ Off.args = {
|
|||||||
agent: MockWorkspaceAgentOff,
|
agent: MockWorkspaceAgentOff,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowingPortForward = Template.bind({})
|
export const ShowingPortForward = TemplateWithPortForward.bind({})
|
||||||
ShowingPortForward.args = {
|
ShowingPortForward.args = {
|
||||||
...Example.args,
|
...Example.args,
|
||||||
applicationsHost: "https://coder.com",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Outdated = Template.bind({})
|
export const Outdated = Template.bind({})
|
||||||
|
@ -43,11 +43,11 @@ import { AgentMetadata } from "./AgentMetadata"
|
|||||||
import { AgentVersion } from "./AgentVersion"
|
import { AgentVersion } from "./AgentVersion"
|
||||||
import { AgentStatus } from "./AgentStatus"
|
import { AgentStatus } from "./AgentStatus"
|
||||||
import Collapse from "@material-ui/core/Collapse"
|
import Collapse from "@material-ui/core/Collapse"
|
||||||
|
import { useProxy } from "contexts/ProxyContext"
|
||||||
|
|
||||||
export interface AgentRowProps {
|
export interface AgentRowProps {
|
||||||
agent: WorkspaceAgent
|
agent: WorkspaceAgent
|
||||||
workspace: Workspace
|
workspace: Workspace
|
||||||
applicationsHost: string | undefined
|
|
||||||
showApps: boolean
|
showApps: boolean
|
||||||
hideSSHButton?: boolean
|
hideSSHButton?: boolean
|
||||||
sshPrefix?: string
|
sshPrefix?: string
|
||||||
@ -61,7 +61,6 @@ export interface AgentRowProps {
|
|||||||
export const AgentRow: FC<AgentRowProps> = ({
|
export const AgentRow: FC<AgentRowProps> = ({
|
||||||
agent,
|
agent,
|
||||||
workspace,
|
workspace,
|
||||||
applicationsHost,
|
|
||||||
showApps,
|
showApps,
|
||||||
hideSSHButton,
|
hideSSHButton,
|
||||||
hideVSCodeDesktopButton,
|
hideVSCodeDesktopButton,
|
||||||
@ -96,6 +95,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||||||
const hasStartupFeatures =
|
const hasStartupFeatures =
|
||||||
Boolean(agent.startup_logs_length) ||
|
Boolean(agent.startup_logs_length) ||
|
||||||
Boolean(logsMachine.context.startupLogs?.length)
|
Boolean(logsMachine.context.startupLogs?.length)
|
||||||
|
const { proxy } = useProxy()
|
||||||
|
|
||||||
const [showStartupLogs, setShowStartupLogs] = useState(
|
const [showStartupLogs, setShowStartupLogs] = useState(
|
||||||
agent.lifecycle_state !== "ready" && hasStartupFeatures,
|
agent.lifecycle_state !== "ready" && hasStartupFeatures,
|
||||||
@ -228,7 +228,6 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||||||
{agent.apps.map((app) => (
|
{agent.apps.map((app) => (
|
||||||
<AppLink
|
<AppLink
|
||||||
key={app.slug}
|
key={app.slug}
|
||||||
appsHost={applicationsHost}
|
|
||||||
app={app}
|
app={app}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
@ -249,15 +248,16 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||||||
sshPrefix={sshPrefix}
|
sshPrefix={sshPrefix}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{applicationsHost !== undefined && applicationsHost !== "" && (
|
{proxy.preferredWildcardHostname &&
|
||||||
<PortForwardButton
|
proxy.preferredWildcardHostname !== "" && (
|
||||||
host={applicationsHost}
|
<PortForwardButton
|
||||||
workspaceName={workspace.name}
|
host={proxy.preferredWildcardHostname}
|
||||||
agentId={agent.id}
|
workspaceName={workspace.name}
|
||||||
agentName={agent.name}
|
agentId={agent.id}
|
||||||
username={workspace.owner_name}
|
agentName={agent.name}
|
||||||
/>
|
username={workspace.owner_name}
|
||||||
)}
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { Story } from "@storybook/react"
|
|||||||
import { MockWorkspace, MockWorkspaceResource } from "testHelpers/entities"
|
import { MockWorkspace, MockWorkspaceResource } from "testHelpers/entities"
|
||||||
import { AgentRow } from "./AgentRow"
|
import { AgentRow } from "./AgentRow"
|
||||||
import { ResourceCard, ResourceCardProps } from "./ResourceCard"
|
import { ResourceCard, ResourceCardProps } from "./ResourceCard"
|
||||||
|
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "components/ResourceCard",
|
title: "components/ResourceCard",
|
||||||
@ -15,15 +16,26 @@ export const Example = Template.bind({})
|
|||||||
Example.args = {
|
Example.args = {
|
||||||
resource: MockWorkspaceResource,
|
resource: MockWorkspaceResource,
|
||||||
agentRow: (agent) => (
|
agentRow: (agent) => (
|
||||||
<AgentRow
|
<ProxyContext.Provider
|
||||||
showApps
|
value={{
|
||||||
key={agent.id}
|
proxy: getPreferredProxy([], undefined),
|
||||||
agent={agent}
|
proxies: [],
|
||||||
workspace={MockWorkspace}
|
isLoading: false,
|
||||||
applicationsHost=""
|
isFetched: true,
|
||||||
serverVersion=""
|
setProxy: () => {
|
||||||
onUpdateAgent={action("updateAgent")}
|
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: (agent) => (
|
||||||
<AgentRow
|
<ProxyContext.Provider
|
||||||
showApps
|
value={{
|
||||||
key={agent.id}
|
proxy: getPreferredProxy([], undefined),
|
||||||
agent={agent}
|
proxies: [],
|
||||||
workspace={MockWorkspace}
|
isLoading: false,
|
||||||
applicationsHost=""
|
isFetched: true,
|
||||||
serverVersion=""
|
setProxy: () => {
|
||||||
onUpdateAgent={action("updateAgent")}
|
return
|
||||||
/>
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentRow
|
||||||
|
showApps
|
||||||
|
key={agent.id}
|
||||||
|
agent={agent}
|
||||||
|
workspace={MockWorkspace}
|
||||||
|
serverVersion=""
|
||||||
|
onUpdateAgent={action("updateAgent")}
|
||||||
|
/>
|
||||||
|
</ProxyContext.Provider>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,8 @@ import { NavLink } from "react-router-dom"
|
|||||||
import { combineClasses } from "utils/combineClasses"
|
import { combineClasses } from "utils/combineClasses"
|
||||||
import AccountIcon from "@material-ui/icons/Person"
|
import AccountIcon from "@material-ui/icons/Person"
|
||||||
import SecurityIcon from "@material-ui/icons/LockOutlined"
|
import SecurityIcon from "@material-ui/icons/LockOutlined"
|
||||||
|
import PublicIcon from "@material-ui/icons/Public"
|
||||||
|
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||||
|
|
||||||
const SidebarNavItem: FC<
|
const SidebarNavItem: FC<
|
||||||
PropsWithChildren<{ href: string; icon: ReactNode }>
|
PropsWithChildren<{ href: string; icon: ReactNode }>
|
||||||
@ -41,6 +43,7 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
|
|||||||
|
|
||||||
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
const dashboard = useDashboard()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={styles.sidebar}>
|
<nav className={styles.sidebar}>
|
||||||
@ -76,6 +79,14 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
>
|
>
|
||||||
Tokens
|
Tokens
|
||||||
</SidebarNavItem>
|
</SidebarNavItem>
|
||||||
|
{dashboard.experiments.includes("moons") && (
|
||||||
|
<SidebarNavItem
|
||||||
|
href="workspace-proxies"
|
||||||
|
icon={<SidebarNavItemIcon icon={PublicIcon} />}
|
||||||
|
>
|
||||||
|
Workspace Proxy
|
||||||
|
</SidebarNavItem>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -27,6 +27,7 @@ export const TerminalLink: FC<React.PropsWithChildren<TerminalLinkProps>> = ({
|
|||||||
userName = "me",
|
userName = "me",
|
||||||
workspaceName,
|
workspaceName,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Always use the primary for the terminal link. This is a relative link.
|
||||||
const href = `/@${userName}/${workspaceName}${
|
const href = `/@${userName}/${workspaceName}${
|
||||||
agentName ? `.${agentName}` : ""
|
agentName ? `.${agentName}` : ""
|
||||||
}/terminal`
|
}/terminal`
|
||||||
|
@ -6,6 +6,7 @@ import * as Mocks from "../../testHelpers/entities"
|
|||||||
import { Workspace, WorkspaceErrors, WorkspaceProps } from "./Workspace"
|
import { Workspace, WorkspaceErrors, WorkspaceProps } from "./Workspace"
|
||||||
import { withReactContext } from "storybook-react-context"
|
import { withReactContext } from "storybook-react-context"
|
||||||
import EventSource from "eventsourcemock"
|
import EventSource from "eventsourcemock"
|
||||||
|
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "components/Workspace",
|
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({})
|
export const Running = Template.bind({})
|
||||||
Running.args = {
|
Running.args = {
|
||||||
|
@ -59,7 +59,6 @@ export interface WorkspaceProps {
|
|||||||
hideVSCodeDesktopButton?: boolean
|
hideVSCodeDesktopButton?: boolean
|
||||||
workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>>
|
workspaceErrors: Partial<Record<WorkspaceErrors, Error | unknown>>
|
||||||
buildInfo?: TypesGen.BuildInfoResponse
|
buildInfo?: TypesGen.BuildInfoResponse
|
||||||
applicationsHost?: string
|
|
||||||
sshPrefix?: string
|
sshPrefix?: string
|
||||||
template?: TypesGen.Template
|
template?: TypesGen.Template
|
||||||
quota_budget?: number
|
quota_budget?: number
|
||||||
@ -92,7 +91,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
|||||||
hideSSHButton,
|
hideSSHButton,
|
||||||
hideVSCodeDesktopButton,
|
hideVSCodeDesktopButton,
|
||||||
buildInfo,
|
buildInfo,
|
||||||
applicationsHost,
|
|
||||||
sshPrefix,
|
sshPrefix,
|
||||||
template,
|
template,
|
||||||
quota_budget,
|
quota_budget,
|
||||||
@ -246,7 +244,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
|||||||
key={agent.id}
|
key={agent.id}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
applicationsHost={applicationsHost}
|
|
||||||
sshPrefix={sshPrefix}
|
sshPrefix={sshPrefix}
|
||||||
showApps={canUpdateWorkspace}
|
showApps={canUpdateWorkspace}
|
||||||
hideSSHButton={hideSSHButton}
|
hideSSHButton={hideSSHButton}
|
||||||
|
53
site/src/contexts/ProxyContext.test.ts
Normal file
53
site/src/contexts/ProxyContext.test.ts
Normal 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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
206
site/src/contexts/ProxyContext.tsx
Normal file
206
site/src/contexts/ProxyContext.tsx
Normal 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)
|
||||||
|
}
|
@ -2,13 +2,19 @@ import { waitFor } from "@testing-library/react"
|
|||||||
import "jest-canvas-mock"
|
import "jest-canvas-mock"
|
||||||
import WS from "jest-websocket-mock"
|
import WS from "jest-websocket-mock"
|
||||||
import { rest } from "msw"
|
import { rest } from "msw"
|
||||||
import { Route, Routes } from "react-router-dom"
|
import {
|
||||||
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"
|
MockPrimaryWorkspaceProxy,
|
||||||
|
MockWorkspace,
|
||||||
|
MockWorkspaceAgent,
|
||||||
|
MockWorkspaceProxies,
|
||||||
|
} from "testHelpers/entities"
|
||||||
import { TextDecoder, TextEncoder } from "util"
|
import { TextDecoder, TextEncoder } from "util"
|
||||||
import { ReconnectingPTYRequest } from "../../api/types"
|
import { ReconnectingPTYRequest } from "../../api/types"
|
||||||
import { history, render } from "../../testHelpers/renderHelpers"
|
import { history, render } from "../../testHelpers/renderHelpers"
|
||||||
import { server } from "../../testHelpers/server"
|
import { server } from "../../testHelpers/server"
|
||||||
import TerminalPage, { Language } from "./TerminalPage"
|
import TerminalPage, { Language } from "./TerminalPage"
|
||||||
|
import { Route, Routes } from "react-router-dom"
|
||||||
|
import { ProxyContext } from "contexts/ProxyContext"
|
||||||
|
|
||||||
Object.defineProperty(window, "matchMedia", {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
writable: true,
|
writable: true,
|
||||||
@ -29,11 +35,28 @@ Object.defineProperty(window, "TextEncoder", {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const renderTerminal = () => {
|
const renderTerminal = () => {
|
||||||
|
// @emyrk using renderWithAuth would be best here, but I was unable to get it to work.
|
||||||
return render(
|
return render(
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/:username/:workspace/terminal"
|
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>,
|
</Routes>,
|
||||||
)
|
)
|
||||||
|
@ -14,6 +14,7 @@ import "xterm/css/xterm.css"
|
|||||||
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
|
||||||
import { pageTitle } from "../../utils/page"
|
import { pageTitle } from "../../utils/page"
|
||||||
import { terminalMachine } from "../../xServices/terminal/terminalXService"
|
import { terminalMachine } from "../../xServices/terminal/terminalXService"
|
||||||
|
import { useProxy } from "contexts/ProxyContext"
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
|
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
|
||||||
@ -56,6 +57,7 @@ const TerminalPage: FC<
|
|||||||
> = ({ renderer }) => {
|
> = ({ renderer }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
const { proxy } = useProxy()
|
||||||
const { username, workspace: workspaceName } = useParams()
|
const { username, workspace: workspaceName } = useParams()
|
||||||
const xtermRef = useRef<HTMLDivElement>(null)
|
const xtermRef = useRef<HTMLDivElement>(null)
|
||||||
const [terminal, setTerminal] = useState<XTerm.Terminal | null>(null)
|
const [terminal, setTerminal] = useState<XTerm.Terminal | null>(null)
|
||||||
@ -76,6 +78,7 @@ const TerminalPage: FC<
|
|||||||
workspaceName: workspaceNameParts?.[0],
|
workspaceName: workspaceNameParts?.[0],
|
||||||
username: username,
|
username: username,
|
||||||
command: command,
|
command: command,
|
||||||
|
baseURL: proxy.preferredPathAppURL,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
readMessage: (_, event) => {
|
readMessage: (_, event) => {
|
||||||
@ -97,14 +100,18 @@ const TerminalPage: FC<
|
|||||||
workspaceAgentError,
|
workspaceAgentError,
|
||||||
workspaceAgent,
|
workspaceAgent,
|
||||||
websocketError,
|
websocketError,
|
||||||
applicationsHost,
|
|
||||||
} = terminalState.context
|
} = terminalState.context
|
||||||
const reloading = useReloading(isDisconnected)
|
const reloading = useReloading(isDisconnected)
|
||||||
|
|
||||||
// handleWebLink handles opening of URLs in the terminal!
|
// handleWebLink handles opening of URLs in the terminal!
|
||||||
const handleWebLink = useCallback(
|
const handleWebLink = useCallback(
|
||||||
(uri: string) => {
|
(uri: string) => {
|
||||||
if (!workspaceAgent || !workspace || !username || !applicationsHost) {
|
if (
|
||||||
|
!workspaceAgent ||
|
||||||
|
!workspace ||
|
||||||
|
!username ||
|
||||||
|
!proxy.preferredWildcardHostname
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +139,7 @@ const TerminalPage: FC<
|
|||||||
}
|
}
|
||||||
open(
|
open(
|
||||||
portForwardURL(
|
portForwardURL(
|
||||||
applicationsHost,
|
proxy.preferredWildcardHostname,
|
||||||
parseInt(url.port),
|
parseInt(url.port),
|
||||||
workspaceAgent.name,
|
workspaceAgent.name,
|
||||||
workspace.name,
|
workspace.name,
|
||||||
@ -143,7 +150,7 @@ const TerminalPage: FC<
|
|||||||
open(uri)
|
open(uri)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[workspaceAgent, workspace, username, applicationsHost],
|
[workspaceAgent, workspace, username, proxy.preferredWildcardHostname],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create the terminal!
|
// Create the terminal!
|
||||||
|
@ -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
|
@ -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,
|
||||||
|
},
|
||||||
|
}))
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
@ -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.",
|
||||||
|
}),
|
||||||
|
}
|
@ -57,7 +57,6 @@ export const WorkspaceReadyPage = ({
|
|||||||
getBuildsError,
|
getBuildsError,
|
||||||
buildError,
|
buildError,
|
||||||
cancellationError,
|
cancellationError,
|
||||||
applicationsHost,
|
|
||||||
sshPrefix,
|
sshPrefix,
|
||||||
permissions,
|
permissions,
|
||||||
missedParameters,
|
missedParameters,
|
||||||
@ -153,7 +152,6 @@ export const WorkspaceReadyPage = ({
|
|||||||
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
|
[WorkspaceErrors.CANCELLATION_ERROR]: cancellationError,
|
||||||
}}
|
}}
|
||||||
buildInfo={buildInfo}
|
buildInfo={buildInfo}
|
||||||
applicationsHost={applicationsHost}
|
|
||||||
sshPrefix={sshPrefix}
|
sshPrefix={sshPrefix}
|
||||||
template={template}
|
template={template}
|
||||||
quota_budget={quotaState.context.quota?.budget}
|
quota_budget={quotaState.context.quota?.budget}
|
||||||
|
@ -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 = {
|
export const MockBuildInfo: TypesGen.BuildInfoResponse = {
|
||||||
external_url: "file:///mock-url",
|
external_url: "file:///mock-url",
|
||||||
version: "v99.999.9999+c9cdf14",
|
version: "v99.999.9999+c9cdf14",
|
||||||
|
dashboard_url: "https:///mock-url",
|
||||||
|
workspace_proxy: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MockSupportLinks: TypesGen.LinkConfig[] = [
|
export const MockSupportLinks: TypesGen.LinkConfig[] = [
|
||||||
|
@ -15,7 +15,15 @@ export const handlers = [
|
|||||||
rest.get("/api/v2/insights/daus", async (req, res, ctx) => {
|
rest.get("/api/v2/insights/daus", async (req, res, ctx) => {
|
||||||
return res(ctx.status(200), ctx.json(M.MockDeploymentDAUResponse))
|
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
|
// build info
|
||||||
rest.get("/api/v2/buildinfo", async (req, res, ctx) => {
|
rest.get("/api/v2/buildinfo", async (req, res, ctx) => {
|
||||||
return res(ctx.status(200), ctx.json(M.MockBuildInfo))
|
return res(ctx.status(200), ctx.json(M.MockBuildInfo))
|
||||||
|
@ -10,7 +10,8 @@ export interface TerminalContext {
|
|||||||
workspaceAgentError?: Error | unknown
|
workspaceAgentError?: Error | unknown
|
||||||
websocket?: WebSocket
|
websocket?: WebSocket
|
||||||
websocketError?: Error | unknown
|
websocketError?: Error | unknown
|
||||||
applicationsHost?: string
|
websocketURL?: string
|
||||||
|
websocketURLError?: Error | unknown
|
||||||
|
|
||||||
// Assigned by connecting!
|
// Assigned by connecting!
|
||||||
// The workspace agent is entirely optional. If the agent is omitted the
|
// The workspace agent is entirely optional. If the agent is omitted the
|
||||||
@ -20,6 +21,8 @@ export interface TerminalContext {
|
|||||||
workspaceName?: string
|
workspaceName?: string
|
||||||
reconnection?: string
|
reconnection?: string
|
||||||
command?: string
|
command?: string
|
||||||
|
// If baseURL is not.....
|
||||||
|
baseURL?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TerminalEvent =
|
export type TerminalEvent =
|
||||||
@ -35,7 +38,7 @@ export type TerminalEvent =
|
|||||||
| { type: "DISCONNECT" }
|
| { type: "DISCONNECT" }
|
||||||
|
|
||||||
export const terminalMachine =
|
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(
|
createMachine(
|
||||||
{
|
{
|
||||||
id: "terminalState",
|
id: "terminalState",
|
||||||
@ -48,12 +51,12 @@ export const terminalMachine =
|
|||||||
getWorkspace: {
|
getWorkspace: {
|
||||||
data: TypesGen.Workspace
|
data: TypesGen.Workspace
|
||||||
}
|
}
|
||||||
getApplicationsHost: {
|
|
||||||
data: TypesGen.AppHostResponse
|
|
||||||
}
|
|
||||||
getWorkspaceAgent: {
|
getWorkspaceAgent: {
|
||||||
data: TypesGen.WorkspaceAgent
|
data: TypesGen.WorkspaceAgent
|
||||||
}
|
}
|
||||||
|
getWebsocketURL: {
|
||||||
|
data: string
|
||||||
|
}
|
||||||
connect: {
|
connect: {
|
||||||
data: WebSocket
|
data: WebSocket
|
||||||
}
|
}
|
||||||
@ -64,27 +67,6 @@ export const terminalMachine =
|
|||||||
setup: {
|
setup: {
|
||||||
type: "parallel",
|
type: "parallel",
|
||||||
states: {
|
states: {
|
||||||
getApplicationsHost: {
|
|
||||||
initial: "gettingApplicationsHost",
|
|
||||||
states: {
|
|
||||||
gettingApplicationsHost: {
|
|
||||||
invoke: {
|
|
||||||
src: "getApplicationsHost",
|
|
||||||
id: "getApplicationsHost",
|
|
||||||
onDone: {
|
|
||||||
actions: [
|
|
||||||
"assignApplicationsHost",
|
|
||||||
"clearApplicationsHostError",
|
|
||||||
],
|
|
||||||
target: "success",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
type: "final",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
getWorkspace: {
|
getWorkspace: {
|
||||||
initial: "gettingWorkspace",
|
initial: "gettingWorkspace",
|
||||||
states: {
|
states: {
|
||||||
@ -123,7 +105,7 @@ export const terminalMachine =
|
|||||||
onDone: [
|
onDone: [
|
||||||
{
|
{
|
||||||
actions: ["assignWorkspaceAgent", "clearWorkspaceAgentError"],
|
actions: ["assignWorkspaceAgent", "clearWorkspaceAgentError"],
|
||||||
target: "connecting",
|
target: "gettingWebSocketURL",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onError: [
|
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: {
|
connecting: {
|
||||||
invoke: {
|
invoke: {
|
||||||
src: "connect",
|
src: "connect",
|
||||||
@ -187,9 +187,6 @@ export const terminalMachine =
|
|||||||
context.workspaceName,
|
context.workspaceName,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
getApplicationsHost: async () => {
|
|
||||||
return API.getApplicationsHost()
|
|
||||||
},
|
|
||||||
getWorkspaceAgent: async (context) => {
|
getWorkspaceAgent: async (context) => {
|
||||||
if (!context.workspace || !context.workspaceName) {
|
if (!context.workspace || !context.workspaceName) {
|
||||||
throw new Error("workspace or workspace name is not set")
|
throw new Error("workspace or workspace name is not set")
|
||||||
@ -213,17 +210,60 @@ export const terminalMachine =
|
|||||||
}
|
}
|
||||||
return agent
|
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) => {
|
connect: (context) => (send) => {
|
||||||
return new Promise<WebSocket>((resolve, reject) => {
|
return new Promise<WebSocket>((resolve, reject) => {
|
||||||
if (!context.workspaceAgent) {
|
if (!context.workspaceAgent) {
|
||||||
return reject("workspace agent is not set")
|
return reject("workspace agent is not set")
|
||||||
}
|
}
|
||||||
const proto = location.protocol === "https:" ? "wss:" : "ws:"
|
if (!context.websocketURL) {
|
||||||
const commandQuery = context.command
|
return reject("websocket URL is not set")
|
||||||
? `&command=${encodeURIComponent(context.command)}`
|
}
|
||||||
: ""
|
|
||||||
const url = `${proto}//${location.host}/api/v2/workspaceagents/${context.workspaceAgent.id}/pty?reconnect=${context.reconnection}${commandQuery}`
|
const socket = new WebSocket(context.websocketURL)
|
||||||
const socket = new WebSocket(url)
|
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
socket.addEventListener("open", () => {
|
socket.addEventListener("open", () => {
|
||||||
resolve(socket)
|
resolve(socket)
|
||||||
@ -262,13 +302,6 @@ export const terminalMachine =
|
|||||||
...context,
|
...context,
|
||||||
workspaceError: undefined,
|
workspaceError: undefined,
|
||||||
})),
|
})),
|
||||||
assignApplicationsHost: assign({
|
|
||||||
applicationsHost: (_, { data }) => data.host,
|
|
||||||
}),
|
|
||||||
clearApplicationsHostError: assign((context) => ({
|
|
||||||
...context,
|
|
||||||
applicationsHostError: undefined,
|
|
||||||
})),
|
|
||||||
assignWorkspaceAgent: assign({
|
assignWorkspaceAgent: assign({
|
||||||
workspaceAgent: (_, event) => event.data,
|
workspaceAgent: (_, event) => event.data,
|
||||||
}),
|
}),
|
||||||
@ -289,6 +322,16 @@ export const terminalMachine =
|
|||||||
...context,
|
...context,
|
||||||
webSocketError: undefined,
|
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) => {
|
sendMessage: (context, event) => {
|
||||||
if (!context.websocket) {
|
if (!context.websocket) {
|
||||||
throw new Error("websocket doesn't exist")
|
throw new Error("websocket doesn't exist")
|
||||||
|
@ -74,8 +74,6 @@ export interface WorkspaceContext {
|
|||||||
// permissions
|
// permissions
|
||||||
permissions?: Permissions
|
permissions?: Permissions
|
||||||
checkPermissionsError?: Error | unknown
|
checkPermissionsError?: Error | unknown
|
||||||
// applications
|
|
||||||
applicationsHost?: string
|
|
||||||
// debug
|
// debug
|
||||||
createBuildLogLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"]
|
createBuildLogLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"]
|
||||||
// SSH Config
|
// SSH Config
|
||||||
@ -189,9 +187,6 @@ export const workspaceMachine = createMachine(
|
|||||||
checkPermissions: {
|
checkPermissions: {
|
||||||
data: TypesGen.AuthorizationResponse
|
data: TypesGen.AuthorizationResponse
|
||||||
}
|
}
|
||||||
getApplicationsHost: {
|
|
||||||
data: TypesGen.AppHostResponse
|
|
||||||
}
|
|
||||||
getSSHPrefix: {
|
getSSHPrefix: {
|
||||||
data: TypesGen.SSHConfigResponse
|
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: {
|
sshConfig: {
|
||||||
initial: "gettingSshConfig",
|
initial: "gettingSshConfig",
|
||||||
states: {
|
states: {
|
||||||
@ -660,17 +631,6 @@ export const workspaceMachine = createMachine(
|
|||||||
clearGetBuildsError: assign({
|
clearGetBuildsError: assign({
|
||||||
getBuildsError: (_) => undefined,
|
getBuildsError: (_) => undefined,
|
||||||
}),
|
}),
|
||||||
// Applications
|
|
||||||
assignApplicationsHost: assign({
|
|
||||||
applicationsHost: (_, { data }) => data.host,
|
|
||||||
}),
|
|
||||||
displayApplicationsHostError: (_, { data }) => {
|
|
||||||
const message = getErrorMessage(
|
|
||||||
data,
|
|
||||||
"Error getting the applications host.",
|
|
||||||
)
|
|
||||||
displayError(message)
|
|
||||||
},
|
|
||||||
// SSH
|
// SSH
|
||||||
assignSSHPrefix: assign({
|
assignSSHPrefix: assign({
|
||||||
sshPrefix: (_, { data }) => data.hostname_prefix,
|
sshPrefix: (_, { data }) => data.hostname_prefix,
|
||||||
@ -880,9 +840,6 @@ export const workspaceMachine = createMachine(
|
|||||||
checks: permissionsToCheck(workspace, template),
|
checks: permissionsToCheck(workspace, template),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getApplicationsHost: async () => {
|
|
||||||
return API.getApplicationsHost()
|
|
||||||
},
|
|
||||||
scheduleBannerMachine: workspaceScheduleBannerMachine,
|
scheduleBannerMachine: workspaceScheduleBannerMachine,
|
||||||
getSSHPrefix: async () => {
|
getSSHPrefix: async () => {
|
||||||
return API.getDeploymentSSHConfig()
|
return API.getDeploymentSSHConfig()
|
||||||
|
@ -67,6 +67,7 @@ export default defineConfig({
|
|||||||
api: path.resolve(__dirname, "./src/api"),
|
api: path.resolve(__dirname, "./src/api"),
|
||||||
components: path.resolve(__dirname, "./src/components"),
|
components: path.resolve(__dirname, "./src/components"),
|
||||||
hooks: path.resolve(__dirname, "./src/hooks"),
|
hooks: path.resolve(__dirname, "./src/hooks"),
|
||||||
|
contexts: path.resolve(__dirname, "./src/contexts"),
|
||||||
i18n: path.resolve(__dirname, "./src/i18n"),
|
i18n: path.resolve(__dirname, "./src/i18n"),
|
||||||
pages: path.resolve(__dirname, "./src/pages"),
|
pages: path.resolve(__dirname, "./src/pages"),
|
||||||
testHelpers: path.resolve(__dirname, "./src/testHelpers"),
|
testHelpers: path.resolve(__dirname, "./src/testHelpers"),
|
||||||
|
Reference in New Issue
Block a user