feat: Auto select workspace proxy based on lowest latency (#7515)

* feat: Proxy auto select and user selection state
* chore: Auto select based on latency
* Add extra test for unknown latencies
* Mock latencies for unit tests
This commit is contained in:
Steven Masley
2023-05-22 16:56:41 +02:00
committed by GitHub
parent f9a97c25dc
commit b8c07ff014
12 changed files with 589 additions and 144 deletions

View File

@ -8,6 +8,7 @@ import { Blob } from "buffer"
import jestFetchMock from "jest-fetch-mock" import jestFetchMock from "jest-fetch-mock"
import { ProxyLatencyReport } from "contexts/useProxyLatency" import { ProxyLatencyReport } from "contexts/useProxyLatency"
import { RegionsResponse } from "api/typesGenerated" import { RegionsResponse } from "api/typesGenerated"
import { useMemo } from "react"
jestFetchMock.enableMocks() jestFetchMock.enableMocks()
@ -16,20 +17,25 @@ jestFetchMock.enableMocks()
// actual network requests. So just globally mock this hook. // actual network requests. So just globally mock this hook.
jest.mock("contexts/useProxyLatency", () => ({ jest.mock("contexts/useProxyLatency", () => ({
useProxyLatency: (proxies?: RegionsResponse) => { useProxyLatency: (proxies?: RegionsResponse) => {
if (!proxies) { // Must use `useMemo` here to avoid infinite loop.
return {} as Record<string, ProxyLatencyReport> // Mocking the hook with a hook.
} const latencies = useMemo(() => {
if (!proxies) {
return proxies.regions.reduce((acc, proxy) => { return {} as Record<string, ProxyLatencyReport>
acc[proxy.id] = {
accurate: true,
// Return a constant latency of 8ms.
// If you make this random it could break stories.
latencyMS: 8,
at: new Date(),
} }
return acc return proxies.regions.reduce((acc, proxy) => {
}, {} as Record<string, ProxyLatencyReport>) acc[proxy.id] = {
accurate: true,
// Return a constant latency of 8ms.
// If you make this random it could break stories.
latencyMS: 8,
at: new Date(),
}
return acc
}, {} as Record<string, ProxyLatencyReport>)
}, [proxies])
return latencies
}, },
})) }))

View File

@ -26,6 +26,9 @@ const Template: Story<AppLinkProps> = (args) => (
setProxy: () => { setProxy: () => {
return return
}, },
clearProxy: () => {
return
},
}} }}
> >
<AppLink {...args} /> <AppLink {...args} />

View File

@ -65,6 +65,9 @@ const TemplateFC = (
setProxy: () => { setProxy: () => {
return return
}, },
clearProxy: () => {
return
},
}} }}
> >
<AgentRow {...args} /> <AgentRow {...args} />

View File

@ -30,6 +30,9 @@ Example.args = {
setProxy: () => { setProxy: () => {
return return
}, },
clearProxy: () => {
return
},
}} }}
> >
<AgentRow <AgentRow
@ -97,6 +100,9 @@ BunchOfMetadata.args = {
setProxy: () => { setProxy: () => {
return return
}, },
clearProxy: () => {
return
},
}} }}
> >
<AgentRow <AgentRow

View File

@ -47,6 +47,9 @@ const Template: Story<WorkspaceProps> = (args) => (
proxies: [], proxies: [],
isLoading: false, isLoading: false,
isFetched: true, isFetched: true,
clearProxy: () => {
return
},
setProxy: () => { setProxy: () => {
return return
}, },

View File

@ -1,62 +0,0 @@
import {
MockPrimaryWorkspaceProxy,
MockWorkspaceProxies,
MockHealthyWildWorkspaceProxy,
MockUnhealthyWildWorkspaceProxy,
} 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,
],
// Primary is the default if the selected is unhealthy
[
"unhealthy selection",
MockWorkspaceProxies,
MockUnhealthyWildWorkspaceProxy,
"",
MockPrimaryWorkspaceProxy.wildcard_hostname,
],
// This should never happen, when there is no primary
["no primary", [MockHealthyWildWorkspaceProxy], undefined, "", ""],
])(
`%p`,
(_, regions, selected, preferredPathAppURL, preferredWildcardHostname) => {
const preferred = getPreferredProxy(regions, selected)
expect(preferred.preferredPathAppURL).toBe(preferredPathAppURL)
expect(preferred.preferredWildcardHostname).toBe(
preferredWildcardHostname,
)
},
)
})

