refactor(site): refactor resource and agents (#11647)

This commit is contained in:
Bruno Quaresma
2024-01-19 09:06:33 -03:00
committed by GitHub
parent 89fd29478d
commit 1f63a11396
18 changed files with 601 additions and 368 deletions

View File

@ -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<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const { children, ...buttonProps } = props;
const theme = useTheme();
return (
<Button
color="neutral"
{...buttonProps}
color="neutral"
size="xlarge"
variant="contained"
ref={ref}
css={{
backgroundColor: theme.palette.background.default,
"&:hover": {
backgroundColor: theme.palette.background.paper,
},
css={(theme) => ({
padding: "12px 20px",
color: theme.palette.text.primary,
// Making them smaller since those icons don't have a padding around them
"& .MuiButton-startIcon": {
width: 12,
height: 12,
"& .MuiButton-startIcon, & .MuiButton-endIcon": {
width: 16,
height: 16,
"& svg": { width: "100%", height: "100%" },
},
}}
})}
>
{children}
</Button>

View File

@ -24,71 +24,6 @@ type ItemStatus = "stale" | "valid" | "loading";
export const WatchAgentMetadataContext = createContext(watchAgentMetadata);
interface MetadataItemProps {
item: WorkspaceAgentMetadata;
}
const MetadataItem: FC<MetadataItemProps> = ({ 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" ? (
<Skeleton width={65} height={12} variant="text" css={styles.skeleton} />
) : status === "stale" ? (
<Tooltip title="This data is stale and no longer up to date">
<StaticWidth css={[styles.metadataValue, styles.metadataStale]}>
{item.result.value}
</StaticWidth>
</Tooltip>
) : (
<StaticWidth
css={[
styles.metadataValue,
item.result.error.length === 0
? styles.metadataValueSuccess
: styles.metadataValueError,
]}
>
{item.result.value}
</StaticWidth>
);
return (
<div css={styles.metadata}>
<div css={styles.metadataLabel}>{item.description.display_name}</div>
<div>{value}</div>
</div>
);
};
export interface AgentMetadataViewProps {
metadata: WorkspaceAgentMetadata[];
}
@ -98,16 +33,11 @@ export const AgentMetadataView: FC<AgentMetadataViewProps> = ({ metadata }) => {
return null;
}
return (
<div css={styles.root}>
<Stack alignItems="baseline" direction="row" spacing={6}>
{metadata.map((m) => {
if (m.description === undefined) {
throw new Error("Metadata item description is undefined");
}
return <MetadataItem key={m.description.key} item={m} />;
})}
</Stack>
</div>
<section css={styles.root}>
{metadata.map((m) => (
<MetadataItem key={m.description.key} item={m} />
))}
</section>
);
};
@ -162,13 +92,19 @@ export const AgentMetadata: FC<AgentMetadataProps> = ({
if (metadata === undefined) {
return (
<div css={styles.root}>
<section css={styles.root}>
<AgentMetadataSkeleton />
</div>
</section>
);
}
return <AgentMetadataView metadata={metadata} />;
return (
<AgentMetadataView
metadata={[...metadata].sort((a, b) =>
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<MetadataItemProps> = ({ 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" ? (
<Skeleton width={65} height={12} variant="text" css={styles.skeleton} />
) : status === "stale" ? (
<Tooltip title="This data is stale and no longer up to date">
<StaticWidth css={[styles.metadataValue, styles.metadataStale]}>
{item.result.value}
</StaticWidth>
</Tooltip>
) : (
<StaticWidth
css={[
styles.metadataValue,
item.result.error.length === 0
? styles.metadataValueSuccess
: styles.metadataValueError,
]}
>
{item.result.value}
</StaticWidth>
);
return (
<div css={styles.metadata}>
<div css={styles.metadataLabel}>{item.description.display_name}</div>
<div>{value}</div>
</div>
);
};
const StaticWidth: FC<HTMLAttributes<HTMLDivElement>> = ({
children,
...attrs
@ -221,25 +215,20 @@ const StaticWidth: FC<HTMLAttributes<HTMLDivElement>> = ({
// 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) => ({

View File

@ -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<typeof AgentRow> = {
title: "components/AgentRow",
parameters: { chromatic },
parameters: {
chromatic,
queries: [
{
key: ["portForward", MockWorkspaceAgent.id],
data: MockListeningPortsResponse,
},
],
},
component: AgentRow,
args: {
storybookLogs,

View File

@ -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<AgentRowProps>;
}>([
{
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(<AgentRow {...props} />);
await waitForLoaderToBeRemoved();
if (result === "visible") {
expect(screen.getByText(DisplayAppNameMap["vscode"])).toBeVisible();
} else {
expect(screen.queryByText(DisplayAppNameMap["vscode"])).toBeNull();
}
});
});

View File

@ -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<AgentRowProps> = ({
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<AgentRowProps> = ({
styles[`agentRow-lifecycle-${agent.lifecycle_state}`],
]}
>
<div css={styles.agentInfo}>
<div css={styles.agentNameAndStatus}>
<div css={styles.agentNameAndInfo}>
<header css={styles.header}>
<div css={styles.agentInfo}>
<div css={styles.agentNameAndStatus}>
<AgentStatus agent={agent} />
<div css={styles.agentName}>{agent.name}</div>
<Stack
direction="row"
spacing={2}
alignItems="baseline"
css={styles.agentDescription}
>
{agent.status === "connected" && (
<>
<span css={styles.agentOS}>{agent.operating_system}</span>
<AgentVersion
agent={agent}
serverVersion={serverVersion}
serverAPIVersion={serverAPIVersion}
onUpdate={onUpdateAgent}
/>
<AgentLatency agent={agent} />
</>
)}
{agent.status === "connecting" && (
<>
<Skeleton width={160} variant="text" />
<Skeleton width={36} variant="text" />
</>
)}
</Stack>
<span css={styles.agentName}>{agent.name}</span>
</div>
{agent.status === "connected" && (
<>
<AgentVersion
agent={agent}
serverVersion={serverVersion}
serverAPIVersion={serverAPIVersion}
onUpdate={onUpdateAgent}
/>
<AgentLatency agent={agent} />
</>
)}
{agent.status === "connecting" && (
<>
<Skeleton width={160} variant="text" />
<Skeleton width={36} variant="text" />
</>
)}
</div>
{showBuiltinApps && (
<div css={{ display: "flex" }}>
{!hideSSHButton && agent.display_apps.includes("ssh_helper") && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
sshPrefix={sshPrefix}
/>
)}
{proxy.preferredWildcardHostname &&
proxy.preferredWildcardHostname !== "" &&
agent.display_apps.includes("port_forwarding_helper") && (
<PortForwardButton
host={proxy.preferredWildcardHostname}
workspaceName={workspace.name}
agent={agent}
username={workspace.owner_name}
/>
)}
</div>
)}
</header>
<div css={styles.content}>
{agent.status === "connected" && (
<div css={styles.agentButtons}>
<section css={styles.apps}>
{shouldDisplayApps && (
<>
{(agent.display_apps.includes("vscode") ||
agent.display_apps.includes("vscode_insiders")) &&
!hideVSCodeDesktopButton && (
<VSCodeDesktopButton
userName={workspace.owner_name}
workspaceName={workspace.name}
agentName={agent.name}
folderPath={agent.expanded_directory}
displayApps={agent.display_apps}
/>
)}
{showVSCode && (
<VSCodeDesktopButton
userName={workspace.owner_name}
workspaceName={workspace.name}
agentName={agent.name}
folderPath={agent.expanded_directory}
displayApps={agent.display_apps}
/>
)}
{agent.apps.map((app) => (
<AppLink
key={app.slug}
@ -222,40 +241,18 @@ export const AgentRow: FC<AgentRowProps> = ({
</>
)}
{showBuiltinApps && (
<>
{agent.display_apps.includes("web_terminal") && (
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
)}
{!hideSSHButton &&
agent.display_apps.includes("ssh_helper") && (
<SSHButton
workspaceName={workspace.name}
agentName={agent.name}
sshPrefix={sshPrefix}
/>
)}
{proxy.preferredWildcardHostname &&
proxy.preferredWildcardHostname !== "" &&
agent.display_apps.includes("port_forwarding_helper") && (
<PortForwardButton
host={proxy.preferredWildcardHostname}
workspaceName={workspace.name}
agent={agent}
username={workspace.owner_name}
/>
)}
</>
{showBuiltinApps && agent.display_apps.includes("web_terminal") && (
<TerminalLink
workspaceName={workspace.name}
agentName={agent.name}
userName={workspace.owner_name}
/>
)}
</div>
</section>
)}
{agent.status === "connecting" && (
<div css={styles.agentButtons}>
<section css={styles.apps}>
<Skeleton
width={80}
height={32}
@ -268,14 +265,19 @@ export const AgentRow: FC<AgentRowProps> = ({
variant="rectangular"
css={styles.buttonSkeleton}
/>
</div>
</section>
)}
<AgentMetadata
storybookMetadata={storybookAgentMetadata}
agent={agent}
/>
</div>
<AgentMetadata storybookMetadata={storybookAgentMetadata} agent={agent} />
{hasStartupFeatures && (
<div css={styles.logsPanel}>
<section
css={(theme) => ({ borderTop: `1px solid ${theme.palette.divider}` })}
>
<Collapse in={showLogs}>
<AutoSizer disableHeight>
{({ width }) => (
@ -430,16 +432,14 @@ export const AgentRow: FC<AgentRowProps> = ({
</AutoSizer>
</Collapse>
<div css={{ display: "flex" }}>
<button
css={styles.logsPanelButton}
onClick={() => setShowLogs((v) => !v)}
>
<DropdownArrow close={showLogs} />
{showLogs ? "Hide" : "Show"} logs
</button>
</div>
</div>
<button
css={styles.logsPanelButton}
onClick={() => setShowLogs((v) => !v)}
>
<DropdownArrow close={showLogs} margin={false} />
{showLogs ? "Hide" : "Show"} logs
</button>
</section>
)}
</Stack>
);
@ -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,

View File

@ -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<AgentRowPreviewProps> = ({
{/* Additionally, we display any apps that are visible, e.g.
apps that are included in agent.display_apps */}
{agent.display_apps.includes("web_terminal") && (
<AppPreview>{DisplayAppNameMap["web_terminal"]}</AppPreview>
<AppPreview>
<TerminalIcon sx={{ width: 12, height: 12 }} />
{DisplayAppNameMap["web_terminal"]}
</AppPreview>
)}
{agent.display_apps.includes("ssh_helper") && (
<AppPreview>{DisplayAppNameMap["ssh_helper"]}</AppPreview>

View File

@ -272,8 +272,8 @@ export const AgentStatus: FC<AgentStatusProps> = ({ 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<string, Interpolation<Theme>>;

View File

@ -67,7 +67,21 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
let primaryTooltip = "";
if (app.health === "initializing") {
canClick = false;
icon = <CircularProgress size={12} />;
icon = (
// This is a hack to make the spinner appear in the center of the start
// icon space
<span
css={{
display: "flex",
width: "100%",
height: "100%",
alignItems: "center",
justifyContent: "center",
}}
>
<CircularProgress size={14} />
</span>
);
primaryTooltip = "Initializing...";
}
if (app.health === "unhealthy") {
@ -93,75 +107,57 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
const isPrivateApp = app.sharing_level === "owner";
const button = (
<AgentButton
startIcon={icon}
endIcon={isPrivateApp ? undefined : <ShareIcon app={app} />}
disabled={!canClick}
>
<span
css={
!isPrivateApp && {
marginRight: 8,
}
}
>
{appDisplayName}
</span>
</AgentButton>
);
return (
<Tooltip title={primaryTooltip}>
<span>
<Link
href={href}
target="_blank"
css={{
pointerEvents: canClick ? undefined : "none",
textDecoration: "none !important",
}}
onClick={
canClick
? async (event) => {
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",
);
}
}
: undefined
<Link
color="inherit"
component={AgentButton}
startIcon={icon}
endIcon={isPrivateApp ? undefined : <ShareIcon app={app} />}
disabled={!canClick}
href={href}
target="_blank"
css={{
pointerEvents: canClick ? undefined : "none",
textDecoration: "none !important",
}}
onClick={async (event) => {
if (!canClick) {
return;
}
>
{button}
</Link>
</span>
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",
);
}
}}
>
{appDisplayName}
</Link>
</Tooltip>
);
};

View File

@ -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<PortForwardButtonProps> = (props) => {
return (
<Popover>
<PopoverTrigger>
<AgentButton disabled={!data}>
{DisplayAppNameMap["port_forwarding_helper"]}
{data ? (
<div css={styles.portCount}>{data.ports.length}</div>
) : (
<CircularProgress size={10} css={{ marginLeft: 8 }} />
)}
</AgentButton>
<Button
disabled={!data}
size="small"
variant="text"
endIcon={<KeyboardArrowDown />}
css={{ fontSize: 13, padding: "8px 12px" }}
startIcon={
data ? (
<div>
<span css={styles.portCount}>{data.ports.length}</span>
</div>
) : (
<CircularProgress size={10} />
)
}
>
Open ports
</Button>
</PopoverTrigger>
<PopoverContent horizontal="right" classes={{ paper }}>
<PortForwardPopoverView {...props} ports={data?.ports} />
@ -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) => ({

View File

@ -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<typeof ResourceCard> = {
title: "components/Resources/ResourceCard",
@ -93,15 +91,7 @@ function getAgentRow(agent: WorkspaceAgent): JSX.Element {
},
}}
>
<AgentRow
showApps
key={agent.id}
agent={agent}
workspace={MockWorkspace}
serverVersion=""
serverAPIVersion=""
onUpdateAgent={action("updateAgent")}
/>
<AgentRowPreview agent={agent} key={agent.id} />
</ProxyContext.Provider>
);
}

View File

@ -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<string, Interpolation<Theme>>;

View File

@ -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<typeof Resources> = {
title: "components/Resources/Resources",
@ -189,15 +187,7 @@ function getAgentRow(agent: WorkspaceAgent): JSX.Element {
},
}}
>
<AgentRow
showApps
key={agent.id}
agent={agent}
workspace={MockWorkspace}
serverVersion=""
serverAPIVersion=""
onUpdateAgent={action("updateAgent")}
/>
<AgentRowPreview key={agent.id} agent={agent} />
</ProxyContext.Provider>
);
}

View File

@ -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<PropsWithChildren<SSHButtonProps>> = ({
return (
<Popover isDefaultOpen={isDefaultOpen}>
<PopoverTrigger>
<AgentButton>{DisplayAppNameMap["ssh_helper"]}</AgentButton>
<Button
size="small"
variant="text"
endIcon={<KeyboardArrowDown />}
css={{ fontSize: 13, padding: "8px 12px" }}
>
Connect to SSH
</Button>
</PopoverTrigger>
<PopoverContent horizontal="right" classes={{ paper }}>

View File

@ -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<React.PropsWithChildren<TerminalLinkProps>> = ({
return (
<Link
underline="none"
color="inherit"
component={AgentButton}
startIcon={<TerminalIcon />}
href={href}
target="_blank"
onClick={(event) => {
@ -46,7 +51,7 @@ export const TerminalLink: FC<React.PropsWithChildren<TerminalLinkProps>> = ({
}}
data-testid="terminal"
>
<AgentButton>{DisplayAppNameMap["web_terminal"]}</AgentButton>
{DisplayAppNameMap["web_terminal"]}
</Link>
);
};

View File

@ -48,16 +48,7 @@ export const VSCodeDesktopButton: FC<
return includesVSCodeDesktop && includesVSCodeInsiders ? (
<div>
<ButtonGroup
ref={menuAnchorRef}
variant="outlined"
css={{
// Workaround to make the border transitions smoothly on button groups
"& > button:hover + button": {
borderLeft: "1px solid #FFF",
},
}}
>
<ButtonGroup ref={menuAnchorRef} variant="outlined">
{variant === "vscode" ? (
<VSCodeButton {...props} />
) : (

View File

@ -28,6 +28,14 @@ const meta: Meta<typeof Workspace> = {
title: "pages/WorkspacePage/Workspace",
args: { permissions },
component: Workspace,
parameters: {
queries: [
{
key: ["portForward", Mocks.MockWorkspaceAgent.id],
data: Mocks.MockListeningPortsResponse,
},
],
},
decorators: [
(Story) => (
<DashboardProviderContext.Provider

View File

@ -1,7 +1,7 @@
import { type Interpolation, type Theme } from "@emotion/react";
import Button from "@mui/material/Button";
import AlertTitle from "@mui/material/AlertTitle";
import { type FC } from "react";
import { PropsWithChildren, type FC, Children } from "react";
import { useNavigate } from "react-router-dom";
import type * as TypesGen from "api/typesGenerated";
import { Alert, AlertDetail } from "components/Alert/Alert";
@ -19,9 +19,11 @@ import { useTheme } from "@mui/material/styles";
import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
import HubOutlined from "@mui/icons-material/HubOutlined";
import { ResourcesSidebar } from "./ResourcesSidebar";
import { ResourceCard } from "components/Resources/ResourceCard";
import { WorkspacePermissions } from "./permissions";
import { resourceOptionValue, useResourcesNav } from "./useResourcesNav";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
import { SensitiveValue } from "components/Resources/SensitiveValue";
import { CopyableValue } from "components/CopyableValue/CopyableValue";
export interface WorkspaceProps {
handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
@ -184,6 +186,9 @@ export const Workspace: FC<WorkspaceProps> = ({
<div css={styles.content}>
<div css={styles.dotBackground}>
{selectedResource && (
<WorkspaceResourceData resource={selectedResource} />
)}
<div
css={{
display: "flex",
@ -231,9 +236,10 @@ export const Workspace: FC<WorkspaceProps> = ({
{buildLogs}
{selectedResource && (
<ResourceCard
resource={selectedResource}
agentRow={(agent) => (
<section
css={{ display: "flex", flexDirection: "column", gap: 24 }}
>
{selectedResource.agents?.map((agent) => (
<AgentRow
key={agent.id}
agent={agent}
@ -247,8 +253,27 @@ export const Workspace: FC<WorkspaceProps> = ({
serverAPIVersion={buildInfo?.agent_api_version || ""}
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
/>
))}
{(!selectedResource.agents ||
selectedResource.agents?.length === 0) && (
<div
css={{
display: "flex",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
}}
>
<div>
<h4 css={{ fontSize: 16, fontWeight: 500 }}>
No agents are currently assigned to this resource.
</h4>
</div>
</div>
)}
/>
</section>
)}
</div>
</div>
@ -257,6 +282,55 @@ export const Workspace: FC<WorkspaceProps> = ({
);
};
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 (
<header css={styles.resourceData}>
{metadata.map((meta) => {
return (
<div css={styles.resourceDataItem} key={meta.key}>
<div css={styles.resourceDataItemValue}>
{meta.sensitive ? (
<SensitiveValue value={meta.value} />
) : (
<MemoizedInlineMarkdown components={{ p: MetaValue }}>
{meta.value}
</MemoizedInlineMarkdown>
)}
</div>
<div css={styles.resourceDataItemLabel}>{meta.key}</div>
</div>
);
})}
</header>
);
};
const MetaValue = ({ children }: PropsWithChildren) => {
const childrenArray = Children.toArray(children);
if (childrenArray.every((child) => typeof child === "string")) {
return (
<CopyableValue value={childrenArray.join("")}>{children}</CopyableValue>
);
}
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<string, Interpolation<Theme>>;

View File

@ -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: {