mirror of https://github.com/coder/coder.git
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:
parent
c00f5e499a
commit
4a9d1c16c7
|
@ -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{
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({})
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
|
@ -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 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>,
|
||||
)
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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,
|
||||
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}
|
||||
|
|
|
@ -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[] = [
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"),
|
||||
|
|
Loading…
Reference in New Issue