mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +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 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
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
@ -26,6 +26,9 @@ const Template: Story<AppLinkProps> = (args) => (
|
|||||||
setProxy: () => {
|
setProxy: () => {
|
||||||
return
|
return
|
||||||
},
|
},
|
||||||
|
clearProxy: () => {
|
||||||
|
return
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppLink {...args} />
|
<AppLink {...args} />
|
||||||
|
@ -65,6 +65,9 @@ const TemplateFC = (
|
|||||||
setProxy: () => {
|
setProxy: () => {
|
||||||
return
|
return
|
||||||
},
|
},
|
||||||
|
clearProxy: () => {
|
||||||
|
return
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AgentRow {...args} />
|
<AgentRow {...args} />
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
},
|
},
|
||||||
|
@ -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,
|
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
|
||||||
}
|
}
|
||||||
|
@ -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" />
|
||||||
|
@ -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.")
|
||||||
|
@ -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",
|
||||||
|
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