View File

@ -0,0 +1,386 @@
import {
MockPrimaryWorkspaceProxy,
MockWorkspaceProxies,
MockHealthyWildWorkspaceProxy,
MockUnhealthyWildWorkspaceProxy,
} from "testHelpers/entities"
import {
getPreferredProxy,
ProxyProvider,
saveUserSelectedProxy,
useProxy,
} from "./ProxyContext"
import * as ProxyLatency from "./useProxyLatency"
import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import { screen } from "@testing-library/react"
import { server } from "testHelpers/server"
import { rest } from "msw"
import { Region } from "api/typesGenerated"
import "testHelpers/localstorage"
import userEvent from "@testing-library/user-event"
// Mock useProxyLatency to use a hard-coded latency. 'jest.mock' must be called
// here and not inside a unit test.
jest.mock("contexts/useProxyLatency", () => ({
useProxyLatency: () => {
return hardCodedLatencies
},
}))
let hardCodedLatencies: Record<string, ProxyLatency.ProxyLatencyReport> = {}
// fakeLatency is a helper function to make a Latency report from just a number.
const fakeLatency = (ms: number): ProxyLatency.ProxyLatencyReport => {
return {
latencyMS: ms,
accurate: true,
at: new Date(),
}
}
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,
],
// Primary is the default if the selected is unhealthy
[
"unhealthy selection",
MockWorkspaceProxies,
{},
MockUnhealthyWildWorkspaceProxy,
"",
MockPrimaryWorkspaceProxy.wildcard_hostname,
],
// This should never happen, when there is no primary
["no primary", [MockHealthyWildWorkspaceProxy], {}, undefined, "", ""],
// Latency behavior
[
"best latency",
MockWorkspaceProxies,
{
[MockPrimaryWorkspaceProxy.id]: fakeLatency(100),
[MockHealthyWildWorkspaceProxy.id]: fakeLatency(50),
// This should be ignored because it's unhealthy
[MockUnhealthyWildWorkspaceProxy.id]: fakeLatency(25),
// This should be ignored because it is not in the list.
["not a proxy"]: fakeLatency(10),
},
undefined,
MockHealthyWildWorkspaceProxy.path_app_url,
MockHealthyWildWorkspaceProxy.wildcard_hostname,
],
])(
`%p`,
(
_,
regions,
latencies,
selected,
preferredPathAppURL,
preferredWildcardHostname,
) => {
const preferred = getPreferredProxy(regions, selected, latencies)
expect(preferred.preferredPathAppURL).toBe(preferredPathAppURL)
expect(preferred.preferredWildcardHostname).toBe(
preferredWildcardHostname,
)
},
)
})
const TestingComponent = () => {
return renderWithAuth(
<ProxyProvider>
<TestingScreen />
</ProxyProvider>,
{
route: `/proxies`,
path: "/proxies",
},
)
}
// TestingScreen just mounts some components that we can check in the unit test.
const TestingScreen = () => {
const { proxy, userProxy, isFetched, isLoading, clearProxy, setProxy } =
useProxy()
return (
<>
<div data-testid="isFetched" title={isFetched.toString()}></div>
<div data-testid="isLoading" title={isLoading.toString()}></div>
<div
data-testid="preferredProxy"
title={proxy.proxy && proxy.proxy.id}
></div>
<div data-testid="userProxy" title={userProxy && userProxy.id}></div>
<button data-testid="clearProxy" onClick={clearProxy}></button>
<div data-testid="userSelectProxyData"></div>
<button
data-testid="userSelectProxy"
onClick={() => {
const data = screen.getByTestId("userSelectProxyData")
if (data.innerText) {
setProxy(JSON.parse(data.innerText))
}
}}
></button>
</>
)
}
interface ProxyContextSelectionTest {
// Regions is the list of regions to return via the "api" response.
regions: Region[]
// storageProxy should be the proxy stored in local storage before the
// component is mounted and context is loaded. This simulates opening a
// new window with a selection saved from before.
storageProxy: Region | undefined
// latencies is the hard coded latencies to return. If empty, no latencies
// are returned.
latencies?: Record<string, ProxyLatency.ProxyLatencyReport>
// afterLoad are actions to take after loading the component, but before
// assertions. This is useful for simulating user actions.
afterLoad?: () => Promise<void>
// Assert these values.
// expProxyID is the proxyID returned to be used.
expProxyID: string
// expUserProxyID is the user's stored selection.
expUserProxyID?: string
}
describe("ProxyContextSelection", () => {
beforeEach(() => {
window.localStorage.clear()
})
// A way to simulate a user clearing the proxy selection.
const clearProxyAction = async (): Promise<void> => {
const user = userEvent.setup()
const clearProxyButton = screen.getByTestId("clearProxy")
await user.click(clearProxyButton)
}
const userSelectProxy = (proxy: Region): (() => Promise<void>) => {
return async (): Promise<void> => {
const user = userEvent.setup()
const selectData = screen.getByTestId("userSelectProxyData")
selectData.innerText = JSON.stringify(proxy)
const selectProxyButton = screen.getByTestId("userSelectProxy")
await user.click(selectProxyButton)
}
}
it.each([
// Not latency behavior
[
"empty",
{
expProxyID: "",
regions: [],
storageProxy: undefined,
latencies: {},
},
],
[
"regions_no_selection",
{
expProxyID: MockPrimaryWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: undefined,
},
],
[
"regions_selected_unhealthy",
{
expProxyID: MockPrimaryWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: MockUnhealthyWildWorkspaceProxy,
expUserProxyID: MockUnhealthyWildWorkspaceProxy.id,
},
],
[
"regions_selected_healthy",
{
expProxyID: MockHealthyWildWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: MockHealthyWildWorkspaceProxy,
expUserProxyID: MockHealthyWildWorkspaceProxy.id,
},
],
[
"regions_selected_clear",
{
expProxyID: MockPrimaryWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: MockHealthyWildWorkspaceProxy,
afterLoad: clearProxyAction,
expUserProxyID: undefined,
},
],
[
"regions_make_selection",
{
expProxyID: MockHealthyWildWorkspaceProxy.id,
regions: MockWorkspaceProxies,
afterLoad: userSelectProxy(MockHealthyWildWorkspaceProxy),
expUserProxyID: MockHealthyWildWorkspaceProxy.id,
},
],
// Latency behavior
[
"regions_default_low_latency",
{
expProxyID: MockHealthyWildWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: undefined,
latencies: {
[MockPrimaryWorkspaceProxy.id]: fakeLatency(100),
[MockHealthyWildWorkspaceProxy.id]: fakeLatency(50),
// This is a trick. It's unhealthy so should be ignored.
[MockUnhealthyWildWorkspaceProxy.id]: fakeLatency(25),
},
},
],
[
// User intentionally selected a high latency proxy.
"regions_select_high_latency",
{
expProxyID: MockHealthyWildWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: undefined,
afterLoad: userSelectProxy(MockHealthyWildWorkspaceProxy),
expUserProxyID: MockHealthyWildWorkspaceProxy.id,
latencies: {
[MockHealthyWildWorkspaceProxy.id]: fakeLatency(500),
[MockPrimaryWorkspaceProxy.id]: fakeLatency(100),
// This is a trick. It's unhealthy so should be ignored.
[MockUnhealthyWildWorkspaceProxy.id]: fakeLatency(25),
},
},
],
[
// Low latency proxy is selected, but it is unhealthy
"regions_select_unhealthy_low_latency",
{
expProxyID: MockPrimaryWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: MockUnhealthyWildWorkspaceProxy,
expUserProxyID: MockUnhealthyWildWorkspaceProxy.id,
latencies: {
[MockHealthyWildWorkspaceProxy.id]: fakeLatency(500),
[MockPrimaryWorkspaceProxy.id]: fakeLatency(100),
// This is a trick. It's unhealthy so should be ignored.
[MockUnhealthyWildWorkspaceProxy.id]: fakeLatency(25),
},
},
],
[
// Excess proxies we do not have are low latency.
// This will probably never happen in production.
"unknown_regions_low_latency",
{
// Default to primary since we have unknowns
expProxyID: MockPrimaryWorkspaceProxy.id,
regions: MockWorkspaceProxies,
storageProxy: MockUnhealthyWildWorkspaceProxy,
expUserProxyID: MockUnhealthyWildWorkspaceProxy.id,
latencies: {
["some"]: fakeLatency(500),
["random"]: fakeLatency(100),
["ids"]: fakeLatency(25),
},
},
],
] as [string, ProxyContextSelectionTest][])(
`%s`,
async (
_,
{
expUserProxyID,
expProxyID: expSelectedProxyID,
regions,
storageProxy,
latencies = {},
afterLoad,
},
) => {
// Mock the latencies
hardCodedLatencies = latencies
// Initial selection if present
if (storageProxy) {
saveUserSelectedProxy(storageProxy)
}
// Mock the API response
server.use(
rest.get("/api/v2/regions", async (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
regions: regions,
}),
)
}),
)
TestingComponent()
await waitForLoaderToBeRemoved()
if (afterLoad) {
await afterLoad()
}
await screen.findByTestId("isFetched").then((x) => {
expect(x.title).toBe("true")
})
await screen.findByTestId("isLoading").then((x) => {
expect(x.title).toBe("false")
})
await screen.findByTestId("preferredProxy").then((x) => {
expect(x.title).toBe(expSelectedProxyID)
})
await screen.findByTestId("userProxy").then((x) => {
expect(x.title).toBe(expUserProxyID || "")
})
},
)
})

