diff --git a/site/src/modules/dashboard/Navbar/NavbarView.tsx b/site/src/modules/dashboard/Navbar/NavbarView.tsx index 376273c8d75ee..06e847ef76a3a 100644 --- a/site/src/modules/dashboard/Navbar/NavbarView.tsx +++ b/site/src/modules/dashboard/Navbar/NavbarView.tsx @@ -1,26 +1,16 @@ import { css, type Interpolation, type Theme, useTheme } from "@emotion/react"; -import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"; import MenuIcon from "@mui/icons-material/Menu"; -import Button from "@mui/material/Button"; -import Divider from "@mui/material/Divider"; import Drawer from "@mui/material/Drawer"; import IconButton from "@mui/material/IconButton"; -import Menu from "@mui/material/Menu"; -import MenuItem from "@mui/material/MenuItem"; -import Skeleton from "@mui/material/Skeleton"; -import { visuallyHidden } from "@mui/utils"; -import { type FC, useRef, useState } from "react"; -import { NavLink, useLocation, useNavigate } from "react-router-dom"; +import { type FC, useState } from "react"; +import { NavLink, useLocation } from "react-router-dom"; import type * as TypesGen from "api/typesGenerated"; -import { Abbr } from "components/Abbr/Abbr"; import { ExternalImage } from "components/ExternalImage/ExternalImage"; -import { displayError } from "components/GlobalSnackbar/utils"; import { CoderIcon } from "components/Icons/CoderIcon"; -import { Latency } from "components/Latency/Latency"; -import { useAuthenticated } from "contexts/auth/RequireAuth"; import type { ProxyContextValue } from "contexts/ProxyContext"; -import { BUTTON_SM_HEIGHT, navHeight } from "theme/constants"; +import { navHeight } from "theme/constants"; import { DeploymentDropdown } from "./DeploymentDropdown"; +import { ProxyMenu } from "./ProxyMenu"; import { UserDropdown } from "./UserDropdown/UserDropdown"; export interface NavbarViewProps { @@ -163,237 +153,6 @@ export const NavbarView: FC = ({ ); }; -interface ProxyMenuProps { - proxyContextValue: ProxyContextValue; -} - -const ProxyMenu: FC = ({ proxyContextValue }) => { - const theme = useTheme(); - const buttonRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const [refetchDate, setRefetchDate] = useState(); - const selectedProxy = proxyContextValue.proxy.proxy; - const refreshLatencies = proxyContextValue.refetchProxyLatencies; - const closeMenu = () => setIsOpen(false); - const navigate = useNavigate(); - const latencies = proxyContextValue.proxyLatencies; - const isLoadingLatencies = Object.keys(latencies).length === 0; - const isLoading = proxyContextValue.isLoading || isLoadingLatencies; - const { permissions } = useAuthenticated(); - - const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => { - if (!refetchDate) { - // Only show loading if the user manually requested a refetch - return false; - } - - // Only show a loading spinner if: - // - A latency exists. This means the latency was fetched at some point, so - // the loader *should* be resolved. - // - The proxy is healthy. If it is not, the loader might never resolve. - // - The latency reported is older than the refetch date. This means the - // latency is stale and we should show a loading spinner until the new - // latency is fetched. - const latency = latencies[proxy.id]; - return proxy.healthy && latency !== undefined && latency.at < refetchDate; - }; - - // This endpoint returns a 404 when not using enterprise. - // If we don't return null, then it looks like this is - // loading forever! - if (proxyContextValue.error) { - return null; - } - - if (isLoading) { - return ( - - ); - } - - return ( - <> - - - -
-

- Select a region nearest to you -

- -

- Workspace proxies improve terminal and web app connections to - workspaces. This does not apply to{" "} - - CLI - {" "} - connections. A region must be manually selected, otherwise the - default primary region will be used. -

