diff --git a/site/src/components/Resources/AgentButton.tsx b/site/src/components/Resources/AgentButton.tsx index 58f0e533b8095..2c0b52e67fe14 100644 --- a/site/src/components/Resources/AgentButton.tsx +++ b/site/src/components/Resources/AgentButton.tsx @@ -1,32 +1,28 @@ import Button, { type ButtonProps } from "@mui/material/Button"; -import { useTheme } from "@emotion/react"; import { forwardRef } from "react"; // eslint-disable-next-line react/display-name -- Name is inferred from variable name export const AgentButton = forwardRef( (props, ref) => { const { children, ...buttonProps } = props; - const theme = useTheme(); return ( diff --git a/site/src/components/Resources/AgentMetadata.tsx b/site/src/components/Resources/AgentMetadata.tsx index ab78842854fb1..886862d96d119 100644 --- a/site/src/components/Resources/AgentMetadata.tsx +++ b/site/src/components/Resources/AgentMetadata.tsx @@ -24,71 +24,6 @@ type ItemStatus = "stale" | "valid" | "loading"; export const WatchAgentMetadataContext = createContext(watchAgentMetadata); -interface MetadataItemProps { - item: WorkspaceAgentMetadata; -} - -const MetadataItem: FC = ({ item }) => { - if (item.result === undefined) { - throw new Error("Metadata item result is undefined"); - } - if (item.description === undefined) { - throw new Error("Metadata item description is undefined"); - } - - const staleThreshold = Math.max( - item.description.interval + item.description.timeout * 2, - // In case there is intense backpressure, we give a little bit of slack. - 5, - ); - - const status: ItemStatus = (() => { - const year = dayjs(item.result.collected_at).year(); - if (year <= 1970 || isNaN(year)) { - return "loading"; - } - // There is a special circumstance for metadata with `interval: 0`. It is - // expected that they run once and never again, so never display them as - // stale. - if (item.result.age > staleThreshold && item.description.interval > 0) { - return "stale"; - } - return "valid"; - })(); - - // Stale data is as good as no data. Plus, we want to build confidence in our - // users that what's shown is real. If times aren't correctly synced this - // could be buggy. But, how common is that anyways? - const value = - status === "loading" ? ( - - ) : status === "stale" ? ( - - - {item.result.value} - - - ) : ( - - {item.result.value} - - ); - - return ( -
-
{item.description.display_name}
-
{value}
-
- ); -}; - export interface AgentMetadataViewProps { metadata: WorkspaceAgentMetadata[]; } @@ -98,16 +33,11 @@ export const AgentMetadataView: FC = ({ metadata }) => { return null; } return ( -
- - {metadata.map((m) => { - if (m.description === undefined) { - throw new Error("Metadata item description is undefined"); - } - return ; - })} - -
+
+ {metadata.map((m) => ( + + ))} +
); }; @@ -162,13 +92,19 @@ export const AgentMetadata: FC = ({ if (metadata === undefined) { return ( -
+
-
+ ); } - return ; + return ( + + a.description.display_name.localeCompare(b.description.display_name), + )} + /> + ); }; export const AgentMetadataSkeleton: FC = () => { @@ -192,6 +128,64 @@ export const AgentMetadataSkeleton: FC = () => { ); }; +interface MetadataItemProps { + item: WorkspaceAgentMetadata; +} + +const MetadataItem: FC = ({ item }) => { + const staleThreshold = Math.max( + item.description.interval + item.description.timeout * 2, + // In case there is intense backpressure, we give a little bit of slack. + 5, + ); + + const status: ItemStatus = (() => { + const year = dayjs(item.result.collected_at).year(); + if (year <= 1970 || isNaN(year)) { + return "loading"; + } + // There is a special circumstance for metadata with `interval: 0`. It is + // expected that they run once and never again, so never display them as + // stale. + if (item.result.age > staleThreshold && item.description.interval > 0) { + return "stale"; + } + return "valid"; + })(); + + // Stale data is as good as no data. Plus, we want to build confidence in our + // users that what's shown is real. If times aren't correctly synced this + // could be buggy. But, how common is that anyways? + const value = + status === "loading" ? ( + + ) : status === "stale" ? ( + + + {item.result.value} + + + ) : ( + + {item.result.value} + + ); + + return ( +
+
{item.description.display_name}
+
{value}
+
+ ); +}; + const StaticWidth: FC> = ({ children, ...attrs @@ -221,25 +215,20 @@ const StaticWidth: FC> = ({ // These are more or less copied from // site/src/components/Resources/ResourceCard.tsx const styles = { - root: (theme) => ({ - padding: "20px 32px", - borderTop: `1px solid ${theme.palette.divider}`, - overflowX: "auto", - scrollPadding: "0 32px", - }), + root: { + display: "flex", + alignItems: "baseline", + flexWrap: "wrap", + gap: 32, + rowGap: 16, + }, metadata: { - fontSize: 12, - lineHeight: "normal", + lineHeight: "1.6", display: "flex", flexDirection: "column", - gap: 4, overflow: "visible", - - // Because of scrolling - "&:last-child": { - paddingRight: 32, - }, + flexShrink: 0, }, metadataLabel: (theme) => ({ @@ -247,7 +236,7 @@ const styles = { textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap", - fontWeight: 500, + fontSize: 13, }), metadataValue: { @@ -259,9 +248,7 @@ const styles = { }, metadataValueSuccess: (theme) => ({ - // color: theme.palette.success.light, - color: theme.experimental.roles.success.fill, - // color: theme.experimental.roles.success.text, + color: theme.experimental.roles.success.outline, }), metadataValueError: (theme) => ({ diff --git a/site/src/components/Resources/AgentRow.stories.tsx b/site/src/components/Resources/AgentRow.stories.tsx index b760899683304..4b74c35eb9226 100644 --- a/site/src/components/Resources/AgentRow.stories.tsx +++ b/site/src/components/Resources/AgentRow.stories.tsx @@ -20,6 +20,7 @@ import { MockWorkspaceAgentDeprecated, MockWorkspaceApp, MockProxyLatencies, + MockListeningPortsResponse, } from "testHelpers/entities"; import { AgentRow, LineWithID } from "./AgentRow"; import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"; @@ -103,7 +104,15 @@ const storybookLogs: LineWithID[] = [ const meta: Meta = { title: "components/AgentRow", - parameters: { chromatic }, + parameters: { + chromatic, + queries: [ + { + key: ["portForward", MockWorkspaceAgent.id], + data: MockListeningPortsResponse, + }, + ], + }, component: AgentRow, args: { storybookLogs, diff --git a/site/src/components/Resources/AgentRow.test.tsx b/site/src/components/Resources/AgentRow.test.tsx new file mode 100644 index 0000000000000..bdedcce222fb7 --- /dev/null +++ b/site/src/components/Resources/AgentRow.test.tsx @@ -0,0 +1,102 @@ +import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities"; +import { AgentRow, AgentRowProps } from "./AgentRow"; +import { DisplayAppNameMap } from "./AppLink/AppLink"; +import { screen } from "@testing-library/react"; +import { + renderWithAuth, + waitForLoaderToBeRemoved, +} from "testHelpers/renderHelpers"; + +jest.mock("components/Resources/AgentMetadata", () => { + const AgentMetadata = () => <>; + return { AgentMetadata }; +}); + +describe.each<{ + result: "visible" | "hidden"; + props: Partial; +}>([ + { + result: "visible", + props: { + showApps: true, + agent: { + ...MockWorkspaceAgent, + display_apps: ["vscode", "vscode_insiders"], + status: "connected", + }, + hideVSCodeDesktopButton: false, + }, + }, + { + result: "hidden", + props: { + showApps: false, + agent: { + ...MockWorkspaceAgent, + display_apps: ["vscode", "vscode_insiders"], + status: "connected", + }, + hideVSCodeDesktopButton: false, + }, + }, + { + result: "hidden", + props: { + showApps: true, + agent: { + ...MockWorkspaceAgent, + display_apps: [], + status: "connected", + }, + hideVSCodeDesktopButton: false, + }, + }, + { + result: "hidden", + props: { + showApps: true, + agent: { + ...MockWorkspaceAgent, + display_apps: ["vscode", "vscode_insiders"], + status: "disconnected", + }, + hideVSCodeDesktopButton: false, + }, + }, + { + result: "hidden", + props: { + showApps: true, + agent: { + ...MockWorkspaceAgent, + display_apps: ["vscode", "vscode_insiders"], + status: "connected", + }, + hideVSCodeDesktopButton: true, + }, + }, +])("VSCode button visibility", ({ props: testProps, result }) => { + const props: AgentRowProps = { + agent: MockWorkspaceAgent, + workspace: MockWorkspace, + showApps: false, + serverVersion: "", + serverAPIVersion: "", + onUpdateAgent: function (): void { + throw new Error("Function not implemented."); + }, + ...testProps, + }; + + test(`visibility: ${result}, showApps: ${props.showApps}, hideVSCodeDesktopButton: ${props.hideVSCodeDesktopButton}, display apps: ${props.agent.display_apps}`, async () => { + renderWithAuth(); + await waitForLoaderToBeRemoved(); + + if (result === "visible") { + expect(screen.getByText(DisplayAppNameMap["vscode"])).toBeVisible(); + } else { + expect(screen.queryByText(DisplayAppNameMap["vscode"])).toBeNull(); + } + }); +}); diff --git a/site/src/components/Resources/AgentRow.tsx b/site/src/components/Resources/AgentRow.tsx index 36358d7f921dd..81abc9fbb45b6 100644 --- a/site/src/components/Resources/AgentRow.tsx +++ b/site/src/components/Resources/AgentRow.tsx @@ -31,12 +31,12 @@ import { FixedSizeList as List, ListOnScrollProps } from "react-window"; import { Stack } from "../Stack/Stack"; import { AgentLatency } from "./AgentLatency"; import { AgentMetadata } from "./AgentMetadata"; -import { AgentStatus } from "./AgentStatus"; import { AgentVersion } from "./AgentVersion"; import { AppLink } from "./AppLink/AppLink"; import { PortForwardButton } from "./PortForwardButton"; import { SSHButton } from "./SSHButton/SSHButton"; import { TerminalLink } from "./TerminalLink/TerminalLink"; +import { AgentStatus } from "./AgentStatus"; // Logs are stored as the Line interface to make rendering // much more efficient. Instead of mapping objects each time, we're @@ -79,6 +79,11 @@ export const AgentRow: FC = ({ showApps && ((agent.status === "connected" && hasAppsToDisplay) || agent.status === "connecting"); + const hasVSCodeApp = + agent.display_apps.includes("vscode") || + agent.display_apps.includes("vscode_insiders"); + const showVSCode = hasVSCodeApp && !hideVSCodeDesktopButton; + const logSourceByID = useMemo(() => { const sources: { [id: string]: WorkspaceAgentLogSource } = {}; for (const source of agent.log_sources) { @@ -163,54 +168,68 @@ export const AgentRow: FC = ({ styles[`agentRow-lifecycle-${agent.lifecycle_state}`], ]} > -
-
-
+
+
+
-
{agent.name}
- - {agent.status === "connected" && ( - <> - {agent.operating_system} - - - - )} - {agent.status === "connecting" && ( - <> - - - - )} - + {agent.name}
+ {agent.status === "connected" && ( + <> + + + + )} + {agent.status === "connecting" && ( + <> + + + + )}
+ {showBuiltinApps && ( +
+ {!hideSSHButton && agent.display_apps.includes("ssh_helper") && ( + + )} + {proxy.preferredWildcardHostname && + proxy.preferredWildcardHostname !== "" && + agent.display_apps.includes("port_forwarding_helper") && ( + + )} +
+ )} +
+ +
{agent.status === "connected" && ( -
+
{shouldDisplayApps && ( <> - {(agent.display_apps.includes("vscode") || - agent.display_apps.includes("vscode_insiders")) && - !hideVSCodeDesktopButton && ( - - )} + {showVSCode && ( + + )} {agent.apps.map((app) => ( = ({ )} - {showBuiltinApps && ( - <> - {agent.display_apps.includes("web_terminal") && ( - - )} - {!hideSSHButton && - agent.display_apps.includes("ssh_helper") && ( - - )} - {proxy.preferredWildcardHostname && - proxy.preferredWildcardHostname !== "" && - agent.display_apps.includes("port_forwarding_helper") && ( - - )} - + {showBuiltinApps && agent.display_apps.includes("web_terminal") && ( + )} -
+ )} {agent.status === "connecting" && ( -
+
= ({ variant="rectangular" css={styles.buttonSkeleton} /> -
+ )} -
- + +
{hasStartupFeatures && ( -
+
({ borderTop: `1px solid ${theme.palette.divider}` })} + > {({ width }) => ( @@ -430,16 +432,14 @@ export const AgentRow: FC = ({ -
- -
-
+ + )} ); @@ -505,78 +505,85 @@ const useAgentLogs = ( const styles = { agentRow: (theme) => ({ - fontSize: 16, - borderLeft: `2px solid ${theme.palette.text.secondary}`, - - "&:not(:first-of-type)": { - borderTop: `2px solid ${theme.palette.divider}`, - }, + fontSize: 14, + border: `1px solid ${theme.palette.text.secondary}`, + backgroundColor: theme.palette.background.default, + borderRadius: 8, }), "agentRow-connected": (theme) => ({ - borderLeftColor: theme.palette.success.light, + borderColor: theme.palette.success.light, }), "agentRow-disconnected": (theme) => ({ - borderLeftColor: theme.palette.text.secondary, + borderColor: theme.palette.divider, }), "agentRow-connecting": (theme) => ({ - borderLeftColor: theme.palette.info.light, + borderColor: theme.palette.info.light, }), "agentRow-timeout": (theme) => ({ - borderLeftColor: theme.palette.warning.light, + borderColor: theme.palette.warning.light, }), "agentRow-lifecycle-created": {}, "agentRow-lifecycle-starting": (theme) => ({ - borderLeftColor: theme.palette.info.light, + borderColor: theme.palette.info.light, }), "agentRow-lifecycle-ready": (theme) => ({ - borderLeftColor: theme.palette.success.light, + borderColor: theme.palette.success.light, }), "agentRow-lifecycle-start_timeout": (theme) => ({ - borderLeftColor: theme.palette.warning.light, + borderColor: theme.palette.warning.light, }), "agentRow-lifecycle-start_error": (theme) => ({ - borderLeftColor: theme.palette.error.light, + borderColor: theme.palette.error.light, }), "agentRow-lifecycle-shutting_down": (theme) => ({ - borderLeftColor: theme.palette.info.light, + borderColor: theme.palette.info.light, }), "agentRow-lifecycle-shutdown_timeout": (theme) => ({ - borderLeftColor: theme.palette.warning.light, + borderColor: theme.palette.warning.light, }), "agentRow-lifecycle-shutdown_error": (theme) => ({ - borderLeftColor: theme.palette.error.light, + borderColor: theme.palette.error.light, }), "agentRow-lifecycle-off": (theme) => ({ - borderLeftColor: theme.palette.text.secondary, + borderColor: theme.palette.divider, }), - agentInfo: (theme) => ({ - padding: "24px 32px", + header: (theme) => ({ + padding: "12px 24px", display: "flex", - gap: 16, + gap: 24, alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", - backgroundColor: theme.palette.background.paper, + lineHeight: "1.5", + borderBottom: `1px solid ${theme.palette.divider}`, [theme.breakpoints.down("md")]: { gap: 16, }, }), + agentInfo: (theme) => ({ + display: "flex", + alignItems: "center", + gap: 24, + color: theme.palette.text.secondary, + fontSize: 13, + }), + agentNameAndInfo: (theme) => ({ display: "flex", alignItems: "center", @@ -588,11 +595,22 @@ const styles = { }, }), - agentButtons: (theme) => ({ + content: { + padding: "32px 24px", display: "flex", - gap: 8, + flexDirection: "column", + gap: 32, + }, + + apps: (theme) => ({ + display: "flex", + gap: 16, flexWrap: "wrap", + "&:empty": { + display: "none", + }, + [theme.breakpoints.down("md")]: { marginLeft: 0, justifyContent: "flex-start", @@ -619,7 +637,7 @@ const styles = { agentNameAndStatus: (theme) => ({ display: "flex", alignItems: "center", - gap: 32, + gap: 12, [theme.breakpoints.down("md")]: { width: "100%", @@ -632,9 +650,10 @@ const styles = { textOverflow: "ellipsis", maxWidth: 260, fontWeight: 600, - fontSize: 16, flexShrink: 0, width: "fit-content", + fontSize: 14, + color: theme.palette.text.primary, [theme.breakpoints.down("md")]: { overflow: "unset", @@ -658,16 +677,12 @@ const styles = { }, }), - logsPanel: (theme) => ({ - borderTop: `1px solid ${theme.palette.divider}`, - }), - logsPanelButton: (theme) => ({ textAlign: "left", background: "transparent", border: 0, fontFamily: "inherit", - padding: "12px 32px", + padding: "12px 24px", color: theme.palette.text.secondary, cursor: "pointer", display: "flex", @@ -675,6 +690,8 @@ const styles = { gap: 8, whiteSpace: "nowrap", width: "100%", + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, "&:hover": { color: theme.palette.text.primary, diff --git a/site/src/components/Resources/AgentRowPreview.tsx b/site/src/components/Resources/AgentRowPreview.tsx index e4372a131571c..f088b5ca77f08 100644 --- a/site/src/components/Resources/AgentRowPreview.tsx +++ b/site/src/components/Resources/AgentRowPreview.tsx @@ -6,6 +6,7 @@ import { AppPreview } from "./AppLink/AppPreview"; import { BaseIcon } from "./AppLink/BaseIcon"; import { VSCodeIcon } from "components/Icons/VSCodeIcon"; import { DisplayAppNameMap } from "./AppLink/AppLink"; +import { TerminalIcon } from "components/Icons/TerminalIcon"; interface AgentRowPreviewStyles { // Helpful when there are more than one row so the values are aligned @@ -101,7 +102,10 @@ export const AgentRowPreview: FC = ({ {/* Additionally, we display any apps that are visible, e.g. apps that are included in agent.display_apps */} {agent.display_apps.includes("web_terminal") && ( - {DisplayAppNameMap["web_terminal"]} + + + {DisplayAppNameMap["web_terminal"]} + )} {agent.display_apps.includes("ssh_helper") && ( {DisplayAppNameMap["ssh_helper"]} diff --git a/site/src/components/Resources/AgentStatus.tsx b/site/src/components/Resources/AgentStatus.tsx index 0793377994b5c..ffb56953efa4a 100644 --- a/site/src/components/Resources/AgentStatus.tsx +++ b/site/src/components/Resources/AgentStatus.tsx @@ -272,8 +272,8 @@ export const AgentStatus: FC = ({ agent }) => { const styles = { status: { - width: 8, - height: 8, + width: 6, + height: 6, borderRadius: "100%", flexShrink: 0, }, @@ -306,15 +306,15 @@ const styles = { timeoutWarning: (theme) => ({ color: theme.palette.warning.light, - width: 16, - height: 16, + width: 14, + height: 14, position: "relative", }), errorWarning: (theme) => ({ color: theme.palette.error.main, - width: 16, - height: 16, + width: 14, + height: 14, position: "relative", }), } satisfies Record>; diff --git a/site/src/components/Resources/AppLink/AppLink.tsx b/site/src/components/Resources/AppLink/AppLink.tsx index 24afe3cef541f..75d03f6c477bc 100644 --- a/site/src/components/Resources/AppLink/AppLink.tsx +++ b/site/src/components/Resources/AppLink/AppLink.tsx @@ -67,7 +67,21 @@ export const AppLink: FC = ({ app, workspace, agent }) => { let primaryTooltip = ""; if (app.health === "initializing") { canClick = false; - icon = ; + icon = ( + // This is a hack to make the spinner appear in the center of the start + // icon space + + + + ); primaryTooltip = "Initializing..."; } if (app.health === "unhealthy") { @@ -93,75 +107,57 @@ export const AppLink: FC = ({ app, workspace, agent }) => { const isPrivateApp = app.sharing_level === "owner"; - const button = ( - } - disabled={!canClick} - > - - {appDisplayName} - - - ); - return ( - - { - event.preventDefault(); - // This is an external URI like "vscode://", so - // it needs to be opened with the browser protocol handler. - if (app.external && !app.url.startsWith("http")) { - // If the protocol is external the browser does not - // redirect the user from the page. + } + disabled={!canClick} + href={href} + target="_blank" + css={{ + pointerEvents: canClick ? undefined : "none", + textDecoration: "none !important", + }} + onClick={async (event) => { + if (!canClick) { + return; + } - // This is a magic undocumented string that is replaced - // with a brand-new session token from the backend. - // This only exists for external URLs, and should only - // be used internally, and is highly subject to break. - const magicTokenString = "$SESSION_TOKEN"; - const hasMagicToken = href.indexOf(magicTokenString); - let url = href; - if (hasMagicToken !== -1) { - setFetchingSessionToken(true); - const key = await getApiKey(); - url = href.replaceAll(magicTokenString, key.key); - setFetchingSessionToken(false); - } - window.location.href = url; - } else { - window.open( - href, - Language.appTitle( - appDisplayName, - generateRandomString(12), - ), - "width=900,height=600", - ); - } - } - : undefined + event.preventDefault(); + // This is an external URI like "vscode://", so + // it needs to be opened with the browser protocol handler. + if (app.external && !app.url.startsWith("http")) { + // If the protocol is external the browser does not + // redirect the user from the page. + + // This is a magic undocumented string that is replaced + // with a brand-new session token from the backend. + // This only exists for external URLs, and should only + // be used internally, and is highly subject to break. + const magicTokenString = "$SESSION_TOKEN"; + const hasMagicToken = href.indexOf(magicTokenString); + let url = href; + if (hasMagicToken !== -1) { + setFetchingSessionToken(true); + const key = await getApiKey(); + url = href.replaceAll(magicTokenString, key.key); + setFetchingSessionToken(false); + } + window.location.href = url; + } else { + window.open( + href, + Language.appTitle(appDisplayName, generateRandomString(12)), + "width=900,height=600", + ); } - > - {button} - - + }} + > + {appDisplayName} + ); }; diff --git a/site/src/components/Resources/PortForwardButton.tsx b/site/src/components/Resources/PortForwardButton.tsx index 2b284586eaf45..40a9cc11dc624 100644 --- a/site/src/components/Resources/PortForwardButton.tsx +++ b/site/src/components/Resources/PortForwardButton.tsx @@ -20,13 +20,12 @@ import { HelpTooltipText, HelpTooltipTitle, } from "components/HelpTooltip/HelpTooltip"; -import { AgentButton } from "components/Resources/AgentButton"; import { Popover, PopoverContent, PopoverTrigger, } from "components/Popover/Popover"; -import { DisplayAppNameMap } from "./AppLink/AppLink"; +import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; export interface PortForwardButtonProps { host: string; @@ -59,14 +58,24 @@ export const PortForwardButton: FC = (props) => { return ( - - {DisplayAppNameMap["port_forwarding_helper"]} - {data ? ( -
{data.ports.length}
- ) : ( - - )} -
+
@@ -214,8 +223,7 @@ const styles = { display: "flex", alignItems: "center", justifyContent: "center", - backgroundColor: theme.experimental.l2.background, - marginLeft: 8, + backgroundColor: theme.palette.action.selected, }), portLink: (theme) => ({ diff --git a/site/src/components/Resources/ResourceCard.stories.tsx b/site/src/components/Resources/ResourceCard.stories.tsx index 576ef68db7de2..56c373c2081e8 100644 --- a/site/src/components/Resources/ResourceCard.stories.tsx +++ b/site/src/components/Resources/ResourceCard.stories.tsx @@ -1,14 +1,12 @@ -import { action } from "@storybook/addon-actions"; import { MockProxyLatencies, - MockWorkspace, MockWorkspaceResource, } from "testHelpers/entities"; -import { AgentRow } from "./AgentRow"; import { ResourceCard } from "./ResourceCard"; import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"; import type { Meta, StoryObj } from "@storybook/react"; import { type WorkspaceAgent } from "api/typesGenerated"; +import { AgentRowPreview } from "./AgentRowPreview"; const meta: Meta = { title: "components/Resources/ResourceCard", @@ -93,15 +91,7 @@ function getAgentRow(agent: WorkspaceAgent): JSX.Element { }, }} > - + ); } diff --git a/site/src/components/Resources/ResourceCard.tsx b/site/src/components/Resources/ResourceCard.tsx index 17a4505801199..6e7188230a10d 100644 --- a/site/src/components/Resources/ResourceCard.tsx +++ b/site/src/components/Resources/ResourceCard.tsx @@ -1,7 +1,7 @@ import { type FC, type PropsWithChildren, useState } from "react"; import IconButton from "@mui/material/IconButton"; import Tooltip from "@mui/material/Tooltip"; -import { type CSSObject, type Interpolation, type Theme } from "@emotion/react"; +import { type Interpolation, type Theme } from "@emotion/react"; import { Children } from "react"; import type { WorkspaceAgent, WorkspaceResource } from "api/typesGenerated"; import { DropdownArrow } from "../DropdownArrow/DropdownArrow"; @@ -13,14 +13,28 @@ import { SensitiveValue } from "./SensitiveValue"; const styles = { resourceCard: (theme) => ({ - borderRadius: 8, border: `1px solid ${theme.palette.divider}`, background: theme.palette.background.default, + + "&:not(:last-child)": { + borderBottom: 0, + }, + + "&:first-child": { + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + }, + + "&:last-child": { + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + }, }), resourceCardProfile: { flexShrink: 0, width: "fit-content", + minWidth: 220, }, resourceCardHeader: (theme) => ({ @@ -37,9 +51,9 @@ const styles = { }, }), - metadata: (theme) => ({ - ...(theme.typography.body2 as CSSObject), - lineHeight: "120%", + metadata: () => ({ + lineHeight: "1.5", + fontSize: 14, }), metadataLabel: (theme) => ({ @@ -50,11 +64,10 @@ const styles = { whiteSpace: "nowrap", }), - metadataValue: (theme) => ({ + metadataValue: () => ({ textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap", - ...(theme.typography.body1 as CSSObject), }), } satisfies Record>; diff --git a/site/src/components/Resources/Resources.stories.tsx b/site/src/components/Resources/Resources.stories.tsx index 0a7693f5b4ead..8141b6516cc1d 100644 --- a/site/src/components/Resources/Resources.stories.tsx +++ b/site/src/components/Resources/Resources.stories.tsx @@ -1,15 +1,13 @@ -import { action } from "@storybook/addon-actions"; import { MockProxyLatencies, - MockWorkspace, MockWorkspaceResource, MockWorkspaceResourceMultipleAgents, } from "testHelpers/entities"; -import { AgentRow } from "./AgentRow"; import { Resources } from "./Resources"; import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"; import type { Meta, StoryObj } from "@storybook/react"; import { type WorkspaceAgent } from "api/typesGenerated"; +import { AgentRowPreview } from "./AgentRowPreview"; const meta: Meta = { title: "components/Resources/Resources", @@ -189,15 +187,7 @@ function getAgentRow(agent: WorkspaceAgent): JSX.Element { }, }} > - + ); } diff --git a/site/src/components/Resources/SSHButton/SSHButton.tsx b/site/src/components/Resources/SSHButton/SSHButton.tsx index 9788618df6406..62b55e9ab3764 100644 --- a/site/src/components/Resources/SSHButton/SSHButton.tsx +++ b/site/src/components/Resources/SSHButton/SSHButton.tsx @@ -14,8 +14,8 @@ import { PopoverTrigger, } from "components/Popover/Popover"; import { Stack } from "components/Stack/Stack"; -import { AgentButton } from "../AgentButton"; -import { DisplayAppNameMap } from "../AppLink/AppLink"; +import Button from "@mui/material/Button"; +import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; export interface SSHButtonProps { workspaceName: string; @@ -35,7 +35,14 @@ export const SSHButton: FC> = ({ return ( - {DisplayAppNameMap["ssh_helper"]} + diff --git a/site/src/components/Resources/TerminalLink/TerminalLink.tsx b/site/src/components/Resources/TerminalLink/TerminalLink.tsx index d1a8e4e9b170b..d73d7d5fe61cb 100644 --- a/site/src/components/Resources/TerminalLink/TerminalLink.tsx +++ b/site/src/components/Resources/TerminalLink/TerminalLink.tsx @@ -4,6 +4,7 @@ import { FC } from "react"; import * as TypesGen from "api/typesGenerated"; import { generateRandomString } from "utils/random"; import { DisplayAppNameMap } from "../AppLink/AppLink"; +import { TerminalIcon } from "components/Icons/TerminalIcon"; export const Language = { terminalTitle: (identifier: string): string => `Terminal - ${identifier}`, @@ -34,6 +35,10 @@ export const TerminalLink: FC> = ({ return ( } href={href} target="_blank" onClick={(event) => { @@ -46,7 +51,7 @@ export const TerminalLink: FC> = ({ }} data-testid="terminal" > - {DisplayAppNameMap["web_terminal"]} + {DisplayAppNameMap["web_terminal"]} ); }; diff --git a/site/src/components/Resources/VSCodeDesktopButton/VSCodeDesktopButton.tsx b/site/src/components/Resources/VSCodeDesktopButton/VSCodeDesktopButton.tsx index 33f4b8a0c3d35..3e96b67f2e144 100644 --- a/site/src/components/Resources/VSCodeDesktopButton/VSCodeDesktopButton.tsx +++ b/site/src/components/Resources/VSCodeDesktopButton/VSCodeDesktopButton.tsx @@ -48,16 +48,7 @@ export const VSCodeDesktopButton: FC< return includesVSCodeDesktop && includesVSCodeInsiders ? (
- button:hover + button": { - borderLeft: "1px solid #FFF", - }, - }} - > + {variant === "vscode" ? ( ) : ( diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 47d1fad733552..07a320c56c513 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -28,6 +28,14 @@ const meta: Meta = { title: "pages/WorkspacePage/Workspace", args: { permissions }, component: Workspace, + parameters: { + queries: [ + { + key: ["portForward", Mocks.MockWorkspaceAgent.id], + data: Mocks.MockListeningPortsResponse, + }, + ], + }, decorators: [ (Story) => ( void; @@ -184,6 +186,9 @@ export const Workspace: FC = ({
+ {selectedResource && ( + + )}
= ({ {buildLogs} {selectedResource && ( - ( +
+ {selectedResource.agents?.map((agent) => ( = ({ serverAPIVersion={buildInfo?.agent_api_version || ""} onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated /> + ))} + + {(!selectedResource.agents || + selectedResource.agents?.length === 0) && ( +
+
+

+ No agents are currently assigned to this resource. +

+
+
)} - /> +
)}
@@ -257,6 +282,55 @@ export const Workspace: FC = ({ ); }; +const WorkspaceResourceData: FC<{ resource: TypesGen.WorkspaceResource }> = ({ + resource, +}) => { + const metadata = resource.metadata ? [...resource.metadata] : []; + + if (resource.daily_cost > 0) { + metadata.push({ + key: "Daily cost", + value: resource.daily_cost.toString(), + sensitive: false, + }); + } + + if (metadata.length === 0) { + return null; + } + + return ( +
+ {metadata.map((meta) => { + return ( +
+
+ {meta.sensitive ? ( + + ) : ( + + {meta.value} + + )} +
+
{meta.key}
+
+ ); + })} +
+ ); +}; + +const MetaValue = ({ children }: PropsWithChildren) => { + const childrenArray = Children.toArray(children); + if (childrenArray.every((child) => typeof child === "string")) { + return ( + {children} + ); + } + return <>{children}; +}; + const countAgents = (resource: TypesGen.WorkspaceResource) => { return resource.agents ? resource.agents.length : 0; }; @@ -266,6 +340,7 @@ const styles = { padding: 24, gridArea: "content", overflowY: "auto", + position: "relative", }, dotBackground: (theme) => ({ @@ -290,4 +365,34 @@ const styles = { flexDirection: "column", }, }), + + resourceData: (theme) => ({ + padding: 24, + margin: "-48px 0 0 -48px", + display: "flex", + flexWrap: "wrap", + gap: 48, + rowGap: 24, + marginBottom: 24, + fontSize: 14, + background: `linear-gradient(180deg, ${theme.palette.background.default} 0%, rgba(0, 0, 0, 0) 100%)`, + }), + + resourceDataItem: () => ({ + lineHeight: "1.5", + }), + + resourceDataItemLabel: (theme) => ({ + fontSize: 13, + color: theme.palette.text.secondary, + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + }), + + resourceDataItemValue: () => ({ + textOverflow: "ellipsis", + overflow: "hidden", + whiteSpace: "nowrap", + }), } satisfies Record>; diff --git a/site/src/theme/mui.ts b/site/src/theme/mui.ts index 502561b472082..5393ace0788c9 100644 --- a/site/src/theme/mui.ts +++ b/site/src/theme/mui.ts @@ -109,6 +109,14 @@ export const components = { }, ["sizeXlarge" as any]: { height: BUTTON_XL_HEIGHT, + + // With higher size we need to increase icon spacing. + "& .MuiButton-startIcon": { + marginRight: 12, + }, + "& .MuiButton-endIcon": { + marginLeft: 12, + }, }, outlined: ({ theme }) => ({ ":hover": { @@ -144,9 +152,6 @@ export const components = { fontSize: 13, }, }, - startIcon: { - marginLeft: "-2px", - }, }, }, MuiButtonGroup: {