feat: Redesign resources table (#4600)

This commit is contained in:
Bruno Quaresma
2022-10-18 10:44:58 -03:00
committed by GitHub
parent 61683f1961
commit 616fe7a3b1
19 changed files with 904 additions and 374 deletions

View File

@ -2,7 +2,7 @@ import IconButton from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import Tooltip from "@material-ui/core/Tooltip" import Tooltip from "@material-ui/core/Tooltip"
import Check from "@material-ui/icons/Check" import Check from "@material-ui/icons/Check"
import React, { useState } from "react" import { useClipboard } from "hooks/useClipboard"
import { combineClasses } from "../../util/combineClasses" import { combineClasses } from "../../util/combineClasses"
import { FileCopyIcon } from "../Icons/FileCopyIcon" import { FileCopyIcon } from "../Icons/FileCopyIcon"
@ -30,39 +30,7 @@ export const CopyButton: React.FC<React.PropsWithChildren<CopyButtonProps>> = ({
tooltipTitle = Language.tooltipTitle, tooltipTitle = Language.tooltipTitle,
}) => { }) => {
const styles = useStyles() const styles = useStyles()
const [isCopied, setIsCopied] = useState<boolean>(false) const { isCopied, copy: copyToClipboard } = useClipboard(text)
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)
}
}
}
return ( return (
<Tooltip title={tooltipTitle} placement="top"> <Tooltip title={tooltipTitle} placement="top">

View File

@ -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",
},
}))

View File

@ -15,14 +15,17 @@ export const PageHeader: React.FC<React.PropsWithChildren<PageHeaderProps>> = ({
const styles = useStyles({}) const styles = useStyles({})
return ( return (
<div className={combineClasses([styles.root, className])}> <header
className={combineClasses([styles.root, className])}
data-testid="header"
>
<hgroup>{children}</hgroup> <hgroup>{children}</hgroup>
{actions && ( {actions && (
<Stack direction="row" className={styles.actions}> <Stack direction="row" className={styles.actions}>
{actions} {actions}
</Stack> </Stack>
)} )}
</div> </header>
) )
} }

View File

@ -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,
},
}))

View File

@ -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",
},
}))

View File

@ -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",
},
}))

View File

@ -0,0 +1,81 @@
import { Story } from "@storybook/react"
import {
MockWorkspace,
MockWorkspaceAgent,
MockWorkspaceResource,
} from "testHelpers/entities"
import { ResourceCard, ResourceCardProps } from "./ResourceCard"
export default {
title: "components/ResourceCard",
component: ResourceCard,
}
const Template: Story<ResourceCardProps> = (args) => <ResourceCard {...args} />
export const Example = Template.bind({})
Example.args = {
resource: MockWorkspaceResource,
workspace: MockWorkspace,
applicationsHost: "https://dev.coder.com",
hideSSHButton: false,
showApps: true,
serverVersion: MockWorkspaceAgent.version,
}
export const NotShowingApps = Template.bind({})
NotShowingApps.args = {
...Example.args,
showApps: false,
}
export const HideSSHButton = Template.bind({})
HideSSHButton.args = {
...Example.args,
hideSSHButton: true,
}
export const BunchOfMetadata = Template.bind({})
BunchOfMetadata.args = {
...Example.args,
resource: {
...MockWorkspaceResource,
metadata: [
{ key: "type", value: "kubernetes_pod", sensitive: false },
{
key: "CPU(limits, requests)",
value: "2 cores, 500m",
sensitive: false,
},
{ key: "container image pull policy", value: "Always", sensitive: false },
{ key: "Disk", value: "10GiB", sensitive: false },
{
key: "image",
value: "docker.io/markmilligan/pycharm-community:latest",
sensitive: false,
},
{ key: "kubernetes namespace", value: "oss", sensitive: false },
{
key: "memory(limits, requests)",
value: "4GB, 500mi",
sensitive: false,
},
{
key: "security context - container",
value: "run_as_user 1000",
sensitive: false,
},
{
key: "security context - pod",
value: "run_as_user 1000 fs_group 1000",
sensitive: false,
},
{ key: "volume", value: "/home/coder", sensitive: false },
{
key: "secret",
value: "3XqfNW0b1bvsGsqud8O6OW6VabH3fwzI",
sensitive: true,
},
],
},
}

