Skip to content
36 changes: 2 additions & 34 deletions site/src/components/CopyButton/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import IconButton from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import Tooltip from "@material-ui/core/Tooltip"
import Check from "@material-ui/icons/Check"
import React, { useState } from "react"
import { useClipboard } from "hooks/useClipboard"
import { combineClasses } from "../../util/combineClasses"
import { FileCopyIcon } from "../Icons/FileCopyIcon"

Expand Down Expand Up @@ -30,39 +30,7 @@ export const CopyButton: React.FC<React.PropsWithChildren<CopyButtonProps>> = ({
tooltipTitle = Language.tooltipTitle,
}) => {
const styles = useStyles()
const [isCopied, setIsCopied] = useState<boolean>(false)

const copyToClipboard = async (): Promise<void> => {
try {
await window.navigator.clipboard.writeText(text)
setIsCopied(true)
window.setTimeout(() => {
setIsCopied(false)
}, 1000)
} catch (err) {
const input = document.createElement("input")
input.value = text
document.body.appendChild(input)
input.focus()
input.select()
const result = document.execCommand("copy")
document.body.removeChild(input)
if (result) {
setIsCopied(true)
window.setTimeout(() => {
setIsCopied(false)
}, 1000)
} else {
const wrappedErr = new Error(
"copyToClipboard: failed to copy text to clipboard",
)
if (err instanceof Error) {
wrappedErr.stack = err.stack
}
console.error(wrappedErr)
}
}
}
const { isCopied, copy: copyToClipboard } = useClipboard(text)

return (
<Tooltip title={tooltipTitle} placement="top">
Expand Down
39 changes: 39 additions & 0 deletions site/src/components/CopyableValue/CopyableValue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { makeStyles } from "@material-ui/core/styles"
import Tooltip from "@material-ui/core/Tooltip"
import { useClickable } from "hooks/useClickable"
import { useClipboard } from "hooks/useClipboard"
import React, { HTMLProps } from "react"
import { combineClasses } from "util/combineClasses"

interface CopyableValueProps extends HTMLProps<HTMLDivElement> {
value: string
}

export const CopyableValue: React.FC<CopyableValueProps> = ({
value,
className,
...props
}) => {
const { isCopied, copy } = useClipboard(value)
const clickableProps = useClickable(copy)
const styles = useStyles()

return (
<Tooltip
title={isCopied ? "Copied!" : "Click to copy"}
placement="bottom-start"
>
<span
{...props}
{...clickableProps}
className={combineClasses([styles.value, className])}
/>
</Tooltip>
)
}

const useStyles = makeStyles(() => ({
value: {
cursor: "pointer",
},
}))
7 changes: 5 additions & 2 deletions site/src/components/PageHeader/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ export const PageHeader: React.FC<React.PropsWithChildren<PageHeaderProps>> = ({
const styles = useStyles({})

return (
<div className={combineClasses([styles.root, className])}>
<header
className={combineClasses([styles.root, className])}
data-testid="header"
>
<hgroup>{children}</hgroup>
{actions && (
<Stack direction="row" className={styles.actions}>
{actions}
</Stack>
)}
</div>
</header>
)
}

Expand Down
114 changes: 114 additions & 0 deletions site/src/components/Resources/AgentLatency.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useRef, useState, FC } from "react"
import { makeStyles, Theme, useTheme } from "@material-ui/core/styles"
import {
HelpTooltipText,
HelpPopover,
HelpTooltipTitle,
} from "components/Tooltips/HelpTooltip"
import { Stack } from "components/Stack/Stack"
import { WorkspaceAgent, DERPRegion } from "api/typesGenerated"

const getDisplayLatency = (theme: Theme, agent: WorkspaceAgent) => {
// Find the right latency to display
const latencyValues = Object.values(agent.latency ?? {})
const latency =
latencyValues.find((derp) => derp.preferred) ??
// Accessing an array index can return undefined as well
// for some reason TS does not handle that
(latencyValues[0] as DERPRegion | undefined)

if (!latency) {
return undefined
}

// Get the color
let color = theme.palette.success.light
if (latency.latency_ms >= 150 && latency.latency_ms < 300) {
color = theme.palette.warning.light
} else if (latency.latency_ms >= 300) {
color = theme.palette.error.light
}

return {
...latency,
color,
}
}

export const AgentLatency: FC<{ agent: WorkspaceAgent }> = ({ agent }) => {
const theme: Theme = useTheme()
const anchorRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const id = isOpen ? "latency-popover" : undefined
const latency = getDisplayLatency(theme, agent)
const styles = useStyles()

if (!latency || !agent.latency) {
return null
}

return (
<>
<span
role="presentation"
aria-label="latency"
ref={anchorRef}
onMouseEnter={() => setIsOpen(true)}
className={styles.trigger}
style={{ color: latency.color }}
>
{Math.round(Math.round(latency.latency_ms))}ms
</span>
<HelpPopover
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
>
<HelpTooltipTitle>Latency</HelpTooltipTitle>
<HelpTooltipText>
Latency from relay servers, used when connections cannot connect
peer-to-peer. Star indicates the preferred relay.
</HelpTooltipText>

<HelpTooltipText>
<Stack direction="column" spacing={1} className={styles.regions}>
{Object.keys(agent.latency).map((regionName) => {
if (!agent.latency) {
throw new Error("No latency found on agent")
}

const region = agent.latency[regionName]

return (
<Stack
direction="row"
key={regionName}
spacing={0.5}
justifyContent="space-between"
className={region.preferred ? styles.preferred : undefined}
>
<strong>{regionName}</strong>
{Math.round(region.latency_ms)}ms
</Stack>
)
})}
</Stack>
</HelpTooltipText>
</HelpPopover>
</>
)
}

const useStyles = makeStyles((theme) => ({
trigger: {
cursor: "pointer",
},
regions: {
marginTop: theme.spacing(2),
},
preferred: {
color: theme.palette.text.primary,
},
}))
100 changes: 100 additions & 0 deletions site/src/components/Resources/AgentStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Tooltip from "@material-ui/core/Tooltip"
import { makeStyles } from "@material-ui/core/styles"
import { combineClasses } from "util/combineClasses"
import { WorkspaceAgent } from "api/typesGenerated"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { useTranslation } from "react-i18next"

const ConnectedStatus: React.FC = () => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")

return (
<Tooltip title={t("agentStatus.connected")}>
<div
role="status"
aria-label={t("agentStatus.connected")}
className={combineClasses([styles.status, styles.connected])}
/>
</Tooltip>
)
}

const DisconnectedStatus: React.FC = () => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")

return (
<Tooltip title={t("agentStatus.disconnected")}>
<div
role="status"
aria-label={t("agentStatus.disconnected")}
className={combineClasses([styles.status, styles.disconnected])}
/>
</Tooltip>
)
}

