feat(site): add latency to the terminal (#7801)

This commit is contained in:
Bruno Quaresma
2023-06-05 18:32:49 +00:00
committed by GitHub
parent 0413ed0178
commit 7ec16cf779
10 changed files with 222 additions and 146 deletions
@@ -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")
})
+3 -44
View File
@@ -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()
})
})
+115 -1
View File
@@ -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<{
+4 -6
View File
@@ -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,
]
-14
View File
@@ -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
}
+16
View File
@@ -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
}