View File

@ -0,0 +1,273 @@
import { makeStyles } from "@material-ui/core/styles"
import { Skeleton } from "@material-ui/lab"
import { PortForwardButton } from "components/PortForwardButton/PortForwardButton"
import { FC, useState } from "react"
import { Workspace, WorkspaceResource } from "../../api/typesGenerated"
import { AppLink } from "../AppLink/AppLink"
import { SSHButton } from "../SSHButton/SSHButton"
import { Stack } from "../Stack/Stack"
import { TerminalLink } from "../TerminalLink/TerminalLink"
import { ResourceAvatar } from "./ResourceAvatar"
import { SensitiveValue } from "./SensitiveValue"
import { AgentLatency } from "./AgentLatency"
import { AgentVersion } from "./AgentVersion"
import {
OpenDropdown,
CloseDropdown,
} from "components/DropdownArrows/DropdownArrows"
import IconButton from "@material-ui/core/IconButton"
import Tooltip from "@material-ui/core/Tooltip"
import { Maybe } from "components/Conditionals/Maybe"
import { CopyableValue } from "components/CopyableValue/CopyableValue"
import { AgentStatus } from "./AgentStatus"
export interface ResourceCardProps {
resource: WorkspaceResource
workspace: Workspace
applicationsHost: string | undefined
showApps: boolean
hideSSHButton?: boolean
serverVersion: string
}
export const ResourceCard: FC<ResourceCardProps> = ({
resource,
workspace,
applicationsHost,
showApps,
hideSSHButton,
serverVersion,
}) => {
const [shouldDisplayAllMetadata, setShouldDisplayAllMetadata] =
useState(false)
const styles = useStyles()
const metadataToDisplay =
// Type is already displayed in the header
resource.metadata?.filter((data) => data.key !== "type") ?? []
const visibleMetadata = shouldDisplayAllMetadata
? metadataToDisplay
: metadataToDisplay.slice(0, 4)
return (
<div key={resource.id} className={styles.resourceCard}>
<Stack
direction="row"
alignItems="flex-start"
className={styles.resourceCardHeader}
spacing={10}
>
<Stack
direction="row"
alignItems="center"
className={styles.resourceCardProfile}
>
<div>
<ResourceAvatar resource={resource} />
</div>
<div className={styles.metadata}>
<div className={styles.metadataLabel}>{resource.type}</div>
<div className={styles.metadataValue}>{resource.name}</div>
</div>
</Stack>
<Stack alignItems="flex-start" direction="row" spacing={5}>
<div className={styles.metadataHeader}>
{visibleMetadata.map((meta) => {
return (
<div className={styles.metadata} key={meta.key}>
<div className={styles.metadataLabel}>{meta.key}</div>
<div className={styles.metadataValue}>
{meta.sensitive ? (
<SensitiveValue value={meta.value} />
) : (
<CopyableValue value={meta.value}>
{meta.value}
</CopyableValue>
)}
</div>
</div>
)
})}
</div>
<Maybe condition={metadataToDisplay.length > 4}>
<Tooltip
title={
shouldDisplayAllMetadata ? "Hide metadata" : "Show all metadata"
}
>
<IconButton
onClick={() => {
setShouldDisplayAllMetadata((value) => !value)
}}
>
{shouldDisplayAllMetadata ? (
<CloseDropdown margin={false} />
) : (
<OpenDropdown margin={false} />
)}
</IconButton>
</Tooltip>
</Maybe>
</Stack>
</Stack>
{resource.agents && resource.agents.length > 0 && (
<div>
{resource.agents.map((agent) => {
return (
<Stack
key={agent.id}
direction="row"
alignItems="center"
justifyContent="space-between"
className={styles.agentRow}
>
<Stack direction="row" alignItems="baseline">
<AgentStatus agent={agent} />
<div>
<div className={styles.agentName}>{agent.name}</div>
<Stack
direction="row"
alignItems="baseline"
className={styles.agentData}
spacing={1}
>
<span className={styles.agentOS}>
{agent.operating_system}
</span>
<AgentVersion
agent={agent}
serverVersion={serverVersion}
/>
<AgentLatency agent={agent} />
</Stack>
</div>
</Stack>
<Stack direction="row" alignItems="center" spacing={0.5}>
{showApps && agent.status === "connected" && (
<>
{applicationsHost !== undefined && (
<PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentId={agent.id}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
{!hideSSHButton && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
/>
)}
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
{agent.apps.map((app) => (
<AppLink
key={app.name}
appsHost={applicationsHost}
appIcon={app.icon}
appName={app.name}
appCommand={app.command}
appSubdomain={app.subdomain}
username={workspace.owner_name}
workspaceName={workspace.name}
agentName={agent.name}
health={app.health}
appSharingLevel={app.sharing_level}
/>
))}
</>
)}
{showApps && agent.status === "connecting" && (
<>
<Skeleton width={80} height={36} variant="rect" />
<Skeleton width={120} height={36} variant="rect" />
</>
)}
</Stack>
</Stack>
)
})}
</div>
)}
</div>
)
}
const useStyles = makeStyles((theme) => ({
resourceCard: {
background: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
},
resourceCardProfile: {
flexShrink: 0,
width: "fit-content",
},
resourceCardHeader: {
padding: theme.spacing(3, 4),
borderBottom: `1px solid ${theme.palette.divider}`,
"&:last-child": {
borderBottom: 0,
},
},
metadataHeader: {
display: "grid",
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
gap: theme.spacing(5),
rowGap: theme.spacing(3),
},
metadata: {
fontSize: 16,
},
metadataLabel: {
fontSize: 12,
color: theme.palette.text.secondary,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
},
metadataValue: {
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
},
agentRow: {
padding: theme.spacing(3, 4),
backgroundColor: theme.palette.background.paperLight,
fontSize: 16,
"&:not(:last-child)": {
borderBottom: `1px solid ${theme.palette.divider}`,
},
},
agentName: {
fontWeight: 600,
},
agentOS: {
textTransform: "capitalize",
},
agentData: {
fontSize: 14,
color: theme.palette.text.secondary,
marginTop: theme.spacing(0.5),
},
}))

View File

@ -1,46 +1,21 @@
import Button from "@material-ui/core/Button" import Button from "@material-ui/core/Button"
import { makeStyles, Theme } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableContainer from "@material-ui/core/TableContainer"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import { Skeleton } from "@material-ui/lab"
import useTheme from "@material-ui/styles/useTheme"
import { import {
CloseDropdown, CloseDropdown,
OpenDropdown, OpenDropdown,
} from "components/DropdownArrows/DropdownArrows" } from "components/DropdownArrows/DropdownArrows"
import { PortForwardButton } from "components/PortForwardButton/PortForwardButton"
import { TableCellDataPrimary } from "components/TableCellData/TableCellData"
import { FC, useState } from "react" import { FC, useState } from "react"
import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace"
import { import {
BuildInfoResponse, BuildInfoResponse,
Workspace, Workspace,
WorkspaceResource, WorkspaceResource,
} from "../../api/typesGenerated" } from "../../api/typesGenerated"
import { AppLink } from "../AppLink/AppLink"
import { SSHButton } from "../SSHButton/SSHButton"
import { Stack } from "../Stack/Stack" import { Stack } from "../Stack/Stack"
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
import { TerminalLink } from "../TerminalLink/TerminalLink"
import { AgentHelpTooltip } from "../Tooltips/AgentHelpTooltip"
import { AgentOutdatedTooltip } from "../Tooltips/AgentOutdatedTooltip"
import { ResourcesHelpTooltip } from "../Tooltips/ResourcesHelpTooltip"
import { ResourceAgentLatency } from "./ResourceAgentLatency"
import { ResourceAvatarData } from "./ResourceAvatarData"
import { AlertBanner } from "components/AlertBanner/AlertBanner" import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { ResourceCard } from "./ResourceCard"
const Language = { const countAgents = (resource: WorkspaceResource) => {
resources: "Resources", return resource.agents ? resource.agents.length : 0
resourceLabel: "Resource",
agentsLabel: "Agents",
agentLabel: "Agent",
statusLabel: "status: ",
versionLabel: "version: ",
osLabel: "os: ",
} }
interface ResourcesProps { interface ResourcesProps {
@ -58,178 +33,41 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
getResourcesError, getResourcesError,
workspace, workspace,
canUpdateWorkspace, canUpdateWorkspace,
buildInfo,
hideSSHButton, hideSSHButton,
applicationsHost, applicationsHost,
buildInfo,
}) => { }) => {
const styles = useStyles()
const theme: Theme = useTheme()
const serverVersion = buildInfo?.version || "" const serverVersion = buildInfo?.version || ""
const styles = useStyles()
const [shouldDisplayHideResources, setShouldDisplayHideResources] = const [shouldDisplayHideResources, setShouldDisplayHideResources] =
useState(false) useState(false)
const displayResources = shouldDisplayHideResources const displayResources = shouldDisplayHideResources
? resources ? resources
: resources.filter((resource) => !resource.hide) : resources
.filter((resource) => !resource.hide)
// Display the resources with agents first
.sort((a, b) => countAgents(b) - countAgents(a))
const hasHideResources = resources.some((r) => r.hide) const hasHideResources = resources.some((r) => r.hide)
if (getResourcesError) {
return <AlertBanner severity="error" error={getResourcesError} />
}
return ( return (
<Stack direction="column" spacing={1}> <Stack direction="column" spacing={1}>
<div aria-label={Language.resources} className={styles.wrapper}> {displayResources.map((resource) => {
{getResourcesError ? ( return (
<AlertBanner severity="error" error={getResourcesError} /> <ResourceCard
) : ( key={resource.id}
<TableContainer className={styles.tableContainer}> resource={resource}
<Table> workspace={workspace}
<TableHead> applicationsHost={applicationsHost}
<TableHeaderRow> showApps={canUpdateWorkspace}
<TableCell> hideSSHButton={hideSSHButton}
<Stack direction="row" spacing={0.5} alignItems="center"> serverVersion={serverVersion}
{Language.resourceLabel} />
<ResourcesHelpTooltip /> )
</Stack> })}
</TableCell>
<TableCell className={styles.agentColumn}>
<Stack direction="row" spacing={0.5} alignItems="center">
{Language.agentLabel}
<AgentHelpTooltip />
</Stack>
</TableCell>
{canUpdateWorkspace && <TableCell></TableCell>}
</TableHeaderRow>
</TableHead>
<TableBody>
{displayResources.map((resource) => {
{
/* We need to initialize the agents to display the resource */
}
const agents = resource.agents ?? [null]
const resourceName = (
<ResourceAvatarData resource={resource} />
)
return agents.map((agent, agentIndex) => {
{
/* If there is no agent, just display the resource name */
}
if (
!agent ||
workspace.latest_build.transition === "stop"
) {
return (
<TableRow key={`${resource.id}-${agentIndex}`}>
<TableCell>{resourceName}</TableCell>
<TableCell colSpan={3}></TableCell>
</TableRow>
)
}
const { displayVersion, outdated } =
getDisplayVersionStatus(agent.version, serverVersion)
const agentStatus = getDisplayAgentStatus(theme, agent)
return (
<TableRow key={`${resource.id}-${agent.id}`}>
{/* We only want to display the name in the first row because we are using rowSpan */}
{/* The rowspan should be the same than the number of agents */}
{agentIndex === 0 && (
<TableCell
className={styles.resourceNameCell}
rowSpan={agents.length}
>
{resourceName}
</TableCell>
)}
<TableCell className={styles.agentColumn}>
<TableCellDataPrimary highlight>
{agent.name}
</TableCellDataPrimary>
<div className={styles.data}>
<div className={styles.dataRow}>
<strong>{Language.statusLabel}</strong>
<span
style={{ color: agentStatus.color }}
className={styles.status}
>
{agentStatus.status}
</span>
</div>
<div className={styles.dataRow}>
<strong>{Language.osLabel}</strong>
<span className={styles.operatingSystem}>
{agent.operating_system}
</span>
</div>
<div className={styles.dataRow}>
<strong>{Language.versionLabel}</strong>
<span className={styles.agentVersion}>
{displayVersion}
</span>
<AgentOutdatedTooltip outdated={outdated} />
</div>
<div className={styles.dataRow}>
<ResourceAgentLatency latency={agent.latency} />
</div>
</div>
</TableCell>
<TableCell>
<div className={styles.accessLinks}>
{canUpdateWorkspace &&
agent.status === "connected" && (
<>
{applicationsHost !== undefined && (
<PortForwardButton
host={applicationsHost}
workspaceName={workspace.name}
agentId={agent.id}
agentName={agent.name}
username={workspace.owner_name}
/>
)}
{!hideSSHButton && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
/>
)}
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
{agent.apps.map((app) => (
<AppLink
key={app.name}
appsHost={applicationsHost}
appIcon={app.icon}
appName={app.name}
appCommand={app.command}
appSubdomain={app.subdomain}
appSharingLevel={app.sharing_level}
username={workspace.owner_name}
workspaceName={workspace.name}
agentName={agent.name}
health={app.health}
/>
))}
</>
)}
{canUpdateWorkspace &&
agent.status === "connecting" && (
<>
<Skeleton width={80} height={60} />
<Skeleton width={120} height={60} />
</>
)}
</div>
</TableCell>
</TableRow>
)
})
})}
</TableBody>
</Table>
</TableContainer>
)}
</div>
{hasHideResources && ( {hasHideResources && (
<div className={styles.buttonWrapper}> <div className={styles.buttonWrapper}>
@ -255,77 +93,7 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
) )
} }
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles(() => ({
wrapper: {
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
},
tableContainer: {
border: 0,
},
resourceAvatar: {
color: "#FFF",
backgroundColor: "#3B73D8",
},
resourceNameCell: {
borderRight: `1px solid ${theme.palette.divider}`,
},
resourceType: {
fontSize: 14,
color: theme.palette.text.secondary,
marginTop: theme.spacing(0.5),
display: "block",
},
// Adds some left spacing
agentColumn: {
paddingLeft: `${theme.spacing(4)}px !important`,
},
operatingSystem: {
display: "block",
textTransform: "capitalize",
},
agentVersion: {
display: "block",
},
accessLinks: {
display: "flex",
gap: theme.spacing(0.5),
flexWrap: "wrap",
justifyContent: "flex-end",
},
status: {
whiteSpace: "nowrap",
},
data: {
color: theme.palette.text.secondary,
fontSize: 14,
marginTop: theme.spacing(0.75),
display: "grid",
gridAutoFlow: "row",
whiteSpace: "nowrap",
gap: theme.spacing(0.75),
height: "fit-content",
},
dataRow: {
display: "flex",
alignItems: "center",
"& strong": {
marginRight: theme.spacing(1),
},
},
buttonWrapper: { buttonWrapper: {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",

View File

@ -0,0 +1,69 @@
import IconButton from "@material-ui/core/IconButton"
import { makeStyles } from "@material-ui/core/styles"
import Tooltip from "@material-ui/core/Tooltip"
import VisibilityOffOutlined from "@material-ui/icons/VisibilityOffOutlined"
import VisibilityOutlined from "@material-ui/icons/VisibilityOutlined"
import { CopyableValue } from "components/CopyableValue/CopyableValue"
import { useState } from "react"
const Language = {
showLabel: "Show value",
hideLabel: "Hide value",
}
export const SensitiveValue: React.FC<{ value: string }> = ({ value }) => {
const [shouldDisplay, setShouldDisplay] = useState(false)
const styles = useStyles()
const displayValue = shouldDisplay ? value : "••••••••"
const buttonLabel = shouldDisplay ? Language.hideLabel : Language.showLabel
const icon = shouldDisplay ? (
<VisibilityOffOutlined />
) : (
<VisibilityOutlined />
)
return (
<div className={styles.sensitiveValue}>
<CopyableValue value={value} className={styles.value}>
{displayValue}
</CopyableValue>
<Tooltip title={buttonLabel}>
<IconButton
className={styles.button}
onClick={() => {
setShouldDisplay((value) => !value)
}}
size="small"
aria-label={buttonLabel}
>
{icon}
</IconButton>
</Tooltip>
</div>
)
}
const useStyles = makeStyles((theme) => ({
value: {
// 22px is the button width
width: "calc(100% - 22px)",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
},
sensitiveValue: {
display: "flex",
alignItems: "center",
gap: theme.spacing(0.5),
},
button: {
color: "inherit",
"& .MuiSvgIcon-root": {
width: 16,
height: 16,
},
},
}))

View File

@ -12,6 +12,7 @@ export type StackProps = {
spacing?: number spacing?: number
alignItems?: CSSProperties["alignItems"] alignItems?: CSSProperties["alignItems"]
justifyContent?: CSSProperties["justifyContent"] justifyContent?: CSSProperties["justifyContent"]
wrap?: CSSProperties["flexWrap"]
} & React.HTMLProps<HTMLDivElement> } & React.HTMLProps<HTMLDivElement>
type StyleProps = Omit<StackProps, "className"> type StyleProps = Omit<StackProps, "className">
@ -23,6 +24,7 @@ const useStyles = makeStyles((theme) => ({
gap: ({ spacing }: StyleProps) => spacing && theme.spacing(spacing), gap: ({ spacing }: StyleProps) => spacing && theme.spacing(spacing),
alignItems: ({ alignItems }: StyleProps) => alignItems, alignItems: ({ alignItems }: StyleProps) => alignItems,
justifyContent: ({ justifyContent }: StyleProps) => justifyContent, justifyContent: ({ justifyContent }: StyleProps) => justifyContent,
flexWrap: ({ wrap }: StyleProps) => wrap,
[theme.breakpoints.down("sm")]: { [theme.breakpoints.down("sm")]: {
width: "100%", width: "100%",
@ -37,6 +39,7 @@ export const Stack: FC<StackProps & { children: ReactNode | ReactNode[] }> = ({
spacing = 2, spacing = 2,
alignItems, alignItems,
justifyContent, justifyContent,
wrap,
...divProps ...divProps
}) => { }) => {
const styles = useStyles({ const styles = useStyles({
@ -44,6 +47,7 @@ export const Stack: FC<StackProps & { children: ReactNode | ReactNode[] }> = ({
direction, direction,
alignItems, alignItems,
justifyContent, justifyContent,
wrap,
}) })
return ( return (

View File

@ -1,5 +1,5 @@
import Link from "@material-ui/core/Link" import Link from "@material-ui/core/Link"
import Popover from "@material-ui/core/Popover" import Popover, { PopoverProps } from "@material-ui/core/Popover"
import { makeStyles } from "@material-ui/core/styles" import { makeStyles } from "@material-ui/core/styles"
import HelpIcon from "@material-ui/icons/HelpOutline" import HelpIcon from "@material-ui/icons/HelpOutline"
import OpenInNewIcon from "@material-ui/icons/OpenInNew" import OpenInNewIcon from "@material-ui/icons/OpenInNew"
@ -35,6 +35,35 @@ const useHelpTooltip = () => {
return helpTooltipContext return helpTooltipContext
} }
export const HelpPopover: React.FC<
PopoverProps & { onOpen: () => void; onClose: () => void }
> = ({ onOpen, onClose, children, ...props }) => {
const styles = useStyles({ size: "small" })
return (
<Popover
className={styles.popover}
classes={{ paper: styles.popoverPaper }}
onClose={onClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
PaperProps={{
onMouseEnter: onOpen,
onMouseLeave: onClose,
}}
{...props}
>
{children}
</Popover>
)
}
export const HelpTooltip: React.FC< export const HelpTooltip: React.FC<
React.PropsWithChildren<HelpTooltipProps> React.PropsWithChildren<HelpTooltipProps>
> = ({ children, open, size = "medium" }) => { > = ({ children, open, size = "medium" }) => {
@ -67,34 +96,17 @@ export const HelpTooltip: React.FC<
> >
<HelpIcon className={styles.icon} /> <HelpIcon className={styles.icon} />
</button> </button>
<Popover <HelpPopover
className={styles.popover}
classes={{ paper: styles.popoverPaper }}
id={id} id={id}
open={isOpen} open={isOpen}
anchorEl={anchorRef.current} anchorEl={anchorRef.current}
onClose={onClose} onOpen={() => setIsOpen(true)}
anchorOrigin={{ onClose={() => setIsOpen(false)}
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
PaperProps={{
onMouseEnter: () => {
setIsOpen(true)
},
onMouseLeave: () => {
setIsOpen(false)
},
}}
> >
<HelpTooltipContext.Provider value={{ open: isOpen, onClose }}> <HelpTooltipContext.Provider value={{ open: isOpen, onClose }}>
{children} {children}
</HelpTooltipContext.Provider> </HelpTooltipContext.Provider>
</Popover> </HelpPopover>
</> </>
) )
} }

View File

@ -0,0 +1,21 @@
import { KeyboardEvent } from "react"
interface UseClickableResult {
tabIndex: 0
role: "button"
onClick: () => void
onKeyDown: (event: KeyboardEvent) => void
}
export const useClickable = (onClick: () => void): UseClickableResult => {
return {
tabIndex: 0,
role: "button",
onClick,
onKeyDown: (event: KeyboardEvent) => {
if (event.key === "Enter") {
onClick()
}
},
}
}

View File

@ -0,0 +1,44 @@
import { useState } from "react"
export const useClipboard = (
text: string,
): { isCopied: boolean; copy: () => Promise<void> } => {
const [isCopied, setIsCopied] = useState<boolean>(false)
const copy = 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)
}
}
}
return {
isCopied,
copy,
}
}

View File

@ -33,5 +33,10 @@
"canceling": "Canceling", "canceling": "Canceling",
"deleted": "Deleted", "deleted": "Deleted",
"pending": "Pending" "pending": "Pending"
},
"agentStatus": {
"connected": "Connected",
"connecting": "Connecting...",
"disconnected": "Disconnected"
} }
} }

View File

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-floating-promises */ /* eslint-disable @typescript-eslint/no-floating-promises */
import { fireEvent, screen, waitFor } from "@testing-library/react" import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event" import userEvent from "@testing-library/user-event"
import EventSourceMock from "eventsourcemock" import EventSourceMock from "eventsourcemock"
import i18next from "i18next" import i18next from "i18next"
@ -27,7 +27,6 @@ import {
renderWithAuth, renderWithAuth,
} from "../../testHelpers/renderHelpers" } from "../../testHelpers/renderHelpers"
import { server } from "../../testHelpers/server" import { server } from "../../testHelpers/server"
import { DisplayAgentStatusLanguage } from "../../util/workspace"
import { WorkspacePage } from "./WorkspacePage" import { WorkspacePage } from "./WorkspacePage"
const { t } = i18next const { t } = i18next
@ -71,7 +70,8 @@ const testStatus = async (ws: Workspace, label: string) => {
), ),
) )
await renderWorkspacePage() await renderWorkspacePage()
const status = await screen.findByRole("status") const header = screen.getByTestId("header")
const status = await within(header).findByRole("status")
expect(status).toHaveTextContent(label) expect(status).toHaveTextContent(label)
} }
@ -96,7 +96,8 @@ describe("WorkspacePage", () => {
await renderWorkspacePage() await renderWorkspacePage()
const workspaceName = await screen.findByText(MockWorkspace.name) const workspaceName = await screen.findByText(MockWorkspace.name)
expect(workspaceName).toBeDefined() expect(workspaceName).toBeDefined()
const status = await screen.findByRole("status") const header = screen.getByTestId("header")
const status = await within(header).findByRole("status")
expect(status).toHaveTextContent("Running") expect(status).toHaveTextContent("Running")
// wait for workspace page to finish loading // wait for workspace page to finish loading
await screen.findByText("stop") await screen.findByText("stop")
@ -335,16 +336,22 @@ describe("WorkspacePage", () => {
MockWorkspaceAgentDisconnected.name, MockWorkspaceAgentDisconnected.name,
) )
expect(agent2Names.length).toEqual(2) expect(agent2Names.length).toEqual(2)
const agent1Status = await screen.findAllByText( const agent1Status = await screen.findAllByLabelText(
DisplayAgentStatusLanguage[MockWorkspaceAgent.status], t<string>(`agentStatus.${MockWorkspaceAgent.status}`, {
ns: "workspacePage",
}),
) )
expect(agent1Status.length).toEqual(1) expect(agent1Status.length).toEqual(1)
const agentDisconnected = await screen.findAllByText( const agentDisconnected = await screen.findAllByLabelText(
DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status], t<string>(`agentStatus.${MockWorkspaceAgentDisconnected.status}`, {
ns: "workspacePage",
}),
) )
expect(agentDisconnected.length).toEqual(1) expect(agentDisconnected.length).toEqual(1)
const agentConnecting = await screen.findAllByText( const agentConnecting = await screen.findAllByLabelText(
DisplayAgentStatusLanguage[MockWorkspaceAgentConnecting.status], t<string>(`agentStatus.${MockWorkspaceAgentConnecting.status}`, {
ns: "workspacePage",
}),
) )
expect(agentConnecting.length).toEqual(1) expect(agentConnecting.length).toEqual(1)
expect(getTemplateMock).toBeCalled() expect(getTemplateMock).toBeCalled()

View File

@ -101,11 +101,11 @@ describe("util > workspace", () => {
describe("getDisplayVersionStatus", () => { describe("getDisplayVersionStatus", () => {
it.each<[string, string, string, boolean]>([ it.each<[string, string, string, boolean]>([
["", "", "(unknown)", false], ["", "", "Unknown", false],
["", "v1.2.3", "(unknown)", false], ["", "v1.2.3", "Unknown", false],
["v1.2.3", "", "v1.2.3", false], ["v1.2.3", "", "v1.2.3", false],
["v1.2.3", "v1.2.3", "v1.2.3", false], ["v1.2.3", "v1.2.3", "v1.2.3", false],
["v1.2.3", "v1.2.4", "v1.2.3 (outdated)", true], ["v1.2.3", "v1.2.4", "v1.2.3", true],
["v1.2.4", "v1.2.3", "v1.2.4", false], ["v1.2.4", "v1.2.3", "v1.2.4", false],
["foo", "bar", "foo", false], ["foo", "bar", "foo", false],
])( ])(

View File

@ -20,8 +20,7 @@ export const DisplayWorkspaceBuildStatusLanguage = {
} }
export const DisplayAgentVersionLanguage = { export const DisplayAgentVersionLanguage = {
unknown: "unknown", unknown: "Unknown",
outdated: "outdated",
} }
export const getDisplayWorkspaceBuildStatus = ( export const getDisplayWorkspaceBuildStatus = (
@ -105,57 +104,18 @@ export const displayWorkspaceBuildDuration = (
return duration ? `${duration} seconds` : inProgressLabel return duration ? `${duration} seconds` : inProgressLabel
} }
export const DisplayAgentStatusLanguage = {
loading: "Loading...",
connected: "⦿ Connected",
connecting: "⦿ Connecting",
disconnected: "◍ Disconnected",
}
export const getDisplayAgentStatus = (
theme: Theme,
agent: TypesGen.WorkspaceAgent,
): {
color: string
status: string
} => {
switch (agent.status) {
case undefined:
return {
color: theme.palette.text.secondary,
status: DisplayAgentStatusLanguage.loading,
}
case "connected":
return {
color: theme.palette.success.main,
status: DisplayAgentStatusLanguage["connected"],
}
case "connecting":
return {
color: theme.palette.primary.main,
status: DisplayAgentStatusLanguage["connecting"],
}
case "disconnected":
return {
color: theme.palette.text.secondary,
status: DisplayAgentStatusLanguage["disconnected"],
}
}
}
export const getDisplayVersionStatus = ( export const getDisplayVersionStatus = (
agentVersion: string, agentVersion: string,
serverVersion: string, serverVersion: string,
): { displayVersion: string; outdated: boolean } => { ): { displayVersion: string; outdated: boolean } => {
if (!semver.valid(serverVersion) || !semver.valid(agentVersion)) { if (!semver.valid(serverVersion) || !semver.valid(agentVersion)) {
return { return {
displayVersion: displayVersion: agentVersion || DisplayAgentVersionLanguage.unknown,
`${agentVersion}` || `(${DisplayAgentVersionLanguage.unknown})`,
outdated: false, outdated: false,
} }
} else if (semver.lt(agentVersion, serverVersion)) { } else if (semver.lt(agentVersion, serverVersion)) {
return { return {
displayVersion: `${agentVersion} (${DisplayAgentVersionLanguage.outdated})`, displayVersion: agentVersion,
outdated: true, outdated: true,
} }
} else { } else {

View File

@ -4,6 +4,7 @@ import { createMachine, assign } from "xstate"
export const portForwardMachine = createMachine( export const portForwardMachine = createMachine(
{ {
predictableActionArguments: true,
id: "portForwardMachine", id: "portForwardMachine",
schema: { schema: {
context: {} as { context: {} as {