mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add latency to the terminal (#7801)
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { Route, Routes } from "react-router-dom"
|
||||
import { renderWithAuth } from "testHelpers/renderHelpers"
|
||||
import { DashboardLayout } from "./DashboardLayout"
|
||||
import * as API from "api/api"
|
||||
@@ -10,12 +9,8 @@ test("Show the new Coder version notification", async () => {
|
||||
version: "v0.12.9",
|
||||
url: "https://github.com/coder/coder/releases/tag/v0.12.9",
|
||||
})
|
||||
renderWithAuth(
|
||||
<Routes>
|
||||
<Route element={<DashboardLayout />}>
|
||||
<Route element={<h1>Test page</h1>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
)
|
||||
renderWithAuth(<DashboardLayout />, {
|
||||
children: [{ element: <h1>Test page</h1> }],
|
||||
})
|
||||
await screen.findByTestId("update-check-snackbar")
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import Drawer from "@mui/material/Drawer"
|
||||
import IconButton from "@mui/material/IconButton"
|
||||
import List from "@mui/material/List"
|
||||
import ListItem from "@mui/material/ListItem"
|
||||
import { makeStyles, useTheme } from "@mui/styles"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import MenuIcon from "@mui/icons-material/Menu"
|
||||
import { CoderIcon } from "components/Icons/CoderIcon"
|
||||
import { FC, useRef, useState } from "react"
|
||||
@@ -20,10 +20,9 @@ import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutl
|
||||
import { ProxyContextValue } from "contexts/ProxyContext"
|
||||
import { displayError } from "components/GlobalSnackbar/utils"
|
||||
import Divider from "@mui/material/Divider"
|
||||
import HelpOutline from "@mui/icons-material/HelpOutline"
|
||||
import Tooltip from "@mui/material/Tooltip"
|
||||
import Skeleton from "@mui/material/Skeleton"
|
||||
import { BUTTON_SM_HEIGHT } from "theme/theme"
|
||||
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency"
|
||||
|
||||
export const USERS_LINK = `/users?filter=${encodeURIComponent("status:active")}`
|
||||
|
||||
@@ -232,7 +231,6 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
||||
</Box>
|
||||
{selectedProxy.display_name}
|
||||
<ProxyStatusLatency
|
||||
proxy={selectedProxy}
|
||||
latency={latencies?.[selectedProxy.id]?.latencyMS}
|
||||
/>
|
||||
</Box>
|
||||
@@ -277,10 +275,7 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
||||
/>
|
||||
</Box>
|
||||
{proxy.display_name}
|
||||
<ProxyStatusLatency
|
||||
proxy={proxy}
|
||||
latency={latencies?.[proxy.id]?.latencyMS}
|
||||
/>
|
||||
<ProxyStatusLatency latency={latencies?.[proxy.id]?.latencyMS} />
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
@@ -301,42 +296,6 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const ProxyStatusLatency: FC<{ proxy: TypesGen.Region; latency?: number }> = ({
|
||||
proxy,
|
||||
latency,
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
let color = theme.palette.success.light
|
||||
|
||||
if (!latency) {
|
||||
return (
|
||||
<Tooltip title="Latency not available">
|
||||
<HelpOutline
|
||||
sx={{
|
||||
ml: "auto",
|
||||
fontSize: "14px !important",
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
if (latency >= 300) {
|
||||
color = theme.palette.error.light
|
||||
}
|
||||
|
||||
if (!proxy.healthy || latency >= 100) {
|
||||
color = theme.palette.warning.light
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ color, fontSize: 13, marginLeft: "auto" }}>
|
||||
{latency.toFixed(0)}ms
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
height: navHeight,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useTheme } from "@mui/material/styles"
|
||||
import HelpOutline from "@mui/icons-material/HelpOutline"
|
||||
import Box from "@mui/material/Box"
|
||||
import Tooltip from "@mui/material/Tooltip"
|
||||
import { FC } from "react"
|
||||
import { getLatencyColor } from "utils/latency"
|
||||
|
||||
export const ProxyStatusLatency: FC<{ latency?: number }> = ({ latency }) => {
|
||||
const theme = useTheme()
|
||||
const color = getLatencyColor(theme, latency)
|
||||
|
||||
if (!latency) {
|
||||
return (
|
||||
<Tooltip title="Latency not available">
|
||||
<HelpOutline
|
||||
sx={{
|
||||
ml: "auto",
|
||||
fontSize: "14px !important",
|
||||
color,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ color, fontSize: 13, marginLeft: "auto" }}>
|
||||
{latency.toFixed(0)}ms
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "components/Tooltips/HelpTooltip"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { WorkspaceAgent, DERPRegion } from "api/typesGenerated"
|
||||
import { getLatencyColor } from "utils/colors"
|
||||
import { getLatencyColor } from "utils/latency"
|
||||
|
||||
const getDisplayLatency = (theme: Theme, agent: WorkspaceAgent) => {
|
||||
// Find the right latency to display
|
||||
|
||||
@@ -3,19 +3,18 @@ import "jest-canvas-mock"
|
||||
import WS from "jest-websocket-mock"
|
||||
import { rest } from "msw"
|
||||
import {
|
||||
MockPrimaryWorkspaceProxy,
|
||||
MockProxyLatencies,
|
||||
MockUser,
|
||||
MockWorkspace,
|
||||
MockWorkspaceAgent,
|
||||
MockWorkspaceProxies,
|
||||
} from "testHelpers/entities"
|
||||
import { TextDecoder, TextEncoder } from "util"
|
||||
import { ReconnectingPTYRequest } from "../../api/types"
|
||||
import { history, render } from "../../testHelpers/renderHelpers"
|
||||
import {
|
||||
renderWithAuth,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "../../testHelpers/renderHelpers"
|
||||
import { server } from "../../testHelpers/server"
|
||||
import TerminalPage, { Language } from "./TerminalPage"
|
||||
import { Route, Routes } from "react-router-dom"
|
||||
import { ProxyContext } from "contexts/ProxyContext"
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
@@ -35,56 +34,35 @@ Object.defineProperty(window, "TextEncoder", {
|
||||
value: TextEncoder,
|
||||
})
|
||||
|
||||
const renderTerminal = () => {
|
||||
// @emyrk using renderWithAuth would be best here, but I was unable to get it to work.
|
||||
return render(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:username/:workspace/terminal"
|
||||
element={
|
||||
<ProxyContext.Provider
|
||||
value={{
|
||||
proxyLatencies: MockProxyLatencies,
|
||||
proxy: {
|
||||
proxy: MockPrimaryWorkspaceProxy,
|
||||
preferredPathAppURL: "",
|
||||
preferredWildcardHostname: "",
|
||||
},
|
||||
proxies: MockWorkspaceProxies,
|
||||
isFetched: true,
|
||||
isLoading: false,
|
||||
setProxy: jest.fn(),
|
||||
clearProxy: jest.fn(),
|
||||
refetchProxyLatencies: jest.fn(),
|
||||
}}
|
||||
>
|
||||
<TerminalPage />
|
||||
</ProxyContext.Provider>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
)
|
||||
const renderTerminal = async (
|
||||
route = `/${MockUser.username}/${MockWorkspace.name}/terminal`,
|
||||
) => {
|
||||
const utils = renderWithAuth(<TerminalPage />, {
|
||||
route,
|
||||
path: "/:username/:workspace/terminal",
|
||||
})
|
||||
await waitForLoaderToBeRemoved()
|
||||
return utils
|
||||
}
|
||||
|
||||
const expectTerminalText = (container: HTMLElement, text: string) => {
|
||||
return waitFor(() => {
|
||||
const elements = container.getElementsByClassName("xterm-rows")
|
||||
if (elements.length === 0) {
|
||||
throw new Error("no xterm-rows")
|
||||
}
|
||||
const row = elements[0] as HTMLDivElement
|
||||
if (!row.textContent) {
|
||||
throw new Error("no text content")
|
||||
}
|
||||
expect(row.textContent).toContain(text)
|
||||
})
|
||||
return waitFor(
|
||||
() => {
|
||||
const elements = container.getElementsByClassName("xterm-rows")
|
||||
if (elements.length === 0) {
|
||||
throw new Error("no xterm-rows")
|
||||
}
|
||||
const row = elements[0] as HTMLDivElement
|
||||
if (!row.textContent) {
|
||||
throw new Error("no text content")
|
||||
}
|
||||
expect(row.textContent).toContain(text)
|
||||
},
|
||||
{ timeout: 3_000 },
|
||||
)
|
||||
}
|
||||
|
||||
describe("TerminalPage", () => {
|
||||
beforeEach(() => {
|
||||
history.push(`/some-user/${MockWorkspace.name}/terminal`)
|
||||
})
|
||||
|
||||
it("shows an error if fetching workspace fails", async () => {
|
||||
// Given
|
||||
server.use(
|
||||
@@ -97,7 +75,7 @@ describe("TerminalPage", () => {
|
||||
)
|
||||
|
||||
// When
|
||||
const { container } = renderTerminal()
|
||||
const { container } = await renderTerminal()
|
||||
|
||||
// Then
|
||||
await expectTerminalText(container, Language.workspaceErrorMessagePrefix)
|
||||
@@ -112,7 +90,7 @@ describe("TerminalPage", () => {
|
||||
)
|
||||
|
||||
// When
|
||||
const { container } = renderTerminal()
|
||||
const { container } = await renderTerminal()
|
||||
|
||||
// Then
|
||||
await expectTerminalText(container, Language.websocketErrorMessagePrefix)
|
||||
@@ -120,59 +98,58 @@ describe("TerminalPage", () => {
|
||||
|
||||
it("renders data from the backend", async () => {
|
||||
// Given
|
||||
const server = new WS(
|
||||
"ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty",
|
||||
const ws = new WS(
|
||||
`ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty`,
|
||||
)
|
||||
const text = "something to render"
|
||||
|
||||
// When
|
||||
const { container } = renderTerminal()
|
||||
const { container } = await renderTerminal()
|
||||
|
||||
// Then
|
||||
await server.connected
|
||||
server.send(text)
|
||||
await ws.connected
|
||||
ws.send(text)
|
||||
await expectTerminalText(container, text)
|
||||
server.close()
|
||||
ws.close()
|
||||
})
|
||||
|
||||
it("resizes on connect", async () => {
|
||||
// Given
|
||||
const server = new WS(
|
||||
"ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty",
|
||||
const ws = new WS(
|
||||
`ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty`,
|
||||
)
|
||||
|
||||
// When
|
||||
renderTerminal()
|
||||
await renderTerminal()
|
||||
|
||||
// Then
|
||||
await server.connected
|
||||
const msg = await server.nextMessage
|
||||
await ws.connected
|
||||
const msg = await ws.nextMessage
|
||||
const req: ReconnectingPTYRequest = JSON.parse(
|
||||
new TextDecoder().decode(msg as Uint8Array),
|
||||
)
|
||||
|
||||
expect(req.height).toBeGreaterThan(0)
|
||||
expect(req.width).toBeGreaterThan(0)
|
||||
server.close()
|
||||
ws.close()
|
||||
})
|
||||
|
||||
it("supports workspace.agent syntax", async () => {
|
||||
// Given
|
||||
const server = new WS(
|
||||
"ws://localhost/api/v2/workspaceagents/" + MockWorkspaceAgent.id + "/pty",
|
||||
const ws = new WS(
|
||||
`ws://localhost/api/v2/workspaceagents/${MockWorkspaceAgent.id}/pty`,
|
||||
)
|
||||
const text = "something to render"
|
||||
|
||||
// When
|
||||
history.push(
|
||||
const { container } = await renderTerminal(
|
||||
`/some-user/${MockWorkspace.name}.${MockWorkspaceAgent.name}/terminal`,
|
||||
)
|
||||
const { container } = renderTerminal()
|
||||
|
||||
// Then
|
||||
await server.connected
|
||||
server.send(text)
|
||||
await ws.connected
|
||||
ws.send(text)
|
||||
await expectTerminalText(container, text)
|
||||
server.close()
|
||||
ws.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Button from "@mui/material/Button"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import { makeStyles, useTheme } from "@mui/styles"
|
||||
import WarningIcon from "@mui/icons-material/ErrorOutlineRounded"
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined"
|
||||
import { useMachine } from "@xstate/react"
|
||||
@@ -20,6 +20,11 @@ import { terminalMachine } from "../../xServices/terminal/terminalXService"
|
||||
import { useProxy } from "contexts/ProxyContext"
|
||||
import { combineClasses } from "utils/combineClasses"
|
||||
import Box from "@mui/material/Box"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { Region } from "api/typesGenerated"
|
||||
import { getLatencyColor } from "utils/latency"
|
||||
import Popover from "@mui/material/Popover"
|
||||
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency"
|
||||
|
||||
export const Language = {
|
||||
workspaceErrorMessagePrefix: "Unable to fetch workspace: ",
|
||||
@@ -81,6 +86,12 @@ const TerminalPage: FC = () => {
|
||||
const shouldDisplayStartupError = workspaceAgent
|
||||
? workspaceAgent.lifecycle_state === "start_error"
|
||||
: false
|
||||
const dashboard = useDashboard()
|
||||
const proxyContext = useProxy()
|
||||
const selectedProxy = proxyContext.proxy.proxy
|
||||
const latency = selectedProxy
|
||||
? proxyContext.proxyLatencies[selectedProxy.id]
|
||||
: undefined
|
||||
|
||||
// handleWebLink handles opening of URLs in the terminal!
|
||||
const handleWebLink = useCallback(
|
||||
@@ -342,11 +353,114 @@ const TerminalPage: FC = () => {
|
||||
ref={xtermRef}
|
||||
data-testid="terminal"
|
||||
/>
|
||||
{dashboard.experiments.includes("moons") &&
|
||||
selectedProxy &&
|
||||
latency && (
|
||||
<BottomBar proxy={selectedProxy} latency={latency.latencyMS} />
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const BottomBar = ({ proxy, latency }: { proxy: Region; latency?: number }) => {
|
||||
const theme = useTheme()
|
||||
const color = getLatencyColor(theme, latency)
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
padding: (theme) => theme.spacing(2),
|
||||
background: (theme) => theme.palette.background.paper,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={anchorRef}
|
||||
component="button"
|
||||
aria-label="Terminal latency"
|
||||
aria-haspopup="true"
|
||||
onMouseEnter={() => setIsOpen(true)}
|
||||
onMouseLeave={() => setIsOpen(false)}
|
||||
sx={{
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
height: 6,
|
||||
width: 6,
|
||||
backgroundColor: color,
|
||||
border: 0,
|
||||
borderRadius: 9999,
|
||||
}}
|
||||
/>
|
||||
<ProxyStatusLatency latency={latency} />
|
||||
</Box>
|
||||
<Popover
|
||||
id="latency-popover"
|
||||
disableRestoreFocus
|
||||
anchorEl={anchorRef.current}
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
sx={{
|
||||
pointerEvents: "none",
|
||||
"& .MuiPaper-root": {
|
||||
padding: (theme) => theme.spacing(1, 2),
|
||||
marginTop: -1,
|
||||
},
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "right",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
color: (theme) => theme.palette.text.secondary,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Selected proxy
|
||||
</Box>
|
||||
<Box
|
||||
sx={{ fontSize: 14, display: "flex", gap: 3, alignItems: "center" }}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Box width={12} height={12} lineHeight={0}>
|
||||
<Box
|
||||
component="img"
|
||||
src={proxy.icon_url}
|
||||
alt=""
|
||||
sx={{ objectFit: "contain" }}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</Box>
|
||||
{proxy.display_name}
|
||||
</Box>
|
||||
<ProxyStatusLatency latency={latency} />
|
||||
</Box>
|
||||
</Popover>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const useReloading = (isDisconnected: boolean) => {
|
||||
const [status, setStatus] = useState<"reloading" | "notReloading">(
|
||||
"notReloading",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import { combineClasses } from "utils/combineClasses"
|
||||
import { ProxyLatencyReport } from "contexts/useProxyLatency"
|
||||
import { getLatencyColor } from "utils/colors"
|
||||
import { getLatencyColor } from "utils/latency"
|
||||
import { alpha } from "@mui/material/styles"
|
||||
|
||||
export const ProxyRow: FC<{
|
||||
|
||||
@@ -47,6 +47,8 @@ type RenderWithAuthOptions = {
|
||||
extraRoutes?: RouteObject[]
|
||||
// The same as extraRoutes but for routes that don't require authentication
|
||||
nonAuthenticatedRoutes?: RouteObject[]
|
||||
// In case you want to render a layout inside of it
|
||||
children?: RouteObject["children"]
|
||||
}
|
||||
|
||||
export function renderWithAuth(
|
||||
@@ -56,17 +58,13 @@ export function renderWithAuth(
|
||||
route = "/",
|
||||
extraRoutes = [],
|
||||
nonAuthenticatedRoutes = [],
|
||||
children,
|
||||
}: RenderWithAuthOptions = {},
|
||||
) {
|
||||
const routes: RouteObject[] = [
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{
|
||||
element: <DashboardLayout />,
|
||||
children: [{ path, element }, ...extraRoutes],
|
||||
},
|
||||
],
|
||||
children: [{ path, element, children }, ...extraRoutes],
|
||||
},
|
||||
...nonAuthenticatedRoutes,
|
||||
]
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Theme } from "@mui/material/styles"
|
||||
|
||||
// Used to convert our theme colors to Hex since monaco theme only support hex colors
|
||||
// From https://www.jameslmilner.com/posts/converting-rgb-hex-hsl-colors/
|
||||
export function hslToHex(hsl: string): string {
|
||||
@@ -23,15 +21,3 @@ export function hslToHex(hsl: string): string {
|
||||
}
|
||||
return `#${f(0)}${f(8)}${f(4)}`
|
||||
}
|
||||
|
||||
// getLatencyColor is the text color to use for a given latency
|
||||
// in milliseconds.
|
||||
export const getLatencyColor = (theme: Theme, latency: number) => {
|
||||
let color = theme.palette.success.light
|
||||
if (latency >= 150 && latency < 300) {
|
||||
color = theme.palette.warning.light
|
||||
} else if (latency >= 300) {
|
||||
color = theme.palette.error.light
|
||||
}
|
||||
return color
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Theme } from "@mui/material/styles"
|
||||
|
||||
export const getLatencyColor = (theme: Theme, latency?: number) => {
|
||||
if (!latency) {
|
||||
return theme.palette.text.secondary
|
||||
}
|
||||
|
||||
let color = theme.palette.success.light
|
||||
|
||||
if (latency >= 150 && latency < 300) {
|
||||
color = theme.palette.warning.light
|
||||
} else if (latency >= 300) {
|
||||
color = theme.palette.error.light
|
||||
}
|
||||
return color
|
||||
}
|
||||
Reference in New Issue
Block a user