diff --git a/site/src/components/CopyButton/CopyButton.tsx b/site/src/components/CopyButton/CopyButton.tsx index 107ccc862d05d..ac8bc3cb87d47 100644 --- a/site/src/components/CopyButton/CopyButton.tsx +++ b/site/src/components/CopyButton/CopyButton.tsx @@ -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> = ({ tooltipTitle = Language.tooltipTitle, }) => { const styles = useStyles() - const [isCopied, setIsCopied] = useState(false) - - const copyToClipboard = async (): Promise => { - 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 ( diff --git a/site/src/components/CopyableValue/CopyableValue.tsx b/site/src/components/CopyableValue/CopyableValue.tsx new file mode 100644 index 0000000000000..15bd72d84ccbe --- /dev/null +++ b/site/src/components/CopyableValue/CopyableValue.tsx @@ -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 { + value: string +} + +export const CopyableValue: React.FC = ({ + value, + className, + ...props +}) => { + const { isCopied, copy } = useClipboard(value) + const clickableProps = useClickable(copy) + const styles = useStyles() + + return ( + + + + ) +} + +const useStyles = makeStyles(() => ({ + value: { + cursor: "pointer", + }, +})) diff --git a/site/src/components/PageHeader/PageHeader.tsx b/site/src/components/PageHeader/PageHeader.tsx index c7f0e49bba152..3944f3405072d 100644 --- a/site/src/components/PageHeader/PageHeader.tsx +++ b/site/src/components/PageHeader/PageHeader.tsx @@ -15,14 +15,17 @@ export const PageHeader: React.FC> = ({ const styles = useStyles({}) return ( -
+
{children}
{actions && ( {actions} )} -
+ ) } diff --git a/site/src/components/Resources/AgentLatency.tsx b/site/src/components/Resources/AgentLatency.tsx new file mode 100644 index 0000000000000..ab74f8271e0c6 --- /dev/null +++ b/site/src/components/Resources/AgentLatency.tsx @@ -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(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 ( + <> + setIsOpen(true)} + className={styles.trigger} + style={{ color: latency.color }} + > + {Math.round(Math.round(latency.latency_ms))}ms + + setIsOpen(true)} + onClose={() => setIsOpen(false)} + > + Latency + + Latency from relay servers, used when connections cannot connect + peer-to-peer. Star indicates the preferred relay. + + + + + {Object.keys(agent.latency).map((regionName) => { + if (!agent.latency) { + throw new Error("No latency found on agent") + } + + const region = agent.latency[regionName] + + return ( + + {regionName} + {Math.round(region.latency_ms)}ms + + ) + })} + + + + + ) +} + +const useStyles = makeStyles((theme) => ({ + trigger: { + cursor: "pointer", + }, + regions: { + marginTop: theme.spacing(2), + }, + preferred: { + color: theme.palette.text.primary, + }, +})) diff --git a/site/src/components/Resources/AgentStatus.tsx b/site/src/components/Resources/AgentStatus.tsx new file mode 100644 index 0000000000000..1a55f2255bbfb --- /dev/null +++ b/site/src/components/Resources/AgentStatus.tsx @@ -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 ( + +
+ + ) +} + +const DisconnectedStatus: React.FC = () => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + + return ( + +
+ + ) +} + +const ConnectingStatus: React.FC = () => { + const styles = useStyles() + const { t } = useTranslation("workspacePage") + + return ( + +
+ + ) +} + +export const AgentStatus: React.FC<{ agent: WorkspaceAgent }> = ({ agent }) => { + return ( + + + + + + + + + + + + ) +} + +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", + }, +})) diff --git a/site/src/components/Resources/AgentVersion.tsx b/site/src/components/Resources/AgentVersion.tsx new file mode 100644 index 0000000000000..aab5fa73e75d4 --- /dev/null +++ b/site/src/components/Resources/AgentVersion.tsx @@ -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(null) + const [isOpen, setIsOpen] = useState(false) + const id = isOpen ? "version-outdated-popover" : undefined + const { displayVersion, outdated } = getDisplayVersionStatus( + agent.version, + serverVersion, + ) + + if (!outdated) { + return {displayVersion} + } + + return ( + <> + setIsOpen(true)} + className={styles.trigger} + > + Agent Outdated + + setIsOpen(true)} + onClose={() => setIsOpen(false)} + > + Agent Outdated + + 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. + + + + ) +} + +const useStyles = makeStyles(() => ({ + trigger: { + cursor: "pointer", + }, +})) diff --git a/site/src/components/Resources/ResourceCard.stories.tsx b/site/src/components/Resources/ResourceCard.stories.tsx new file mode 100644 index 0000000000000..11684a3ec4aab --- /dev/null +++ b/site/src/components/Resources/ResourceCard.stories.tsx @@ -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 = (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, + }, + ], + }, +} diff --git a/site/src/components/Resources/ResourceCard.tsx b/site/src/components/Resources/ResourceCard.tsx new file mode 100644 index 0000000000000..c1513d158fc9c --- /dev/null +++ b/site/src/components/Resources/ResourceCard.tsx @@ -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 = ({ + 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 ( +
+ + +
+ +
+
+
{resource.type}
+
{resource.name}
+
+
+ + +
+ {visibleMetadata.map((meta) => { + return ( +
+
{meta.key}
+
+ {meta.sensitive ? ( + + ) : ( + + {meta.value} + + )} +
+
+ ) + })} +
+ + 4}> + + { + setShouldDisplayAllMetadata((value) => !value) + }} + > + {shouldDisplayAllMetadata ? ( + + ) : ( + + )} + + + +
+
+ + {resource.agents && resource.agents.length > 0 && ( +
+ {resource.agents.map((agent) => { + return ( + + + +
+
{agent.name}
+ + + {agent.operating_system} + + + + +
+
+ + + {showApps && agent.status === "connected" && ( + <> + {applicationsHost !== undefined && ( + + )} + {!hideSSHButton && ( + + )} + + {agent.apps.map((app) => ( + + ))} + + )} + {showApps && agent.status === "connecting" && ( + <> + + + + )} + +
+ ) + })} +
+ )} +
+ ) +} + +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), + }, +})) diff --git a/site/src/components/Resources/Resources.tsx b/site/src/components/Resources/Resources.tsx index 9d2e63ac9e39c..d15bcf5441ea1 100644 --- a/site/src/components/Resources/Resources.tsx +++ b/site/src/components/Resources/Resources.tsx @@ -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> = ({ 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 + } + return ( -
- {getResourcesError ? ( - - ) : ( - - - - - - - {Language.resourceLabel} - - - - - - {Language.agentLabel} - - - - {canUpdateWorkspace && } - - - - {displayResources.map((resource) => { - { - /* We need to initialize the agents to display the resource */ - } - const agents = resource.agents ?? [null] - const resourceName = ( - - ) - - return agents.map((agent, agentIndex) => { - { - /* If there is no agent, just display the resource name */ - } - if ( - !agent || - workspace.latest_build.transition === "stop" - ) { - return ( - - {resourceName} - - - ) - } - const { displayVersion, outdated } = - getDisplayVersionStatus(agent.version, serverVersion) - const agentStatus = getDisplayAgentStatus(theme, agent) - return ( - - {/* 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 && ( - - {resourceName} - - )} - - - - {agent.name} - -
-
- {Language.statusLabel} - - {agentStatus.status} - -
-
- {Language.osLabel} - - {agent.operating_system} - -
-
- {Language.versionLabel} - - {displayVersion} - - -
-
- -
-
-
- -
- {canUpdateWorkspace && - agent.status === "connected" && ( - <> - {applicationsHost !== undefined && ( - - )} - {!hideSSHButton && ( - - )} - - {agent.apps.map((app) => ( - - ))} - - )} - {canUpdateWorkspace && - agent.status === "connecting" && ( - <> - - - - )} -
-
-
- ) - }) - })} -
-
-
- )} -
+ {displayResources.map((resource) => { + return ( + + ) + })} {hasHideResources && (
@@ -255,77 +93,7 @@ export const Resources: FC> = ({ ) } -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", diff --git a/site/src/components/Resources/SensitiveValue.tsx b/site/src/components/Resources/SensitiveValue.tsx new file mode 100644 index 0000000000000..b8e7f42d761b1 --- /dev/null +++ b/site/src/components/Resources/SensitiveValue.tsx @@ -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 ? ( + + ) : ( + + ) + + return ( +
+ + {displayValue} + + + { + setShouldDisplay((value) => !value) + }} + size="small" + aria-label={buttonLabel} + > + {icon} + + +
+ ) +} + +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, + }, + }, +})) diff --git a/site/src/components/Stack/Stack.tsx b/site/src/components/Stack/Stack.tsx index d12f4e5821a56..1f181816c683d 100644 --- a/site/src/components/Stack/Stack.tsx +++ b/site/src/components/Stack/Stack.tsx @@ -12,6 +12,7 @@ export type StackProps = { spacing?: number alignItems?: CSSProperties["alignItems"] justifyContent?: CSSProperties["justifyContent"] + wrap?: CSSProperties["flexWrap"] } & React.HTMLProps type StyleProps = Omit @@ -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 = ({ spacing = 2, alignItems, justifyContent, + wrap, ...divProps }) => { const styles = useStyles({ @@ -44,6 +47,7 @@ export const Stack: FC = ({ direction, alignItems, justifyContent, + wrap, }) return ( diff --git a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx index 281c140b0077d..9b992e6bca431 100644 --- a/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/Tooltips/HelpTooltip/HelpTooltip.tsx @@ -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 ( + + {children} + + ) +} + export const HelpTooltip: React.FC< React.PropsWithChildren > = ({ children, open, size = "medium" }) => { @@ -67,34 +96,17 @@ export const HelpTooltip: React.FC< > - { - setIsOpen(true) - }, - onMouseLeave: () => { - setIsOpen(false) - }, - }} + onOpen={() => setIsOpen(true)} + onClose={() => setIsOpen(false)} > {children} - + ) } diff --git a/site/src/hooks/useClickable.ts b/site/src/hooks/useClickable.ts new file mode 100644 index 0000000000000..a694a5f6821af --- /dev/null +++ b/site/src/hooks/useClickable.ts @@ -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() + } + }, + } +} diff --git a/site/src/hooks/useClipboard.ts b/site/src/hooks/useClipboard.ts new file mode 100644 index 0000000000000..3737ac64ecef5 --- /dev/null +++ b/site/src/hooks/useClipboard.ts @@ -0,0 +1,44 @@ +import { useState } from "react" + +export const useClipboard = ( + text: string, +): { isCopied: boolean; copy: () => Promise } => { + const [isCopied, setIsCopied] = useState(false) + + const copy = async (): Promise => { + 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, + } +} diff --git a/site/src/i18n/en/workspacePage.json b/site/src/i18n/en/workspacePage.json index 8f36e9b3d44e6..a0901d88bf857 100644 --- a/site/src/i18n/en/workspacePage.json +++ b/site/src/i18n/en/workspacePage.json @@ -33,5 +33,10 @@ "canceling": "Canceling", "deleted": "Deleted", "pending": "Pending" + }, + "agentStatus": { + "connected": "Connected", + "connecting": "Connecting...", + "disconnected": "Disconnected" } } diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 3f2df80c358b1..3986d9930f4db 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -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(`agentStatus.${MockWorkspaceAgent.status}`, { + ns: "workspacePage", + }), ) expect(agent1Status.length).toEqual(1) - const agentDisconnected = await screen.findAllByText( - DisplayAgentStatusLanguage[MockWorkspaceAgentDisconnected.status], + const agentDisconnected = await screen.findAllByLabelText( + t(`agentStatus.${MockWorkspaceAgentDisconnected.status}`, { + ns: "workspacePage", + }), ) expect(agentDisconnected.length).toEqual(1) - const agentConnecting = await screen.findAllByText( - DisplayAgentStatusLanguage[MockWorkspaceAgentConnecting.status], + const agentConnecting = await screen.findAllByLabelText( + t(`agentStatus.${MockWorkspaceAgentConnecting.status}`, { + ns: "workspacePage", + }), ) expect(agentConnecting.length).toEqual(1) expect(getTemplateMock).toBeCalled() diff --git a/site/src/util/workspace.test.ts b/site/src/util/workspace.test.ts index 6650a9090cd97..8f2a6893d13f8 100644 --- a/site/src/util/workspace.test.ts +++ b/site/src/util/workspace.test.ts @@ -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], ])( diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index 49574e4abe5ca..1e23a1cfe3717 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -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 { diff --git a/site/src/xServices/portForward/portForwardXService.ts b/site/src/xServices/portForward/portForwardXService.ts index 27fa2bfd4cc56..8c2494083cbbe 100644 --- a/site/src/xServices/portForward/portForwardXService.ts +++ b/site/src/xServices/portForward/portForwardXService.ts @@ -4,6 +4,7 @@ import { createMachine, assign } from "xstate" export const portForwardMachine = createMachine( { + predictableActionArguments: true, id: "portForwardMachine", schema: { context: {} as {