diff --git a/site/jest.setup.ts b/site/jest.setup.ts index 24e3ae46a7eda..86a6108f9661e 100644 --- a/site/jest.setup.ts +++ b/site/jest.setup.ts @@ -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 - } - - 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 } - return acc - }, {} as Record) + 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) + }, [proxies]) + + return latencies }, })) diff --git a/site/src/components/AppLink/AppLink.stories.tsx b/site/src/components/AppLink/AppLink.stories.tsx index d963637902fd5..c478dbf973a77 100644 --- a/site/src/components/AppLink/AppLink.stories.tsx +++ b/site/src/components/AppLink/AppLink.stories.tsx @@ -26,6 +26,9 @@ const Template: Story = (args) => ( setProxy: () => { return }, + clearProxy: () => { + return + }, }} > diff --git a/site/src/components/Resources/AgentRow.stories.tsx b/site/src/components/Resources/AgentRow.stories.tsx index 9b9d63d5b90ca..eac06ec1231bf 100644 --- a/site/src/components/Resources/AgentRow.stories.tsx +++ b/site/src/components/Resources/AgentRow.stories.tsx @@ -65,6 +65,9 @@ const TemplateFC = ( setProxy: () => { return }, + clearProxy: () => { + return + }, }} > diff --git a/site/src/components/Resources/ResourceCard.stories.tsx b/site/src/components/Resources/ResourceCard.stories.tsx index afba475fa24ee..cdc1f028772db 100644 --- a/site/src/components/Resources/ResourceCard.stories.tsx +++ b/site/src/components/Resources/ResourceCard.stories.tsx @@ -30,6 +30,9 @@ Example.args = { setProxy: () => { return }, + clearProxy: () => { + return + }, }} > { return }, + clearProxy: () => { + return + }, }} > = (args) => ( proxies: [], isLoading: false, isFetched: true, + clearProxy: () => { + return + }, setProxy: () => { return }, diff --git a/site/src/contexts/ProxyContext.test.ts b/site/src/contexts/ProxyContext.test.ts deleted file mode 100644 index a94ebbec22081..0000000000000 --- a/site/src/contexts/ProxyContext.test.ts +++ /dev/null @@ -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, - ) - }, - ) -}) diff --git a/site/src/contexts/ProxyContext.test.tsx b/site/src/contexts/ProxyContext.test.tsx new file mode 100644 index 0000000000000..f7b513699b3e0 --- /dev/null +++ b/site/src/contexts/ProxyContext.test.tsx @@ -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 = {} + +// 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( + + + , + { + 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 ( + <> +
+
+
+
+ +
+ + + ) +} + +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 + // afterLoad are actions to take after loading the component, but before + // assertions. This is useful for simulating user actions. + afterLoad?: () => Promise + + // 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 => { + const user = userEvent.setup() + const clearProxyButton = screen.getByTestId("clearProxy") + await user.click(clearProxyButton) + } + + const userSelectProxy = (proxy: Region): (() => Promise) => { + return async (): Promise => { + 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 || "") + }) + }, + ) +}) diff --git a/site/src/contexts/ProxyContext.tsx b/site/src/contexts/ProxyContext.tsx index 66390164809a6..32d3e3ae11d63 100644 --- a/site/src/contexts/ProxyContext.tsx +++ b/site/src/contexts/ProxyContext.tsx @@ -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 - // 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 + // 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( * ProxyProvider interacts with local storage to indicate the preferred workspace proxy. */ export const ProxyProvider: FC = ({ 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(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( + computeUsableURLS(userSavedProxy), + ) + const queryKey = ["get-proxies"] const { data: proxiesResp, @@ -66,41 +97,37 @@ export const ProxyProvider: FC = ({ 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 ( = ({ 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, ): 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) + + 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") +} + +export const saveUserSelectedProxy = (saved: Region): void => { + window.localStorage.setItem("user-selected-proxy", JSON.stringify(saved)) } -const loadPreferredProxy = (): PreferredProxy | undefined => { - const str = localStorage.getItem("preferred-proxy") +export const loadUserSelectedProxy = (): Region | undefined => { + const str = localStorage.getItem("user-selected-proxy") if (!str) { return undefined } diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index 1ca67ae146344..e12363506d504 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -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(), }} > diff --git a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage.tsx b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage.tsx index 30a549eea1391..1a6a26db31313 100644 --- a/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage.tsx +++ b/site/src/pages/UserSettingsPage/WorkspaceProxyPage/WorkspaceProxyPage.tsx @@ -36,7 +36,7 @@ export const WorkspaceProxyPage: FC> = () => { isLoading={proxiesLoading} hasLoaded={proxiesFetched} getWorkspaceProxiesError={proxiesError} - preferredProxy={proxy.selectedProxy} + preferredProxy={proxy.proxy} onSelect={(proxy) => { if (!proxy.healthy) { displayError("Please select a healthy workspace proxy.") diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 40b8281a208bf..0039cf19a84e6 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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", diff --git a/site/src/testHelpers/localstorage.ts b/site/src/testHelpers/localstorage.ts new file mode 100644 index 0000000000000..331a311323a36 --- /dev/null +++ b/site/src/testHelpers/localstorage.ts @@ -0,0 +1,22 @@ +export const localStorageMock = () => { + const store = {} as Record + + 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() })