mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
refactor(site): refactor resource and agents (#11647)
This commit is contained in:
@ -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>
|
||||
|
@ -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) => ({
|
||||
|
@ -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,
|
||||
|
102
site/src/components/Resources/AgentRow.test.tsx
Normal file
102
site/src/components/Resources/AgentRow.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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>>;
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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) => ({
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>>;
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 }}>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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} />
|
||||
) : (
|
||||
|
@ -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
|
||||
|
@ -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>>;
|
||||
|
@ -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: {
|
||||
|
Reference in New Issue
Block a user