mirror of
https://github.com/coder/coder.git
synced 2025-07-08 11:39:50 +00:00
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:
@ -8,6 +8,7 @@ import { Blob } from "buffer"
|
||||
import jestFetchMock from "jest-fetch-mock"
|
||||
import { ProxyLatencyReport } from "contexts/useProxyLatency"
|
||||
import { RegionsResponse } from "api/typesGenerated"
|
||||
import { useMemo } from "react"
|
||||
|
||||
jestFetchMock.enableMocks()
|
||||
|
||||
@ -16,20 +17,25 @@ jestFetchMock.enableMocks()
|
||||
// actual network requests. So just globally mock this hook.
|
||||
jest.mock("contexts/useProxyLatency", () => ({
|
||||
useProxyLatency: (proxies?: RegionsResponse) => {
|
||||
if (!proxies) {
|
||||
return {} as Record<string, ProxyLatencyReport>
|
||||
}
|
||||
|
||||
return proxies.regions.reduce((acc, proxy) => {
|
||||
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(),
|
||||
// Must use `useMemo` here to avoid infinite loop.
|
||||
// Mocking the hook with a hook.
|
||||
const latencies = useMemo(() => {
|
||||
if (!proxies) {
|
||||
return {} as Record<string, ProxyLatencyReport>
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, ProxyLatencyReport>)
|
||||
return proxies.regions.reduce((acc, proxy) => {
|
||||
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
|
||||
},
|
||||
}))
|
||||
|
||||
|
@ -26,6 +26,9 @@ const Template: Story<AppLinkProps> = (args) => (
|
||||
setProxy: () => {
|
||||
return
|
||||
},
|
||||
clearProxy: () => {
|
||||
return
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AppLink {...args} />
|
||||
|
@ -65,6 +65,9 @@ const TemplateFC = (
|
||||
setProxy: () => {
|
||||
return
|
||||
},
|
||||
clearProxy: () => {
|
||||
return
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AgentRow {...args} />
|
||||
|
@ -30,6 +30,9 @@ Example.args = {
|
||||
setProxy: () => {
|
||||
return
|
||||
},
|
||||
clearProxy: () => {
|
||||
return
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AgentRow
|
||||
@ -97,6 +100,9 @@ BunchOfMetadata.args = {
|
||||
setProxy: () => {
|
||||
return
|
||||
},
|
||||
clearProxy: () => {
|
||||
return
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AgentRow
|
||||
|
@ -47,6 +47,9 @@ const Template: Story<WorkspaceProps> = (args) => (
|
||||
proxies: [],
|
||||
isLoading: false,
|
||||
isFetched: true,
|
||||
clearProxy: () => {
|
||||
return
|
||||
},
|
||||
setProxy: () => {
|
||||
return
|
||||
},
|
||||
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
386
site/src/contexts/ProxyContext.test.tsx
Normal file
386
site/src/contexts/ProxyContext.test.tsx
Normal 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 || "")
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
@ -6,28 +6,60 @@ import {
|
||||
createContext,
|
||||
FC,
|
||||
PropsWithChildren,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react"
|
||||
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
|
||||
|
||||
// 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[]
|
||||
proxyLatencies?: Record<string, ProxyLatencyReport>
|
||||
// isfetched is true when the proxy api call is complete.
|
||||
// isFetched is true when the 'proxies' api call is complete.
|
||||
isFetched: boolean
|
||||
// isLoading is true if the proxy is in the process of being fetched.
|
||||
isLoading: boolean
|
||||
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
|
||||
// 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 {
|
||||
// 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
|
||||
// object. Use the preferred fields.
|
||||
selectedProxy: Region | undefined
|
||||
proxy: 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"
|
||||
@ -44,19 +76,18 @@ export const ProxyContext = createContext<ProxyContextValue | 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")
|
||||
|
||||
// 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 {
|
||||
data: proxiesResp,
|
||||
@ -66,41 +97,37 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
} = 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)
|
||||
},
|
||||
})
|
||||
|
||||
// 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.
|
||||
const proxyLatencies = useProxyLatency(proxiesResp)
|
||||
|
||||
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)
|
||||
}
|
||||
// updateProxy is a helper function that when called will
|
||||
// update the proxy being used.
|
||||
const updateProxy = useCallback(() => {
|
||||
// Update the saved user proxy for the caller.
|
||||
setUserSavedProxy(loadUserSelectedProxy())
|
||||
setProxy(
|
||||
getPreferredProxy(
|
||||
proxiesResp?.regions ?? [],
|
||||
loadUserSelectedProxy(),
|
||||
proxyLatencies,
|
||||
),
|
||||
)
|
||||
}, [proxiesResp, proxyLatencies])
|
||||
|
||||
// This useEffect ensures the proxy to be used is updated whenever the state changes.
|
||||
// This includes proxies being loaded, latencies being calculated, and the user selecting a proxy.
|
||||
useEffect(() => {
|
||||
updateProxy()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only update if the source data changes
|
||||
}, [proxiesResp, proxyLatencies])
|
||||
|
||||
return (
|
||||
<ProxyContext.Provider
|
||||
value={{
|
||||
userProxy: userSavedProxy,
|
||||
proxyLatencies: proxyLatencies,
|
||||
proxy: experimentEnabled
|
||||
? proxy
|
||||
@ -113,9 +140,19 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
isLoading: proxiesLoading,
|
||||
isFetched: proxiesFetched,
|
||||
error: proxiesError,
|
||||
// A function that takes the new proxies and selected proxy and updates
|
||||
// the state with the appropriate urls.
|
||||
setProxy: setAndSaveProxy,
|
||||
|
||||
// These functions are exposed to allow the user to select a proxy.
|
||||
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}
|
||||
@ -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.
|
||||
* 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.
|
||||
* @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 = (
|
||||
proxies: Region[],
|
||||
selectedProxy?: Region,
|
||||
latencies?: Record<string, ProxyLatencyReport>,
|
||||
): 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(
|
||||
@ -158,37 +193,76 @@ export const getPreferredProxy = (
|
||||
|
||||
// If no proxy is selected, or the selected proxy is unhealthy default to the primary proxy.
|
||||
if (!selectedProxy || !selectedProxy.healthy) {
|
||||
// By default, use the primary proxy.
|
||||
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.
|
||||
if (selectedProxy && selectedProxy.healthy) {
|
||||
return computeUsableURLS(selectedProxy)
|
||||
}
|
||||
|
||||
const computeUsableURLS = (proxy?: Region): PreferredProxy => {
|
||||
if (!proxy) {
|
||||
// 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
|
||||
return {
|
||||
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 {
|
||||
selectedProxy: selectedProxy,
|
||||
proxy: proxy,
|
||||
// Trim trailing slashes to be consistent
|
||||
preferredPathAppURL: pathAppURL.replace(/\/$/, ""),
|
||||
preferredWildcardHostname: wildcardHostname,
|
||||
preferredPathAppURL: pathAppURL,
|
||||
preferredWildcardHostname: proxy.wildcard_hostname,
|
||||
}
|
||||
}
|
||||
|
||||
// Local storage functions
|
||||
|
||||
export const savePreferredProxy = (saved: PreferredProxy): void => {
|
||||
window.localStorage.setItem("preferred-proxy", JSON.stringify(saved))
|
||||
export const clearUserSelectedProxy = (): void => {
|
||||
window.localStorage.removeItem("user-selected-proxy")
|
||||
}
|
||||
|
||||
const loadPreferredProxy = (): PreferredProxy | undefined => {
|
||||
const str = localStorage.getItem("preferred-proxy")
|
||||
export const saveUserSelectedProxy = (saved: Region): void => {
|
||||
window.localStorage.setItem("user-selected-proxy", JSON.stringify(saved))
|
||||
}
|
||||
|
||||
export const loadUserSelectedProxy = (): Region | undefined => {
|
||||
const str = localStorage.getItem("user-selected-proxy")
|
||||
if (!str) {
|
||||
return undefined
|
||||
}
|
||||
|
@ -46,7 +46,7 @@ const renderTerminal = () => {
|
||||
value={{
|
||||
proxyLatencies: MockProxyLatencies,
|
||||
proxy: {
|
||||
selectedProxy: MockPrimaryWorkspaceProxy,
|
||||
proxy: MockPrimaryWorkspaceProxy,
|
||||
preferredPathAppURL: "",
|
||||
preferredWildcardHostname: "",
|
||||
},
|
||||
@ -54,6 +54,7 @@ const renderTerminal = () => {
|
||||
isFetched: true,
|
||||
isLoading: false,
|
||||
setProxy: jest.fn(),
|
||||
clearProxy: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<TerminalPage renderer="dom" />
|
||||
|
@ -36,7 +36,7 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
isLoading={proxiesLoading}
|
||||
hasLoaded={proxiesFetched}
|
||||
getWorkspaceProxiesError={proxiesError}
|
||||
preferredProxy={proxy.selectedProxy}
|
||||
preferredProxy={proxy.proxy}
|
||||
onSelect={(proxy) => {
|
||||
if (!proxy.healthy) {
|
||||
displayError("Please select a healthy workspace proxy.")
|
||||
|
@ -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 = {
|
||||
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",
|
||||
|
22
site/src/testHelpers/localstorage.ts
Normal file
22
site/src/testHelpers/localstorage.ts
Normal 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() })
|
Reference in New Issue
Block a user