View File

@ -6,28 +6,60 @@ import {
createContext, createContext,
FC, FC,
PropsWithChildren, PropsWithChildren,
useCallback,
useContext, useContext,
useEffect,
useState, useState,
} from "react" } from "react"
import { ProxyLatencyReport, useProxyLatency } from "./useProxyLatency" import { ProxyLatencyReport, useProxyLatency } from "./useProxyLatency"
interface ProxyContextValue { export interface ProxyContextValue {
// proxy is **always** the workspace proxy that should be used.
// The 'proxy.selectedProxy' field is the proxy being used and comes from either:
// 1. The user manually selected this proxy. (saved to local storage)
// 2. The default proxy auto selected because:
// a. The user has not selected a proxy.
// b. The user's selected proxy is not in the list of proxies.
// c. The user's selected proxy is not healthy.
// 3. undefined if there are no proxies.
//
// The values 'proxy.preferredPathAppURL' and 'proxy.preferredWildcardHostname' can
// always be used even if 'proxy.selectedProxy' is undefined. These values are sourced from
// the 'selectedProxy', but default to relative paths if the 'selectedProxy' is undefined.
proxy: PreferredProxy proxy: PreferredProxy
// userProxy is always the proxy the user has selected. This value comes from local storage.
// The value `proxy` should always be used instead of `userProxy`. `userProxy` is only exposed
// so the caller can determine if the proxy being used is the user's selected proxy, or if it
// was auto selected based on some other criteria.
userProxy?: Region
// proxies is the list of proxies returned by coderd. This is fetched async.
// isFetched, isLoading, and error are used to track the state of the async call.
proxies?: Region[] proxies?: Region[]
proxyLatencies?: Record<string, ProxyLatencyReport> // isFetched is true when the 'proxies' api call is complete.
// isfetched is true when the proxy api call is complete.
isFetched: boolean isFetched: boolean
// isLoading is true if the proxy is in the process of being fetched.
isLoading: boolean isLoading: boolean
error?: Error | unknown error?: Error | unknown
// proxyLatencies is a map of proxy id to latency report. If the proxyLatencies[proxy.id] is undefined
// then the latency has not been fetched yet. Calculations happen async for each proxy in the list.
// Refer to the returned report for a given proxy for more information.
proxyLatencies: Record<string, ProxyLatencyReport>
// setProxy is a function that sets the user's selected proxy. This function should
// only be called if the user is manually selecting a proxy. This value is stored in local
// storage and will persist across reloads and tabs.
setProxy: (selectedProxy: Region) => void setProxy: (selectedProxy: Region) => void
// clearProxy is a function that clears the user's selected proxy.
// If no proxy is selected, then the default proxy will be used.
clearProxy: () => void
} }
interface PreferredProxy { interface PreferredProxy {
// selectedProxy is the proxy the user has selected. // proxy is the proxy being used. It is provided for
// getting the fields such as "display_name" and "id"
// Do not use the fields 'path_app_url' or 'wildcard_hostname' from this // Do not use the fields 'path_app_url' or 'wildcard_hostname' from this
// object. Use the preferred fields. // object. Use the preferred fields.
selectedProxy: Region | undefined proxy: Region | undefined
// PreferredPathAppURL is the URL of the proxy or it is the empty string // PreferredPathAppURL is the URL of the proxy or it is the empty string
// to indicate using relative paths. To add a path to this: // to indicate using relative paths. To add a path to this:
// PreferredPathAppURL + "/path/to/app" // PreferredPathAppURL + "/path/to/app"
@ -44,19 +76,18 @@ export const ProxyContext = createContext<ProxyContextValue | undefined>(
* ProxyProvider interacts with local storage to indicate the preferred workspace proxy. * ProxyProvider interacts with local storage to indicate the preferred workspace proxy.
*/ */
export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => { 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 dashboard = useDashboard()
const experimentEnabled = dashboard?.experiments.includes("moons") const experimentEnabled = dashboard?.experiments.includes("moons")
// Using a useState so the caller always has the latest user saved
// proxy.
const [userSavedProxy, setUserSavedProxy] = useState(loadUserSelectedProxy())
// Load the initial state from local storage.
const [proxy, setProxy] = useState<PreferredProxy>(
computeUsableURLS(userSavedProxy),
)
const queryKey = ["get-proxies"] const queryKey = ["get-proxies"]
const { const {
data: proxiesResp, data: proxiesResp,
@ -66,41 +97,37 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
} = useQuery({ } = useQuery({
queryKey, queryKey,
queryFn: getWorkspaceProxies, 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)
},
}) })
// Everytime we get a new proxiesResponse, update the latency check // Every time we get a new proxiesResponse, update the latency check
// to each workspace proxy. // to each workspace proxy.
const proxyLatencies = useProxyLatency(proxiesResp) const proxyLatencies = useProxyLatency(proxiesResp)
const setAndSaveProxy = ( // updateProxy is a helper function that when called will
selectedProxy?: Region, // update the proxy being used.
// By default the proxies come from the api call above. const updateProxy = useCallback(() => {
// Allow the caller to override this if they have a more up // Update the saved user proxy for the caller.
// to date list of proxies. setUserSavedProxy(loadUserSelectedProxy())
proxies: Region[] = proxiesResp?.regions || [], setProxy(
) => { getPreferredProxy(
if (!proxies) { proxiesResp?.regions ?? [],
throw new Error( loadUserSelectedProxy(),
"proxies are not yet loaded, so selecting a proxy makes no sense. How did you get here?", proxyLatencies,
) ),
} )
const preferred = getPreferredProxy(proxies, selectedProxy) }, [proxiesResp, proxyLatencies])
// Save to local storage to persist the user's preference across reloads
// and other tabs. // This useEffect ensures the proxy to be used is updated whenever the state changes.
savePreferredProxy(preferred) // This includes proxies being loaded, latencies being calculated, and the user selecting a proxy.
// Set the state for the current context. useEffect(() => {
setProxy(preferred) updateProxy()
} // eslint-disable-next-line react-hooks/exhaustive-deps -- Only update if the source data changes
}, [proxiesResp, proxyLatencies])
return ( return (
<ProxyContext.Provider <ProxyContext.Provider
value={{ value={{
userProxy: userSavedProxy,
proxyLatencies: proxyLatencies, proxyLatencies: proxyLatencies,
proxy: experimentEnabled proxy: experimentEnabled
? proxy ? proxy
@ -113,9 +140,19 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
isLoading: proxiesLoading, isLoading: proxiesLoading,
isFetched: proxiesFetched, isFetched: proxiesFetched,
error: proxiesError, error: proxiesError,
// A function that takes the new proxies and selected proxy and updates
// the state with the appropriate urls. // These functions are exposed to allow the user to select a proxy.
setProxy: setAndSaveProxy, setProxy: (proxy: Region) => {
// Save to local storage to persist the user's preference across reloads
saveUserSelectedProxy(proxy)
// Update the selected proxy
updateProxy()
},
clearProxy: () => {
// Clear the user's selection from local storage.
clearUserSelectedProxy()
updateProxy()
},
}} }}
> >
{children} {children}
@ -134,22 +171,20 @@ export const useProxy = (): ProxyContextValue => {
} }
/** /**
* getURLs is a helper function to calculate the urls to use for a given proxy configuration. By default, it is * getPreferredProxy 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. * assumed no proxy is configured and relative paths should be used.
* Exported for testing. * Exported for testing.
* *
* @param proxies Is the list of proxies returned by coderd. If this is empty, default behavior is used. * @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. * @param selectedProxy Is the proxy saved in local storage. If this is undefined, default behavior is used.
* @param latencies If provided, this is used to determine the best proxy to default to.
* If not, `primary` is always the best default.
*/ */
export const getPreferredProxy = ( export const getPreferredProxy = (
proxies: Region[], proxies: Region[],
selectedProxy?: Region, selectedProxy?: Region,
latencies?: Record<string, ProxyLatencyReport>,
): PreferredProxy => { ): 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 // If a proxy is selected, make sure it is in the list of proxies. If it is not
// we should default to the primary. // we should default to the primary.
selectedProxy = proxies.find( selectedProxy = proxies.find(
@ -158,37 +193,76 @@ export const getPreferredProxy = (
// If no proxy is selected, or the selected proxy is unhealthy default to the primary proxy. // If no proxy is selected, or the selected proxy is unhealthy default to the primary proxy.
if (!selectedProxy || !selectedProxy.healthy) { if (!selectedProxy || !selectedProxy.healthy) {
// By default, use the primary proxy.
selectedProxy = proxies.find((proxy) => proxy.name === "primary") selectedProxy = proxies.find((proxy) => proxy.name === "primary")
// If we have latencies, then attempt to use the best proxy by latency instead.
if (latencies) {
const proxyMap = proxies.reduce((acc, proxy) => {
acc[proxy.id] = proxy
return acc
}, {} as Record<string, Region>)
const best = Object.keys(latencies)
.map((proxyId) => {
return {
id: proxyId,
...latencies[proxyId],
}
})
// If the proxy is not in our list, or it is unhealthy, ignore it.
.filter((latency) => proxyMap[latency.id]?.healthy)
.sort((a, b) => a.latencyMS - b.latencyMS)
.at(0)
// Found a new best, use it!
if (best) {
const bestProxy = proxies.find((proxy) => proxy.id === best.id)
// Default to w/e it was before
selectedProxy = bestProxy || selectedProxy
}
}
} }
// Only use healthy proxies. return computeUsableURLS(selectedProxy)
if (selectedProxy && selectedProxy.healthy) { }
const computeUsableURLS = (proxy?: Region): PreferredProxy => {
if (!proxy) {
// By default use relative links for the primary proxy. // By default use relative links for the primary proxy.
// This is the default, and we should not change it. // This is the default, and we should not change it.
if (selectedProxy.name !== "primary") { return {
pathAppURL = selectedProxy.path_app_url proxy: undefined,
preferredPathAppURL: "",
preferredWildcardHostname: "",
} }
wildcardHostname = selectedProxy.wildcard_hostname
} }
// TODO: @emyrk Should we notify the user if they had an unhealthy proxy selected? let pathAppURL = proxy?.path_app_url.replace(/\/$/, "")
// Primary proxy uses relative paths. It's the only exception.
if (proxy.name === "primary") {
pathAppURL = ""
}
return { return {
selectedProxy: selectedProxy, proxy: proxy,
// Trim trailing slashes to be consistent // Trim trailing slashes to be consistent
preferredPathAppURL: pathAppURL.replace(/\/$/, ""), preferredPathAppURL: pathAppURL,
preferredWildcardHostname: wildcardHostname, preferredWildcardHostname: proxy.wildcard_hostname,
} }
} }
// Local storage functions // Local storage functions
export const savePreferredProxy = (saved: PreferredProxy): void => { export const clearUserSelectedProxy = (): void => {
window.localStorage.setItem("preferred-proxy", JSON.stringify(saved)) window.localStorage.removeItem("user-selected-proxy")
} }
const loadPreferredProxy = (): PreferredProxy | undefined => { export const saveUserSelectedProxy = (saved: Region): void => {
const str = localStorage.getItem("preferred-proxy") window.localStorage.setItem("user-selected-proxy", JSON.stringify(saved))
}
export const loadUserSelectedProxy = (): Region | undefined => {
const str = localStorage.getItem("user-selected-proxy")
if (!str) { if (!str) {
return undefined return undefined
} }

View File

@ -46,7 +46,7 @@ const renderTerminal = () => {
value={{ value={{
proxyLatencies: MockProxyLatencies, proxyLatencies: MockProxyLatencies,
proxy: { proxy: {
selectedProxy: MockPrimaryWorkspaceProxy, proxy: MockPrimaryWorkspaceProxy,
preferredPathAppURL: "", preferredPathAppURL: "",
preferredWildcardHostname: "", preferredWildcardHostname: "",
}, },
@ -54,6 +54,7 @@ const renderTerminal = () => {
isFetched: true, isFetched: true,
isLoading: false, isLoading: false,
setProxy: jest.fn(), setProxy: jest.fn(),
clearProxy: jest.fn(),
}} }}
> >
<TerminalPage renderer="dom" /> <TerminalPage renderer="dom" />

View File

@ -36,7 +36,7 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
isLoading={proxiesLoading} isLoading={proxiesLoading}
hasLoaded={proxiesFetched} hasLoaded={proxiesFetched}
getWorkspaceProxiesError={proxiesError} getWorkspaceProxiesError={proxiesError}
preferredProxy={proxy.selectedProxy} preferredProxy={proxy.proxy}
onSelect={(proxy) => { onSelect={(proxy) => {
if (!proxy.healthy) { if (!proxy.healthy) {
displayError("Please select a healthy workspace proxy.") displayError("Please select a healthy workspace proxy.")

View File

@ -1397,7 +1397,10 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = {
}), }),
} }
export const MockExperiments: TypesGen.Experiment[] = ["workspace_actions"] export const MockExperiments: TypesGen.Experiment[] = [
"workspace_actions",
"moons",
]
export const MockAuditLog: TypesGen.AuditLog = { export const MockAuditLog: TypesGen.AuditLog = {
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",

View File

@ -0,0 +1,22 @@
export const localStorageMock = () => {
const store = {} as Record<string, string>
return {
getItem: (key: string): string => {
return store[key]
},
setItem: (key: string, value: string) => {
store[key] = value
},
clear: () => {
Object.keys(store).forEach((key) => {
delete store[key]
})
},
removeItem: (key: string) => {
delete store[key]
},
}
}
Object.defineProperty(window, "localStorage", { value: localStorageMock() })