-
- - - - {proxyContextValue.proxies && - [...proxyContextValue.proxies] - .sort((a, b) => { - const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity; - const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity; - return latencyA - latencyB; - }) - .map((proxy) => ( - { - if (!proxy.healthy) { - displayError("Please select a healthy workspace proxy."); - closeMenu(); - return; - } - - proxyContextValue.setProxy(proxy); - closeMenu(); - }} - > -
-
- -
- - {proxy.display_name} - - -
-
- ))} - - - - {Boolean(permissions.editWorkspaceProxies) && ( - { - navigate("/deployment/workspace-proxies"); - }} - > - Proxy settings - - )} - - { - // Stop the menu from closing - e.stopPropagation(); - // Refresh the latencies. - const refetchDate = refreshLatencies(); - setRefetchDate(refetchDate); - }} - > - Refresh Latencies - -
- - ); -}; - const styles = { desktopNavItems: (theme) => css` display: none; diff --git a/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx b/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx new file mode 100644 index 0000000000000..185677b62d8df --- /dev/null +++ b/site/src/modules/dashboard/Navbar/ProxyMenu.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn, userEvent, within } from "@storybook/test"; +import { getAuthorizationKey } from "api/queries/authCheck"; +import { AuthProvider } from "contexts/auth/AuthProvider"; +import { permissionsToCheck } from "contexts/auth/permissions"; +import { getPreferredProxy } from "contexts/ProxyContext"; +import { + MockAuthMethodsAll, + MockPermissions, + MockProxyLatencies, + MockUser, + MockWorkspaceProxies, +} from "testHelpers/entities"; +import { ProxyMenu } from "./ProxyMenu"; + +const defaultProxyContextValue = { + proxyLatencies: MockProxyLatencies, + proxy: getPreferredProxy(MockWorkspaceProxies, undefined), + proxies: MockWorkspaceProxies, + isLoading: false, + isFetched: true, + setProxy: fn(), + clearProxy: fn(), + refetchProxyLatencies: () => new Date(), +}; + +const meta: Meta = { + title: "modules/dashboard/ProxyMenu", + component: ProxyMenu, + args: { + proxyContextValue: defaultProxyContextValue, + }, + decorators: [ + (Story) => ( + + + + ), + (Story) => ( +
+ +
+ ), + ], + parameters: { + queries: [ + { key: ["me"], data: MockUser }, + { key: ["authMethods"], data: MockAuthMethodsAll }, + { key: ["hasFirstUser"], data: true }, + { + key: getAuthorizationKey({ checks: permissionsToCheck }), + data: MockPermissions, + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Closed: Story = {}; + +export const Opened: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + }, +}; + +export const SingleProxy: Story = { + args: { + proxyContextValue: { + ...defaultProxyContextValue, + proxies: [MockWorkspaceProxies[0]], + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + }, +}; diff --git a/site/src/modules/dashboard/Navbar/ProxyMenu.tsx b/site/src/modules/dashboard/Navbar/ProxyMenu.tsx new file mode 100644 index 0000000000000..61fb88fd190a0 --- /dev/null +++ b/site/src/modules/dashboard/Navbar/ProxyMenu.tsx @@ -0,0 +1,252 @@ +import { useTheme } from "@emotion/react"; +import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"; +import Button from "@mui/material/Button"; +import Divider from "@mui/material/Divider"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import Skeleton from "@mui/material/Skeleton"; +import { visuallyHidden } from "@mui/utils"; +import { type FC, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import type * as TypesGen from "api/typesGenerated"; +import { Abbr } from "components/Abbr/Abbr"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { Latency } from "components/Latency/Latency"; +import { useAuthenticated } from "contexts/auth/RequireAuth"; +import type { ProxyContextValue } from "contexts/ProxyContext"; +import { BUTTON_SM_HEIGHT } from "theme/constants"; + +interface ProxyMenuProps { + proxyContextValue: ProxyContextValue; +} + +export const ProxyMenu: FC = ({ proxyContextValue }) => { + const theme = useTheme(); + const buttonRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [refetchDate, setRefetchDate] = useState(); + const selectedProxy = proxyContextValue.proxy.proxy; + const refreshLatencies = proxyContextValue.refetchProxyLatencies; + const closeMenu = () => setIsOpen(false); + const navigate = useNavigate(); + const latencies = proxyContextValue.proxyLatencies; + const isLoadingLatencies = Object.keys(latencies).length === 0; + const isLoading = proxyContextValue.isLoading || isLoadingLatencies; + const { permissions } = useAuthenticated(); + + const proxyLatencyLoading = (proxy: TypesGen.Region): boolean => { + if (!refetchDate) { + // Only show loading if the user manually requested a refetch + return false; + } + + // Only show a loading spinner if: + // - A latency exists. This means the latency was fetched at some point, so + // the loader *should* be resolved. + // - The proxy is healthy. If it is not, the loader might never resolve. + // - The latency reported is older than the refetch date. This means the + // latency is stale and we should show a loading spinner until the new + // latency is fetched. + const latency = latencies[proxy.id]; + return proxy.healthy && latency !== undefined && latency.at < refetchDate; + }; + + // This endpoint returns a 404 when not using enterprise. + // If we don't return null, then it looks like this is + // loading forever! + if (proxyContextValue.error) { + return null; + } + + if (isLoading) { + return ( + + ); + } + + return ( + <> + + + + {proxyContextValue.proxies && proxyContextValue.proxies.length > 1 && ( + <> +
+

+ Select a region nearest to you +

+ +

+ Workspace proxies improve terminal and web app connections to + workspaces. This does not apply to{" "} + + CLI + {" "} + connections. A region must be manually selected, otherwise the + default primary region will be used. +

+
+ + + + )} + + {proxyContextValue.proxies && + [...proxyContextValue.proxies] + .sort((a, b) => { + const latencyA = latencies?.[a.id]?.latencyMS ?? Infinity; + const latencyB = latencies?.[b.id]?.latencyMS ?? Infinity; + return latencyA - latencyB; + }) + .map((proxy) => ( + { + if (!proxy.healthy) { + displayError("Please select a healthy workspace proxy."); + closeMenu(); + return; + } + + proxyContextValue.setProxy(proxy); + closeMenu(); + }} + > +
+
+ +
+ + {proxy.display_name} + + +
+
+ ))} + + + + {Boolean(permissions.editWorkspaceProxies) && ( + { + navigate("/deployment/workspace-proxies"); + }} + > + Proxy settings + + )} + + { + // Stop the menu from closing + e.stopPropagation(); + // Refresh the latencies. + const refetchDate = refreshLatencies(); + setRefetchDate(refetchDate); + }} + > + Refresh Latencies + +
+ + ); +};