const ConnectingStatus: React.FC = () => {
const styles = useStyles()
const { t } = useTranslation("workspacePage")

return (
<Tooltip title={t("agentStatus.connecting")}>
<div
role="status"
aria-label={t("agentStatus.connecting")}
className={combineClasses([styles.status, styles.connecting])}
/>
</Tooltip>
)
}

export const AgentStatus: React.FC<{ agent: WorkspaceAgent }> = ({ agent }) => {
return (
<ChooseOne>
<Cond condition={agent.status === "connected"}>
<ConnectedStatus />
</Cond>
<Cond condition={agent.status === "disconnected"}>
<DisconnectedStatus />
</Cond>
<Cond>
<ConnectingStatus />
</Cond>
</ChooseOne>
)
}

const useStyles = makeStyles((theme) => ({
status: {
width: theme.spacing(1),
height: theme.spacing(1),
borderRadius: "100%",
},

connected: {
backgroundColor: theme.palette.success.light,
},

disconnected: {
backgroundColor: theme.palette.text.secondary,
},

"@keyframes pulse": {
"0%": {
opacity: 0.25,
},
"50%": {
opacity: 1,
},
"100%": {
opacity: 0.25,
},
},

connecting: {
backgroundColor: theme.palette.info.light,
animation: "$pulse 1s ease-in-out forwards infinite",
},
}))
61 changes: 61 additions & 0 deletions site/src/components/Resources/AgentVersion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useRef, useState, FC } from "react"
import { makeStyles } from "@material-ui/core/styles"
import {
HelpTooltipText,
HelpPopover,
HelpTooltipTitle,
} from "components/Tooltips/HelpTooltip"
import { WorkspaceAgent } from "api/typesGenerated"
import { getDisplayVersionStatus } from "util/workspace"

export const AgentVersion: FC<{
agent: WorkspaceAgent
serverVersion: string
}> = ({ agent, serverVersion }) => {
const styles = useStyles()
const anchorRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const id = isOpen ? "version-outdated-popover" : undefined
const { displayVersion, outdated } = getDisplayVersionStatus(
agent.version,
serverVersion,
)

if (!outdated) {
return <span>{displayVersion}</span>
}

return (
<>
<span
role="presentation"
aria-label="latency"
ref={anchorRef}
onMouseEnter={() => setIsOpen(true)}
className={styles.trigger}
>
Agent Outdated
</span>
<HelpPopover
id={id}
open={isOpen}
anchorEl={anchorRef.current}
onOpen={() => setIsOpen(true)}
onClose={() => setIsOpen(false)}
>
<HelpTooltipTitle>Agent Outdated</HelpTooltipTitle>
<HelpTooltipText>
This agent is an older version than the Coder server. This can happen
after you update Coder with running workspaces. To fix this, you can
stop and start the workspace.
</HelpTooltipText>
</HelpPopover>
</>
)
}

const useStyles = makeStyles(() => ({
trigger: {
cursor: "pointer",
},
}))
Loading