mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
feat: Redesign resources table (#4600)
This commit is contained in:
@ -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"
|
||||
|
||||
@ -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">
|
||||
|
39
site/src/components/CopyableValue/CopyableValue.tsx
Normal file
39
site/src/components/CopyableValue/CopyableValue.tsx
Normal 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",
|
||||
},
|
||||
}))
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
114
site/src/components/Resources/AgentLatency.tsx
Normal file
114
site/src/components/Resources/AgentLatency.tsx
Normal 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,
|
||||
},
|
||||
}))
|
100
site/src/components/Resources/AgentStatus.tsx
Normal file
100
site/src/components/Resources/AgentStatus.tsx
Normal 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",
|
||||
},
|
||||
}))
|
61
site/src/components/Resources/AgentVersion.tsx
Normal file
61
site/src/components/Resources/AgentVersion.tsx
Normal 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",
|
||||
},
|
||||
}))
|
81
site/src/components/Resources/ResourceCard.stories.tsx
Normal file
81
site/src/components/Resources/ResourceCard.stories.tsx
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
273
site/src/components/Resources/ResourceCard.tsx
Normal file
273
site/src/components/Resources/ResourceCard.tsx
Normal 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),
|
||||
},
|
||||
}))
|
@ -1,46 +1,21 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import { makeStyles, Theme } 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 { makeStyles } from "@material-ui/core/styles"
|
||||
import {
|
||||
CloseDropdown,
|
||||
OpenDropdown,
|
||||
} from "components/DropdownArrows/DropdownArrows"
|
||||
import { PortForwardButton } from "components/PortForwardButton/PortForwardButton"
|
||||
import { TableCellDataPrimary } from "components/TableCellData/TableCellData"
|
||||
import { FC, useState } from "react"
|
||||
import { getDisplayAgentStatus, getDisplayVersionStatus } from "util/workspace"
|
||||
import {
|
||||
BuildInfoResponse,
|
||||
Workspace,
|
||||
WorkspaceResource,
|
||||
} from "../../api/typesGenerated"
|
||||
import { AppLink } from "../AppLink/AppLink"
|
||||
import { SSHButton } from "../SSHButton/SSHButton"
|
||||
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 { ResourceCard } from "./ResourceCard"
|
||||
|
||||
const Language = {
|
||||
resources: "Resources",
|
||||
resourceLabel: "Resource",
|
||||
agentsLabel: "Agents",
|
||||
agentLabel: "Agent",
|
||||
statusLabel: "status: ",
|
||||
versionLabel: "version: ",
|
||||
osLabel: "os: ",
|
||||
const countAgents = (resource: WorkspaceResource) => {
|
||||
return resource.agents ? resource.agents.length : 0
|
||||
}
|
||||
|
||||
interface ResourcesProps {
|
||||
@ -58,178 +33,41 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
getResourcesError,
|
||||
workspace,
|
||||
canUpdateWorkspace,
|
||||
buildInfo,
|
||||
hideSSHButton,
|
||||
applicationsHost,
|
||||
buildInfo,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const theme: Theme = useTheme()
|
||||
const serverVersion = buildInfo?.version || ""
|
||||
const styles = useStyles()
|
||||
const [shouldDisplayHideResources, setShouldDisplayHideResources] =
|
||||
useState(false)
|
||||
const displayResources = shouldDisplayHideResources
|
||||
? 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)
|
||||
|
||||
if (getResourcesError) {
|
||||
return <AlertBanner severity="error" error={getResourcesError} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="column" spacing={1}>
|
||||
<div aria-label={Language.resources} className={styles.wrapper}>
|
||||
{getResourcesError ? (
|
||||
<AlertBanner severity="error" error={getResourcesError} />
|
||||
) : (
|
||||
<TableContainer className={styles.tableContainer}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableHeaderRow>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
{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>
|
||||
<ResourceCard
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
workspace={workspace}
|
||||
applicationsHost={applicationsHost}
|
||||
showApps={canUpdateWorkspace}
|
||||
hideSSHButton={hideSSHButton}
|
||||
serverVersion={serverVersion}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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 && (
|
||||
<div className={styles.buttonWrapper}>
|
||||
@ -255,77 +93,7 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
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),
|
||||
},
|
||||
},
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
buttonWrapper: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
69
site/src/components/Resources/SensitiveValue.tsx
Normal file
69
site/src/components/Resources/SensitiveValue.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
}))
|
@ -12,6 +12,7 @@ export type StackProps = {
|
||||
spacing?: number
|
||||
alignItems?: CSSProperties["alignItems"]
|
||||
justifyContent?: CSSProperties["justifyContent"]
|
||||
wrap?: CSSProperties["flexWrap"]
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
|
||||
type StyleProps = Omit<StackProps, "className">
|
||||
@ -23,6 +24,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
gap: ({ spacing }: StyleProps) => spacing && theme.spacing(spacing),
|
||||
alignItems: ({ alignItems }: StyleProps) => alignItems,
|
||||
justifyContent: ({ justifyContent }: StyleProps) => justifyContent,
|
||||
flexWrap: ({ wrap }: StyleProps) => wrap,
|
||||
|
||||
[theme.breakpoints.down("sm")]: {
|
||||
width: "100%",
|
||||
@ -37,6 +39,7 @@ export const Stack: FC<StackProps & { children: ReactNode | ReactNode[] }> = ({
|
||||
spacing = 2,
|
||||
alignItems,
|
||||
justifyContent,
|
||||
wrap,
|
||||
...divProps
|
||||
}) => {
|
||||
const styles = useStyles({
|
||||
@ -44,6 +47,7 @@ export const Stack: FC<StackProps & { children: ReactNode | ReactNode[] }> = ({
|
||||
direction,
|
||||
alignItems,
|
||||
justifyContent,
|
||||
wrap,
|
||||
})
|
||||
|
||||
return (
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 HelpIcon from "@material-ui/icons/HelpOutline"
|
||||
import OpenInNewIcon from "@material-ui/icons/OpenInNew"
|
||||
@ -35,6 +35,35 @@ const useHelpTooltip = () => {
|
||||
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<
|
||||
React.PropsWithChildren<HelpTooltipProps>
|
||||
> = ({ children, open, size = "medium" }) => {
|
||||
@ -67,34 +96,17 @@ export const HelpTooltip: React.FC<
|
||||
>
|
||||
<HelpIcon className={styles.icon} />
|
||||
</button>
|
||||
<Popover
|
||||
className={styles.popover}
|
||||
classes={{ paper: styles.popoverPaper }}
|
||||
<HelpPopover
|
||||
id={id}
|
||||
open={isOpen}
|
||||
anchorEl={anchorRef.current}
|
||||
onClose={onClose}
|
||||
anchorOrigin={{
|
||||
vertical: "bottom",
|
||||
horizontal: "left",
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "left",
|
||||
}}
|
||||
PaperProps={{
|
||||
onMouseEnter: () => {
|
||||
setIsOpen(true)
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
setIsOpen(false)
|
||||
},
|
||||
}}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClose={() => setIsOpen(false)}
|
||||
>
|
||||
<HelpTooltipContext.Provider value={{ open: isOpen, onClose }}>
|
||||
{children}
|
||||
</HelpTooltipContext.Provider>
|
||||
</Popover>
|
||||
</HelpPopover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
21
site/src/hooks/useClickable.ts
Normal file
21
site/src/hooks/useClickable.ts
Normal 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()
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
44
site/src/hooks/useClipboard.ts
Normal file
44
site/src/hooks/useClipboard.ts
Normal 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,
|
||||
}
|
||||
}
|
@ -33,5 +33,10 @@
|
||||
"canceling": "Canceling",
|
||||
"deleted": "Deleted",
|
||||
"pending": "Pending"
|
||||
},
|
||||
"agentStatus": {
|
||||
"connected": "Connected",
|
||||
"connecting": "Connecting...",
|
||||
"disconnected": "Disconnected"
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
/* 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 EventSourceMock from "eventsourcemock"
|
||||
import i18next from "i18next"
|
||||
@ -27,7 +27,6 @@ import {
|
||||
renderWithAuth,
|
||||
} from "../../testHelpers/renderHelpers"
|
||||
import { server } from "../../testHelpers/server"
|
||||
import { DisplayAgentStatusLanguage } from "../../util/workspace"
|
||||
import { WorkspacePage } from "./WorkspacePage"
|
||||
|
||||
const { t } = i18next
|
||||
@ -71,7 +70,8 @@ const testStatus = async (ws: Workspace, label: string) => {
|
||||
),
|
||||
)
|
||||
await renderWorkspacePage()
|
||||
const status = await screen.findByRole("status")
|
||||
const header = screen.getByTestId("header")
|
||||
const status = await within(header).findByRole("status")
|
||||
expect(status).toHaveTextContent(label)
|
||||
}
|
||||
|
||||
@ -96,7 +96,8 @@ describe("WorkspacePage", () => {
|
||||
await renderWorkspacePage()
|
||||
const workspaceName = await screen.findByText(MockWorkspace.name)
|
||||
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")
|
||||
// wait for workspace page to finish loading
|
||||
await screen.findByText("stop")
|
||||
@ -335,16 +336,22 @@ describe("WorkspacePage", () => {
|
||||
MockWorkspaceAgentDisconnected.name,
|
||||
)
|
||||
expect(agent2Names.length).toEqual(2)
|
||||
const agent1Status = await screen.findAllByText(
|
||||
DisplayAgentStatusLanguage[MockWorkspaceAgent.status],
|
||||
const agent1Status = await screen.findAllByLabelText(
|
||||
t<string>(`agentStatus.${MockWorkspaceAgent.status}`, {
|
||||
ns: "workspacePage",
|
||||
}),
|
||||
)
|
||||
expect(agent1Status.length).toEqual(1)
|
||||
const agentDisconnected = await screen.findAllByText(
|
||||
DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status],
|
||||
const agentDisconnected = await screen.findAllByLabelText(
|
||||
t<string>(`agentStatus.${MockWorkspaceAgentDisconnected.status}`, {
|
||||
ns: "workspacePage",
|
||||
}),
|
||||
)
|
||||
expect(agentDisconnected.length).toEqual(1)
|
||||
const agentConnecting = await screen.findAllByText(
|
||||
DisplayAgentStatusLanguage[MockWorkspaceAgentConnecting.status],
|
||||
const agentConnecting = await screen.findAllByLabelText(
|
||||
t<string>(`agentStatus.${MockWorkspaceAgentConnecting.status}`, {
|
||||
ns: "workspacePage",
|
||||
}),
|
||||
)
|
||||
expect(agentConnecting.length).toEqual(1)
|
||||
expect(getTemplateMock).toBeCalled()
|
||||
|
@ -101,11 +101,11 @@ describe("util > workspace", () => {
|
||||
|
||||
describe("getDisplayVersionStatus", () => {
|
||||
it.each<[string, string, string, boolean]>([
|
||||
["", "", "(unknown)", false],
|
||||
["", "v1.2.3", "(unknown)", false],
|
||||
["", "", "Unknown", false],
|
||||
["", "v1.2.3", "Unknown", false],
|
||||
["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],
|
||||
["foo", "bar", "foo", false],
|
||||
])(
|
||||
|
@ -20,8 +20,7 @@ export const DisplayWorkspaceBuildStatusLanguage = {
|
||||
}
|
||||
|
||||
export const DisplayAgentVersionLanguage = {
|
||||
unknown: "unknown",
|
||||
outdated: "outdated",
|
||||
unknown: "Unknown",
|
||||
}
|
||||
|
||||
export const getDisplayWorkspaceBuildStatus = (
|
||||
@ -105,57 +104,18 @@ export const displayWorkspaceBuildDuration = (
|
||||
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 = (
|
||||
agentVersion: string,
|
||||
serverVersion: string,
|
||||
): { displayVersion: string; outdated: boolean } => {
|
||||
if (!semver.valid(serverVersion) || !semver.valid(agentVersion)) {
|
||||
return {
|
||||
displayVersion:
|
||||
`${agentVersion}` || `(${DisplayAgentVersionLanguage.unknown})`,
|
||||
displayVersion: agentVersion || DisplayAgentVersionLanguage.unknown,
|
||||
outdated: false,
|
||||
}
|
||||
} else if (semver.lt(agentVersion, serverVersion)) {
|
||||
return {
|
||||
displayVersion: `${agentVersion} (${DisplayAgentVersionLanguage.outdated})`,
|
||||
displayVersion: agentVersion,
|
||||
outdated: true,
|
||||
}
|
||||
} else {
|
||||
|
@ -4,6 +4,7 @@ import { createMachine, assign } from "xstate"
|
||||
|
||||
export const portForwardMachine = createMachine(
|
||||
{
|
||||
predictableActionArguments: true,
|
||||
id: "portForwardMachine",
|
||||
schema: {
|
||||
context: {} as {
|
||||
|
Reference in New Issue
Block a user