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 { 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">
|
||||||
|
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({})
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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",
|
||||||
|
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
|
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 (
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
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",
|
"canceling": "Canceling",
|
||||||
"deleted": "Deleted",
|
"deleted": "Deleted",
|
||||||
"pending": "Pending"
|
"pending": "Pending"
|
||||||
|
},
|
||||||
|
"agentStatus": {
|
||||||
|
"connected": "Connected",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"disconnected": "Disconnected"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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],
|
||||||
])(
|
])(
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
Reference in New Issue
Block a user