mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: move proxy settings page to deployment options (#8246)
* feat: Move workspace proxy page to deployment options Workspace proxy settings page is now an admin feature * WorkspaceProxy response extends region
This commit is contained in:
+3
-3
@@ -7,7 +7,7 @@ import { TextEncoder, TextDecoder } from "util"
|
||||
import { Blob } from "buffer"
|
||||
import jestFetchMock from "jest-fetch-mock"
|
||||
import { ProxyLatencyReport } from "contexts/useProxyLatency"
|
||||
import { RegionsResponse } from "api/typesGenerated"
|
||||
import { Region } from "api/typesGenerated"
|
||||
import { useMemo } from "react"
|
||||
|
||||
jestFetchMock.enableMocks()
|
||||
@@ -16,14 +16,14 @@ jestFetchMock.enableMocks()
|
||||
// This would fail unit testing, or at least make it very slow with
|
||||
// actual network requests. So just globally mock this hook.
|
||||
jest.mock("contexts/useProxyLatency", () => ({
|
||||
useProxyLatency: (proxies?: RegionsResponse) => {
|
||||
useProxyLatency: (proxies?: Region[]) => {
|
||||
// Must use `useMemo` here to avoid infinite loop.
|
||||
// Mocking the hook with a hook.
|
||||
const proxyLatencies = useMemo(() => {
|
||||
if (!proxies) {
|
||||
return {} as Record<string, ProxyLatencyReport>
|
||||
}
|
||||
return proxies.regions.reduce((acc, proxy) => {
|
||||
return proxies.reduce((acc, proxy) => {
|
||||
acc[proxy.id] = {
|
||||
accurate: true,
|
||||
// Return a constant latency of 8ms.
|
||||
|
||||
+3
-1
@@ -146,7 +146,9 @@ type Handler struct {
|
||||
buildInfoJSON string
|
||||
|
||||
AppearanceFetcher func(ctx context.Context) (codersdk.AppearanceConfig, error)
|
||||
RegionsFetcher func(ctx context.Context) (codersdk.RegionsResponse, error)
|
||||
// RegionsFetcher will attempt to fetch the more detailed WorkspaceProxy data, but will fall back to the
|
||||
// regions if the user does not have the correct permissions.
|
||||
RegionsFetcher func(ctx context.Context) (any, error)
|
||||
|
||||
Entitlements atomic.Pointer[codersdk.Entitlements]
|
||||
Experiments atomic.Pointer[codersdk.Experiments]
|
||||
|
||||
@@ -276,6 +276,10 @@ export const AppRouter: FC = () => {
|
||||
<Route path="network" element={<NetworkSettingsPage />} />
|
||||
<Route path="userauth" element={<UserAuthSettingsPage />} />
|
||||
<Route path="gitauth" element={<GitAuthSettingsPage />} />
|
||||
<Route
|
||||
path="workspace-proxies"
|
||||
element={<WorkspaceProxyPage />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="settings" element={<SettingsLayout />}>
|
||||
@@ -286,10 +290,6 @@ export const AppRouter: FC = () => {
|
||||
<Route index element={<TokensPage />} />
|
||||
<Route path="new" element={<CreateTokenPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="workspace-proxies"
|
||||
element={<WorkspaceProxyPage />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="/:username">
|
||||
|
||||
+17
-7
@@ -966,13 +966,23 @@ export const getFile = async (fileId: string): Promise<ArrayBuffer> => {
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspaceProxies =
|
||||
async (): Promise<TypesGen.RegionsResponse> => {
|
||||
const response = await axios.get<TypesGen.RegionsResponse>(
|
||||
`/api/v2/regions`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
export const getWorkspaceProxyRegions = async (): Promise<
|
||||
TypesGen.RegionsResponse<TypesGen.Region>
|
||||
> => {
|
||||
const response = await axios.get<TypesGen.RegionsResponse<TypesGen.Region>>(
|
||||
`/api/v2/regions`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspaceProxies = async (): Promise<
|
||||
TypesGen.RegionsResponse<TypesGen.WorkspaceProxy>
|
||||
> => {
|
||||
const response = await axios.get<
|
||||
TypesGen.RegionsResponse<TypesGen.WorkspaceProxy>
|
||||
>(`/api/v2/workspaceproxies`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
|
||||
try {
|
||||
|
||||
@@ -747,8 +747,8 @@ export interface Region {
|
||||
}
|
||||
|
||||
// From codersdk/workspaceproxy.go
|
||||
export interface RegionsResponse {
|
||||
readonly regions: Region[]
|
||||
export interface RegionsResponse<R extends RegionTypes> {
|
||||
readonly regions: R[]
|
||||
}
|
||||
|
||||
// From codersdk/replicas.go
|
||||
@@ -1307,17 +1307,11 @@ export interface WorkspaceOptions {
|
||||
}
|
||||
|
||||
// From codersdk/workspaceproxy.go
|
||||
export interface WorkspaceProxy {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly display_name: string
|
||||
readonly icon: string
|
||||
readonly url: string
|
||||
readonly wildcard_hostname: string
|
||||
export interface WorkspaceProxy extends Region {
|
||||
readonly status?: WorkspaceProxyStatus
|
||||
readonly created_at: string
|
||||
readonly updated_at: string
|
||||
readonly deleted: boolean
|
||||
readonly status?: WorkspaceProxyStatus
|
||||
}
|
||||
|
||||
// From codersdk/deployment.go
|
||||
@@ -1732,3 +1726,6 @@ export const WorkspaceTransitions: WorkspaceTransition[] = [
|
||||
"start",
|
||||
"stop",
|
||||
]
|
||||
|
||||
// From codersdk/workspaceproxy.go
|
||||
export type RegionTypes = Region | WorkspaceProxy
|
||||
|
||||
@@ -40,6 +40,24 @@ export const NotHealthyBadge: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const NotRegisteredBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<span className={combineClasses([styles.badge, styles.warnBadge])}>
|
||||
Not Registered
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const NotReachableBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
<span className={combineClasses([styles.badge, styles.errorBadge])}>
|
||||
Not Reachable
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const DisabledBadge: FC = () => {
|
||||
const styles = useStyles()
|
||||
return (
|
||||
@@ -88,6 +106,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "fit-content",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
|
||||
enterpriseBadge: {
|
||||
@@ -115,6 +134,11 @@ const useStyles = makeStyles((theme) => ({
|
||||
backgroundColor: theme.palette.error.dark,
|
||||
},
|
||||
|
||||
warnBadge: {
|
||||
border: `1px solid ${theme.palette.warning.light}`,
|
||||
backgroundColor: theme.palette.warning.dark,
|
||||
},
|
||||
|
||||
disabledBadge: {
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
|
||||
@@ -4,12 +4,14 @@ import LaunchOutlined from "@mui/icons-material/LaunchOutlined"
|
||||
import ApprovalIcon from "@mui/icons-material/VerifiedUserOutlined"
|
||||
import LockRounded from "@mui/icons-material/LockOutlined"
|
||||
import Globe from "@mui/icons-material/PublicOutlined"
|
||||
import HubOutlinedIcon from "@mui/icons-material/HubOutlined"
|
||||
import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined"
|
||||
import { GitIcon } from "components/Icons/GitIcon"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { ElementType, PropsWithChildren, ReactNode, FC } from "react"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import { combineClasses } from "utils/combineClasses"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
|
||||
const SidebarNavItem: FC<
|
||||
PropsWithChildren<{ href: string; icon: ReactNode }>
|
||||
@@ -40,6 +42,7 @@ const SidebarNavItemIcon: FC<{ icon: ElementType }> = ({ icon: Icon }) => {
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const styles = useStyles()
|
||||
const dashboard = useDashboard()
|
||||
|
||||
return (
|
||||
<nav className={styles.sidebar}>
|
||||
@@ -76,6 +79,14 @@ export const Sidebar: React.FC = () => {
|
||||
<SidebarNavItem href="network" icon={<SidebarNavItemIcon icon={Globe} />}>
|
||||
Network
|
||||
</SidebarNavItem>
|
||||
{dashboard.experiments.includes("moons") && (
|
||||
<SidebarNavItem
|
||||
href="workspace-proxies"
|
||||
icon={<SidebarNavItemIcon icon={HubOutlinedIcon} />}
|
||||
>
|
||||
Workspace Proxy
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
<SidebarNavItem
|
||||
href="security"
|
||||
icon={<SidebarNavItemIcon icon={LockRounded} />}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
MockUser,
|
||||
MockUser2,
|
||||
} from "../../testHelpers/entities"
|
||||
import { render } from "../../testHelpers/renderHelpers"
|
||||
import { renderWithAuth } from "../../testHelpers/renderHelpers"
|
||||
import { Language as navLanguage, NavbarView } from "./NavbarView"
|
||||
import { ProxyContextValue } from "contexts/ProxyContext"
|
||||
import { action } from "@storybook/addon-actions"
|
||||
@@ -41,7 +41,7 @@ describe("NavbarView", () => {
|
||||
})
|
||||
|
||||
it("workspaces nav link has the correct href", async () => {
|
||||
render(
|
||||
renderWithAuth(
|
||||
<NavbarView
|
||||
proxyContextValue={proxyContextValue}
|
||||
user={MockUser}
|
||||
@@ -55,7 +55,7 @@ describe("NavbarView", () => {
|
||||
})
|
||||
|
||||
it("templates nav link has the correct href", async () => {
|
||||
render(
|
||||
renderWithAuth(
|
||||
<NavbarView
|
||||
proxyContextValue={proxyContextValue}
|
||||
user={MockUser}
|
||||
@@ -69,7 +69,7 @@ describe("NavbarView", () => {
|
||||
})
|
||||
|
||||
it("users nav link has the correct href", async () => {
|
||||
render(
|
||||
renderWithAuth(
|
||||
<NavbarView
|
||||
proxyContextValue={proxyContextValue}
|
||||
user={MockUser}
|
||||
@@ -91,7 +91,7 @@ describe("NavbarView", () => {
|
||||
}
|
||||
|
||||
// When
|
||||
render(
|
||||
renderWithAuth(
|
||||
<NavbarView
|
||||
proxyContextValue={proxyContextValue}
|
||||
user={mockUser}
|
||||
@@ -108,7 +108,7 @@ describe("NavbarView", () => {
|
||||
})
|
||||
|
||||
it("audit nav link has the correct href", async () => {
|
||||
render(
|
||||
renderWithAuth(
|
||||
<NavbarView
|
||||
proxyContextValue={proxyContextValue}
|
||||
user={MockUser}
|
||||
@@ -122,7 +122,7 @@ describe("NavbarView", () => {
|
||||
})
|
||||
|
||||
it("audit nav link is hidden for members", async () => {
|
||||
render(
|
||||
renderWithAuth(
|
||||
<NavbarView
|
||||
proxyContextValue={proxyContextValue}
|
||||
user={MockUser2}
|
||||
@@ -136,7 +136,7 @@ describe("NavbarView", () => {
|
||||
})
|
||||
|
||||
it("deployment nav link has the correct href", async () => {
|
||||
render(
|
||||
renderWithAuth(
|
||||
<NavbarView
|
||||
proxyContextValue={proxyContextValue}
|
||||
user={MockUser}
|
||||
@@ -152,7 +152,7 @@ describe("NavbarView", () => {
|
||||
})
|
||||
|
||||
it("deployment nav link is hidden for members", async () => {
|
||||
render(
|
||||
renderWithAuth(
|
||||
<NavbarView
|
||||
proxyContextValue={proxyContextValue}
|
||||
user={MockUser2}
|
||||
|
||||
@@ -23,6 +23,7 @@ import Divider from "@mui/material/Divider"
|
||||
import Skeleton from "@mui/material/Skeleton"
|
||||
import { BUTTON_SM_HEIGHT } from "theme/theme"
|
||||
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency"
|
||||
import { usePermissions } from "hooks/usePermissions"
|
||||
|
||||
export const USERS_LINK = `/users?filter=${encodeURIComponent("status:active")}`
|
||||
|
||||
@@ -194,6 +195,7 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
||||
const latencies = proxyContextValue.proxyLatencies
|
||||
const isLoadingLatencies = Object.keys(latencies).length === 0
|
||||
const isLoading = proxyContextValue.isLoading || isLoadingLatencies
|
||||
const permissions = usePermissions()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -280,15 +282,25 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
||||
</MenuItem>
|
||||
))}
|
||||
<Divider sx={{ borderColor: (theme) => theme.palette.divider }} />
|
||||
{Boolean(permissions.editWorkspaceProxies) && (
|
||||
<MenuItem
|
||||
sx={{ fontSize: 14 }}
|
||||
onClick={() => {
|
||||
navigate("settings/deployment/workspace-proxies")
|
||||
}}
|
||||
>
|
||||
Proxy settings
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
sx={{ fontSize: 14 }}
|
||||
onClick={() => {
|
||||
navigate("/settings/workspace-proxies")
|
||||
onClick={(e) => {
|
||||
// Stop the menu from closing
|
||||
e.stopPropagation()
|
||||
// Refresh the latencies.
|
||||
refreshLatencies()
|
||||
}}
|
||||
>
|
||||
Proxy settings
|
||||
</MenuItem>
|
||||
<MenuItem sx={{ fontSize: 14 }} onClick={refreshLatencies}>
|
||||
Refresh Latencies
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -9,8 +9,6 @@ import { NavLink } from "react-router-dom"
|
||||
import { combineClasses } from "utils/combineClasses"
|
||||
import AccountIcon from "@mui/icons-material/Person"
|
||||
import SecurityIcon from "@mui/icons-material/LockOutlined"
|
||||
import PublicIcon from "@mui/icons-material/Public"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
|
||||
const SidebarNavItem: FC<
|
||||
PropsWithChildren<{ href: string; icon: ReactNode }>
|
||||
@@ -43,7 +41,6 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
|
||||
|
||||
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
||||
const styles = useStyles()
|
||||
const dashboard = useDashboard()
|
||||
|
||||
return (
|
||||
<nav className={styles.sidebar}>
|
||||
@@ -79,14 +76,6 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
|
||||
>
|
||||
Tokens
|
||||
</SidebarNavItem>
|
||||
{dashboard.experiments.includes("moons") && (
|
||||
<SidebarNavItem
|
||||
href="workspace-proxies"
|
||||
icon={<SidebarNavItemIcon icon={PublicIcon} />}
|
||||
>
|
||||
Workspace Proxy
|
||||
</SidebarNavItem>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -360,6 +360,14 @@ describe("ProxyContextSelection", () => {
|
||||
}),
|
||||
)
|
||||
}),
|
||||
rest.get("/api/v2/workspaceproxies", async (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
regions: regions,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
TestingComponent()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getWorkspaceProxies } from "api/api"
|
||||
import { Region } from "api/typesGenerated"
|
||||
import { getWorkspaceProxies, getWorkspaceProxyRegions } from "api/api"
|
||||
import { Region, WorkspaceProxy } from "api/typesGenerated"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import {
|
||||
createContext,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useState,
|
||||
} from "react"
|
||||
import { ProxyLatencyReport, useProxyLatency } from "./useProxyLatency"
|
||||
import { usePermissions } from "hooks/usePermissions"
|
||||
|
||||
export interface ProxyContextValue {
|
||||
// proxy is **always** the workspace proxy that should be used.
|
||||
@@ -36,7 +37,12 @@ export interface ProxyContextValue {
|
||||
|
||||
// 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[]
|
||||
//
|
||||
// Region[] is returned if the user is a non-admin.
|
||||
// WorkspaceProxy[] is returned if the user is an admin. WorkspaceProxy extends Region with
|
||||
// more information about the proxy and the status. More information includes the error message if
|
||||
// the proxy is unhealthy.
|
||||
proxies?: Region[] | WorkspaceProxy[]
|
||||
// isFetched is true when the 'proxies' api call is complete.
|
||||
isFetched: boolean
|
||||
isLoading: boolean
|
||||
@@ -103,12 +109,26 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
if (regions) {
|
||||
const rawContent = regions.getAttribute("content")
|
||||
try {
|
||||
return JSON.parse(rawContent as string)
|
||||
const obj = JSON.parse(rawContent as string)
|
||||
if ("regions" in obj) {
|
||||
return obj.regions as Region[]
|
||||
}
|
||||
return obj as Region[]
|
||||
} catch (ex) {
|
||||
// Ignore this and fetch as normal!
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const permissions = usePermissions()
|
||||
const query = async (): Promise<Region[]> => {
|
||||
const endpoint = permissions.editWorkspaceProxies
|
||||
? getWorkspaceProxies
|
||||
: getWorkspaceProxyRegions
|
||||
const resp = await endpoint()
|
||||
return resp.regions
|
||||
}
|
||||
|
||||
const {
|
||||
data: proxiesResp,
|
||||
error: proxiesError,
|
||||
@@ -116,7 +136,7 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
isFetched: proxiesFetched,
|
||||
} = useQuery({
|
||||
queryKey,
|
||||
queryFn: getWorkspaceProxies,
|
||||
queryFn: query,
|
||||
staleTime: initialData ? Infinity : undefined,
|
||||
initialData,
|
||||
})
|
||||
@@ -133,7 +153,7 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
setUserSavedProxy(loadUserSelectedProxy())
|
||||
setProxy(
|
||||
getPreferredProxy(
|
||||
proxiesResp?.regions ?? [],
|
||||
proxiesResp ?? [],
|
||||
loadUserSelectedProxy(),
|
||||
proxyLatencies,
|
||||
// Do not auto select based on latencies, as inconsistent latencies can cause this
|
||||
@@ -161,9 +181,9 @@ export const ProxyProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
: {
|
||||
// If the experiment is disabled, then call 'getPreferredProxy' with the regions from
|
||||
// the api call. The default behavior is to use the `primary` proxy.
|
||||
...getPreferredProxy(proxiesResp?.regions || []),
|
||||
...getPreferredProxy(proxiesResp || []),
|
||||
},
|
||||
proxies: proxiesResp?.regions,
|
||||
proxies: proxiesResp,
|
||||
isLoading: proxiesLoading,
|
||||
isFetched: proxiesFetched,
|
||||
error: proxiesError,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Region, RegionsResponse } from "api/typesGenerated"
|
||||
import { Region } from "api/typesGenerated"
|
||||
import { useEffect, useReducer, useState } from "react"
|
||||
import PerformanceObserver from "@fastly/performance-observer-polyfill"
|
||||
import axios from "axios"
|
||||
@@ -33,11 +33,11 @@ const proxyLatenciesReducer = (
|
||||
}
|
||||
|
||||
export const useProxyLatency = (
|
||||
proxies?: RegionsResponse,
|
||||
proxies?: Region[],
|
||||
): {
|
||||
// Refetch can be called to refetch the proxy latencies.
|
||||
// Until the new values are loaded, the old values will still be used.
|
||||
refetch: () => void
|
||||
refetch: () => Date
|
||||
proxyLatencies: Record<string, ProxyLatencyReport>
|
||||
} => {
|
||||
// maxStoredLatencies is the maximum number of latencies to store per proxy in local storage.
|
||||
@@ -62,7 +62,9 @@ export const useProxyLatency = (
|
||||
new Date().toISOString(),
|
||||
)
|
||||
const refetch = () => {
|
||||
setLatestFetchRequest(new Date().toISOString())
|
||||
const d = new Date()
|
||||
setLatestFetchRequest(d.toISOString())
|
||||
return d
|
||||
}
|
||||
|
||||
// Only run latency updates when the proxies change.
|
||||
@@ -74,7 +76,7 @@ export const useProxyLatency = (
|
||||
// proxyMap is a map of the proxy path_app_url to the proxy object.
|
||||
// This is for the observer to know which requests are important to
|
||||
// record.
|
||||
const proxyChecks = proxies.regions.reduce((acc, proxy) => {
|
||||
const proxyChecks = proxies.reduce((acc, proxy) => {
|
||||
// Only run the latency check on healthy proxies.
|
||||
if (!proxy.healthy) {
|
||||
return acc
|
||||
@@ -216,7 +218,7 @@ const updateStoredLatencies = (action: ProxyLatencyAction): void => {
|
||||
// garbageCollectStoredLatencies will remove any latencies that are older then 1 week or latencies of proxies
|
||||
// that no longer exist. This is intended to keep the size of local storage down.
|
||||
const garbageCollectStoredLatencies = (
|
||||
regions: RegionsResponse,
|
||||
regions: Region[],
|
||||
maxStored: number,
|
||||
): void => {
|
||||
const latencies = loadStoredLatencies()
|
||||
@@ -228,12 +230,12 @@ const garbageCollectStoredLatencies = (
|
||||
|
||||
const cleanupLatencies = (
|
||||
stored: Record<string, ProxyLatencyReport[]>,
|
||||
regions: RegionsResponse,
|
||||
regions: Region[],
|
||||
now: Date,
|
||||
maxStored: number,
|
||||
): Record<string, ProxyLatencyReport[]> => {
|
||||
Object.keys(stored).forEach((proxyID) => {
|
||||
if (!regions.regions.find((region) => region.id === proxyID)) {
|
||||
if (!regions.find((region) => region.id === proxyID)) {
|
||||
delete stored[proxyID]
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,16 +2,13 @@ import { FC, PropsWithChildren } from "react"
|
||||
import { Section } from "components/SettingsLayout/Section"
|
||||
import { WorkspaceProxyView } from "./WorkspaceProxyView"
|
||||
import makeStyles from "@mui/styles/makeStyles"
|
||||
import { displayError } from "components/GlobalSnackbar/utils"
|
||||
import { useProxy } from "contexts/ProxyContext"
|
||||
|
||||
export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
const styles = useStyles()
|
||||
|
||||
const description =
|
||||
"Workspace proxies are used to reduce the latency of connections to your workspaces." +
|
||||
"To get the best experience, choose the workspace proxy that is closest to you." +
|
||||
"This selection only affects browser connections to your workspace."
|
||||
"Workspace proxies are used to reduce the latency of connections to your workspaces."
|
||||
|
||||
const {
|
||||
proxyLatencies,
|
||||
@@ -20,7 +17,6 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
isFetched: proxiesFetched,
|
||||
isLoading: proxiesLoading,
|
||||
proxy,
|
||||
setProxy,
|
||||
} = useProxy()
|
||||
|
||||
return (
|
||||
@@ -37,14 +33,6 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
|
||||
hasLoaded={proxiesFetched}
|
||||
getWorkspaceProxiesError={proxiesError}
|
||||
preferredProxy={proxy.proxy}
|
||||
onSelect={(proxy) => {
|
||||
if (!proxy.healthy) {
|
||||
displayError("Please select a healthy workspace proxy.")
|
||||
return
|
||||
}
|
||||
|
||||
setProxy(proxy)
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
|
||||
@@ -1,83 +1,95 @@
|
||||
import { Region } from "api/typesGenerated"
|
||||
import { Region, WorkspaceProxy } from "api/typesGenerated"
|
||||
import { AvatarData } from "components/AvatarData/AvatarData"
|
||||
import { Avatar } from "components/Avatar/Avatar"
|
||||
import { useClickableTableRow } from "hooks/useClickableTableRow"
|
||||
import TableCell from "@mui/material/TableCell"
|
||||
import TableRow from "@mui/material/TableRow"
|
||||
import { FC } from "react"
|
||||
import {
|
||||
HealthyBadge,
|
||||
NotHealthyBadge,
|
||||
NotReachableBadge,
|
||||
NotRegisteredBadge,
|
||||
} from "components/DeploySettingsLayout/Badges"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import { combineClasses } from "utils/combineClasses"
|
||||
import { ProxyLatencyReport } from "contexts/useProxyLatency"
|
||||
import { getLatencyColor } from "utils/latency"
|
||||
import { alpha } from "@mui/material/styles"
|
||||
|
||||
export const ProxyRow: FC<{
|
||||
latency?: ProxyLatencyReport
|
||||
proxy: Region
|
||||
onSelectRegion: (proxy: Region) => void
|
||||
preferred: boolean
|
||||
}> = ({ proxy, onSelectRegion, preferred, latency }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
const clickable = useClickableTableRow(() => {
|
||||
onSelectRegion(proxy)
|
||||
})
|
||||
}> = ({ proxy, latency }) => {
|
||||
// If we have a more specific proxy status, use that.
|
||||
// All users can see healthy/unhealthy, some can see more.
|
||||
let statusBadge = <ProxyStatus proxy={proxy} />
|
||||
if ("status" in proxy) {
|
||||
statusBadge = <DetailedProxyStatus proxy={proxy as WorkspaceProxy} />
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={proxy.name}
|
||||
data-testid={`${proxy.name}`}
|
||||
{...clickable}
|
||||
// Make sure to include our classname here.
|
||||
className={combineClasses({
|
||||
[clickable.className]: true,
|
||||
[styles.preferredrow]: preferred,
|
||||
})}
|
||||
>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={
|
||||
proxy.display_name && proxy.display_name.length > 0
|
||||
? proxy.display_name
|
||||
: proxy.name
|
||||
}
|
||||
avatar={
|
||||
proxy.icon_url !== "" && (
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={proxy.icon_url}
|
||||
variant="square"
|
||||
fitImage
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<>
|
||||
<TableRow key={proxy.name} data-testid={`${proxy.name}`}>
|
||||
<TableCell>
|
||||
<AvatarData
|
||||
title={
|
||||
proxy.display_name && proxy.display_name.length > 0
|
||||
? proxy.display_name
|
||||
: proxy.name
|
||||
}
|
||||
avatar={
|
||||
proxy.icon_url !== "" && (
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={proxy.icon_url}
|
||||
variant="square"
|
||||
fitImage
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell sx={{ fontSize: 14 }}>{proxy.path_app_url}</TableCell>
|
||||
<TableCell sx={{ fontSize: 14 }}>
|
||||
<ProxyStatus proxy={proxy} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
textAlign: "right",
|
||||
color: (theme) =>
|
||||
latency
|
||||
? getLatencyColor(theme, latency.latencyMS)
|
||||
: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{latency ? `${latency.latencyMS.toFixed(0)} ms` : "Not available"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableCell sx={{ fontSize: 14 }}>{proxy.path_app_url}</TableCell>
|
||||
<TableCell sx={{ fontSize: 14 }}>{statusBadge}</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
textAlign: "right",
|
||||
color: (theme) =>
|
||||
latency
|
||||
? getLatencyColor(theme, latency.latencyMS)
|
||||
: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{latency ? `${latency.latencyMS.toFixed(0)} ms` : "Not available"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// DetailedProxyStatus allows a more precise status to be displayed.
|
||||
const DetailedProxyStatus: FC<{
|
||||
proxy: WorkspaceProxy
|
||||
}> = ({ proxy }) => {
|
||||
if (!proxy.status) {
|
||||
// If the status is null/undefined/not provided, just go with the boolean "healthy" value.
|
||||
return <ProxyStatus proxy={proxy} />
|
||||
}
|
||||
|
||||
switch (proxy.status.status) {
|
||||
case "ok":
|
||||
return <HealthyBadge />
|
||||
case "unhealthy":
|
||||
return <NotHealthyBadge />
|
||||
case "unreachable":
|
||||
return <NotReachableBadge />
|
||||
case "unregistered":
|
||||
return <NotRegisteredBadge />
|
||||
default:
|
||||
return <NotHealthyBadge />
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyStatus will only show "healthy" or "not healthy" status.
|
||||
const ProxyStatus: FC<{
|
||||
proxy: Region
|
||||
}> = ({ proxy }) => {
|
||||
@@ -88,14 +100,3 @@ const ProxyStatus: FC<{
|
||||
|
||||
return icon
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
preferredrow: {
|
||||
backgroundColor: alpha(
|
||||
theme.palette.primary.main,
|
||||
theme.palette.action.hoverOpacity,
|
||||
),
|
||||
outline: `1px solid ${theme.palette.primary.main}`,
|
||||
outlineOffset: "-1px",
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -20,7 +20,6 @@ export interface WorkspaceProxyViewProps {
|
||||
getWorkspaceProxiesError?: Error | unknown
|
||||
isLoading: boolean
|
||||
hasLoaded: boolean
|
||||
onSelect: (proxy: Region) => void
|
||||
preferredProxy?: Region
|
||||
selectProxyError?: Error | unknown
|
||||
}
|
||||
@@ -33,9 +32,7 @@ export const WorkspaceProxyView: FC<
|
||||
getWorkspaceProxiesError,
|
||||
isLoading,
|
||||
hasLoaded,
|
||||
onSelect,
|
||||
selectProxyError,
|
||||
preferredProxy,
|
||||
}) => {
|
||||
return (
|
||||
<Stack>
|
||||
@@ -69,10 +66,6 @@ export const WorkspaceProxyView: FC<
|
||||
latency={proxyLatencies?.[proxy.id]}
|
||||
key={proxy.id}
|
||||
proxy={proxy}
|
||||
onSelectRegion={onSelect}
|
||||
preferred={
|
||||
preferredProxy ? proxy.id === preferredProxy.id : false
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Cond>
|
||||
|
||||
@@ -30,9 +30,6 @@ PrimarySelected.args = {
|
||||
proxies: MockWorkspaceProxies,
|
||||
proxyLatencies: MockProxyLatencies,
|
||||
preferredProxy: MockPrimaryWorkspaceProxy,
|
||||
onSelect: () => {
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
export const Example = Template.bind({})
|
||||
@@ -42,9 +39,6 @@ Example.args = {
|
||||
proxies: MockWorkspaceProxies,
|
||||
proxyLatencies: MockProxyLatencies,
|
||||
preferredProxy: MockHealthyWildWorkspaceProxy,
|
||||
onSelect: () => {
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading = Template.bind({})
|
||||
|
||||
@@ -71,7 +71,7 @@ export const MockTokens: TypesGen.APIKeyWithOwner[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export const MockPrimaryWorkspaceProxy: TypesGen.Region = {
|
||||
export const MockPrimaryWorkspaceProxy: TypesGen.WorkspaceProxy = {
|
||||
id: "4aa23000-526a-481f-a007-0f20b98b1e12",
|
||||
name: "primary",
|
||||
display_name: "Default",
|
||||
@@ -79,9 +79,16 @@ export const MockPrimaryWorkspaceProxy: TypesGen.Region = {
|
||||
healthy: true,
|
||||
path_app_url: "https://coder.com",
|
||||
wildcard_hostname: "*.coder.com",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
deleted: false,
|
||||
status: {
|
||||
status: "ok",
|
||||
checked_at: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
export const MockHealthyWildWorkspaceProxy: TypesGen.Region = {
|
||||
export const MockHealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = {
|
||||
id: "5e2c1ab7-479b-41a9-92ce-aa85625de52c",
|
||||
name: "haswildcard",
|
||||
display_name: "Subdomain Supported",
|
||||
@@ -89,9 +96,16 @@ export const MockHealthyWildWorkspaceProxy: TypesGen.Region = {
|
||||
healthy: true,
|
||||
path_app_url: "https://external.com",
|
||||
wildcard_hostname: "*.external.com",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
deleted: false,
|
||||
status: {
|
||||
status: "ok",
|
||||
checked_at: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
export const MockUnhealthyWildWorkspaceProxy: TypesGen.Region = {
|
||||
export const MockUnhealthyWildWorkspaceProxy: TypesGen.WorkspaceProxy = {
|
||||
id: "8444931c-0247-4171-842a-569d9f9cbadb",
|
||||
name: "unhealthy",
|
||||
display_name: "Unhealthy",
|
||||
@@ -99,9 +113,20 @@ export const MockUnhealthyWildWorkspaceProxy: TypesGen.Region = {
|
||||
healthy: false,
|
||||
path_app_url: "https://unhealthy.coder.com",
|
||||
wildcard_hostname: "*unhealthy..coder.com",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
deleted: false,
|
||||
status: {
|
||||
status: "unhealthy",
|
||||
report: {
|
||||
errors: ["This workspace proxy is manually marked as unhealthy."],
|
||||
warnings: ["This is a manual warning for this workspace proxy."],
|
||||
},
|
||||
checked_at: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
export const MockWorkspaceProxies: TypesGen.Region[] = [
|
||||
export const MockWorkspaceProxies: TypesGen.WorkspaceProxy[] = [
|
||||
MockPrimaryWorkspaceProxy,
|
||||
MockHealthyWildWorkspaceProxy,
|
||||
MockUnhealthyWildWorkspaceProxy,
|
||||
@@ -113,6 +138,13 @@ export const MockWorkspaceProxies: TypesGen.Region[] = [
|
||||
healthy: true,
|
||||
path_app_url: "https://cowboy.coder.com",
|
||||
wildcard_hostname: "",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
deleted: false,
|
||||
status: {
|
||||
status: "ok",
|
||||
checked_at: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1625,6 +1657,7 @@ export const MockPermissions: Permissions = {
|
||||
viewUpdateCheck: true,
|
||||
viewDeploymentStats: true,
|
||||
viewGitAuthConfig: true,
|
||||
editWorkspaceProxies: true,
|
||||
}
|
||||
|
||||
export const MockDeploymentConfig: Types.DeploymentConfig = {
|
||||
|
||||
@@ -24,6 +24,14 @@ export const handlers = [
|
||||
}),
|
||||
)
|
||||
}),
|
||||
rest.get("/api/v2/workspaceproxies", async (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
regions: M.MockWorkspaceProxies,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
// build info
|
||||
rest.get("/api/v2/buildinfo", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockBuildInfo))
|
||||
|
||||
@@ -19,6 +19,7 @@ export const checks = {
|
||||
viewUpdateCheck: "viewUpdateCheck",
|
||||
viewGitAuthConfig: "viewGitAuthConfig",
|
||||
viewDeploymentStats: "viewDeploymentStats",
|
||||
editWorkspaceProxies: "editWorkspaceProxies",
|
||||
} as const
|
||||
|
||||
export const permissionsToCheck = {
|
||||
@@ -88,6 +89,12 @@ export const permissionsToCheck = {
|
||||
},
|
||||
action: "read",
|
||||
},
|
||||
[checks.editWorkspaceProxies]: {
|
||||
object: {
|
||||
resource_type: "workspace_proxy",
|
||||
},
|
||||
action: "create",
|
||||
},
|
||||
} as const
|
||||
|
||||
export type Permissions = Record<keyof typeof permissionsToCheck, boolean>
|
||||
|
||||
Reference in New Issue
Block a user