mirror of https://github.com/coder/coder.git
feat(site): refactor workspace header to be more slim (#11327)
This commit is contained in:
parent
608937c79c
commit
cf17fabcc6
|
@ -21,8 +21,8 @@
|
|||
"contravariance",
|
||||
"cronstrue",
|
||||
"databasefake",
|
||||
"dbmem",
|
||||
"dbgen",
|
||||
"dbmem",
|
||||
"dbtype",
|
||||
"DERP",
|
||||
"derphttp",
|
||||
|
@ -118,13 +118,13 @@
|
|||
"stretchr",
|
||||
"STTY",
|
||||
"stuntest",
|
||||
"tanstack",
|
||||
"tailbroker",
|
||||
"tailcfg",
|
||||
"tailexchange",
|
||||
"tailnet",
|
||||
"tailnettest",
|
||||
"Tailscale",
|
||||
"tanstack",
|
||||
"tbody",
|
||||
"TCGETS",
|
||||
"tcpip",
|
||||
|
@ -141,6 +141,7 @@
|
|||
"tios",
|
||||
"tmpdir",
|
||||
"tokenconfig",
|
||||
"Topbar",
|
||||
"tparallel",
|
||||
"trialer",
|
||||
"trimprefix",
|
||||
|
|
|
@ -17,7 +17,6 @@ export const decorators = [
|
|||
(Story, context) => {
|
||||
const selectedTheme = DecoratorHelpers.pluckThemeFromContext(context);
|
||||
const { themeOverride } = DecoratorHelpers.useThemeParameters();
|
||||
|
||||
const selected = themeOverride || selectedTheme || "dark";
|
||||
|
||||
return (
|
||||
|
@ -39,23 +38,7 @@ export const decorators = [
|
|||
</HelmetProvider>
|
||||
);
|
||||
},
|
||||
(Story) => {
|
||||
return (
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
},
|
||||
withQuery,
|
||||
];
|
||||
|
||||
export const parameters = {
|
||||
|
@ -89,3 +72,25 @@ export const parameters = {
|
|||
},
|
||||
},
|
||||
};
|
||||
|
||||
function withQuery(Story, { parameters }) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (parameters.queries) {
|
||||
parameters.queries.forEach((query) => {
|
||||
queryClient.setQueryData(query.key, query.data);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Story />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -47,12 +47,9 @@ export const createWorkspace = async (
|
|||
|
||||
await expect(page).toHaveURL("/@admin/" + name);
|
||||
|
||||
await page.waitForSelector(
|
||||
"span[data-testid='build-status'] >> text=Running",
|
||||
{
|
||||
state: "visible",
|
||||
},
|
||||
);
|
||||
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
|
||||
state: "visible",
|
||||
});
|
||||
return name;
|
||||
};
|
||||
|
||||
|
@ -197,12 +194,9 @@ export const stopWorkspace = async (page: Page, workspaceName: string) => {
|
|||
|
||||
await page.getByTestId("workspace-stop-button").click();
|
||||
|
||||
await page.waitForSelector(
|
||||
"span[data-testid='build-status'] >> text=Stopped",
|
||||
{
|
||||
state: "visible",
|
||||
},
|
||||
);
|
||||
await page.waitForSelector("*[data-testid='build-status'] >> text=Stopped", {
|
||||
state: "visible",
|
||||
});
|
||||
};
|
||||
|
||||
export const buildWorkspaceWithParameters = async (
|
||||
|
@ -225,12 +219,9 @@ export const buildWorkspaceWithParameters = async (
|
|||
await page.getByTestId("confirm-button").click();
|
||||
}
|
||||
|
||||
await page.waitForSelector(
|
||||
"span[data-testid='build-status'] >> text=Running",
|
||||
{
|
||||
state: "visible",
|
||||
},
|
||||
);
|
||||
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
|
||||
state: "visible",
|
||||
});
|
||||
};
|
||||
|
||||
// startAgent runs the coder agent with the provided token.
|
||||
|
@ -772,12 +763,9 @@ export const updateWorkspace = async (
|
|||
await fillParameters(page, richParameters, buildParameters);
|
||||
await page.getByTestId("form-submit").click();
|
||||
|
||||
await page.waitForSelector(
|
||||
"span[data-testid='build-status'] >> text=Running",
|
||||
{
|
||||
state: "visible",
|
||||
},
|
||||
);
|
||||
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
|
||||
state: "visible",
|
||||
});
|
||||
};
|
||||
|
||||
export const updateWorkspaceParameters = async (
|
||||
|
@ -796,10 +784,7 @@ export const updateWorkspaceParameters = async (
|
|||
await fillParameters(page, richParameters, buildParameters);
|
||||
await page.getByTestId("form-submit").click();
|
||||
|
||||
await page.waitForSelector(
|
||||
"span[data-testid='build-status'] >> text=Running",
|
||||
{
|
||||
state: "visible",
|
||||
},
|
||||
);
|
||||
await page.waitForSelector("*[data-testid='build-status'] >> text=Running", {
|
||||
state: "visible",
|
||||
});
|
||||
};
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import * as _storybook_types from "@storybook/react";
|
||||
import { Experiments, FeatureName } from "api/typesGenerated";
|
||||
import { QueryKey } from "react-query";
|
||||
|
||||
declare module "@storybook/react" {
|
||||
interface Parameters {
|
||||
features?: FeatureName[];
|
||||
experiments?: Experiments;
|
||||
queries?: { key: QueryKey; data: unknown }[];
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import * as API from "api/api";
|
||||
|
||||
const getWorkspaceQuotaQueryKey = (username: string) => [
|
||||
export const getWorkspaceQuotaQueryKey = (username: string) => [
|
||||
username,
|
||||
"workspaceQuota",
|
||||
];
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
import { css } from "@emotion/css";
|
||||
import Button, { ButtonProps } from "@mui/material/Button";
|
||||
import IconButton, { IconButtonProps } from "@mui/material/IconButton";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { Avatar, AvatarProps } from "components/Avatar/Avatar";
|
||||
import {
|
||||
ForwardedRef,
|
||||
HTMLAttributes,
|
||||
PropsWithChildren,
|
||||
ReactElement,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
|
||||
export const Topbar = (props: HTMLAttributes<HTMLDivElement>) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<header
|
||||
{...props}
|
||||
css={{
|
||||
minHeight: 48,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontSize: 13,
|
||||
lineHeight: "1.2",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TopbarIconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
{...props}
|
||||
size="small"
|
||||
css={{
|
||||
padding: 0,
|
||||
borderRadius: 0,
|
||||
height: 48,
|
||||
width: 48,
|
||||
|
||||
"& svg": {
|
||||
fontSize: 20,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
) as typeof IconButton;
|
||||
|
||||
export const TopbarButton = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(props: ButtonProps, ref) => {
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
color="neutral"
|
||||
css={{
|
||||
height: 28,
|
||||
fontSize: 13,
|
||||
borderRadius: 4,
|
||||
padding: "0 12px",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const TopbarData = (props: HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
css={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const TopbarDivider = (props: HTMLAttributes<HTMLSpanElement>) => {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<span {...props} css={{ color: theme.palette.divider }}>
|
||||
/
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const TopbarAvatar = (props: AvatarProps) => {
|
||||
return (
|
||||
<Avatar
|
||||
{...props}
|
||||
variant="square"
|
||||
fitImage
|
||||
css={{ width: 16, height: 16 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type TopbarIconProps = PropsWithChildren<HTMLAttributes<HTMLOrSVGElement>>;
|
||||
|
||||
export const TopbarIcon = forwardRef<HTMLOrSVGElement, TopbarIconProps>(
|
||||
(props: TopbarIconProps, ref) => {
|
||||
const { children, ...restProps } = props;
|
||||
const theme = useTheme();
|
||||
|
||||
return cloneElement(
|
||||
children as ReactElement<
|
||||
HTMLAttributes<HTMLOrSVGElement> & {
|
||||
ref: ForwardedRef<HTMLOrSVGElement>;
|
||||
}
|
||||
>,
|
||||
{
|
||||
...restProps,
|
||||
ref,
|
||||
className: css({ fontSize: 16, color: theme.palette.text.disabled }),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
|
@ -1,61 +0,0 @@
|
|||
import { StatsItem } from "components/Stats/Stats";
|
||||
import Link from "@mui/material/Link";
|
||||
import { type FC } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import type { Workspace } from "api/typesGenerated";
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
import { displayDormantDeletion } from "./utils";
|
||||
|
||||
interface DormantDeletionStatProps {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
export const DormantDeletionStat: FC<DormantDeletionStatProps> = ({
|
||||
workspace,
|
||||
}) => {
|
||||
const { entitlements, experiments } = useDashboard();
|
||||
const allowAdvancedScheduling =
|
||||
entitlements.features["advanced_template_scheduling"].enabled;
|
||||
// This check can be removed when https://github.com/coder/coder/milestone/19
|
||||
// is merged up
|
||||
const allowWorkspaceActions = experiments.includes("workspace_actions");
|
||||
|
||||
if (
|
||||
!displayDormantDeletion(
|
||||
workspace,
|
||||
allowAdvancedScheduling,
|
||||
allowWorkspaceActions,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatsItem
|
||||
label="Deletion on"
|
||||
className="containerClass"
|
||||
value={
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/templates/${workspace.template_name}/settings/schedule`}
|
||||
title="Schedule settings"
|
||||
>
|
||||
{/* We check for string existence in the conditional */}
|
||||
{new Date(workspace.deleting_at!).toLocaleString()}
|
||||
</Link>
|
||||
}
|
||||
css={{
|
||||
"&.containerClass": {
|
||||
flexDirection: "column",
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
|
||||
"& > span:first-of-type": {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import { type FC } from "react";
|
||||
import type { Workspace } from "api/typesGenerated";
|
||||
import { displayDormantDeletion } from "./utils";
|
||||
import { displayDormantDeletion } from "utils/dormant";
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
|
||||
interface DormantDeletionTextProps {
|
||||
|
|
|
@ -1,3 +1,2 @@
|
|||
export * from "./DormantDeletionStat";
|
||||
export * from "./DormantDeletionText";
|
||||
export * from "./DormantWorkspaceBanner";
|
||||
|
|
|
@ -42,12 +42,12 @@ export const WorkspaceOutdatedTooltip: FC<TooltipProps> = (props) => {
|
|||
<InfoIcon css={styles.icon} />
|
||||
</HelpTooltipTrigger>
|
||||
|
||||
<OutdatedTooltipContent {...props} />
|
||||
<WorkspaceOutdatedTooltipContent {...props} />
|
||||
</HelpTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const OutdatedTooltipContent = (props: TooltipProps) => {
|
||||
export const WorkspaceOutdatedTooltipContent = (props: TooltipProps) => {
|
||||
const popover = usePopover();
|
||||
const { onUpdateVersion, ariaLabel, latestVersionId, templateName } = props;
|
||||
const { data: activeVersion } = useQuery({
|
||||
|
@ -62,7 +62,7 @@ const OutdatedTooltipContent = (props: TooltipProps) => {
|
|||
<HelpTooltipText>{Language.versionTooltipText}</HelpTooltipText>
|
||||
|
||||
<div css={styles.container}>
|
||||
<div>
|
||||
<div css={{ lineHeight: "1.6" }}>
|
||||
<div css={styles.bold}>New version</div>
|
||||
<div>
|
||||
{activeVersion ? (
|
||||
|
@ -79,7 +79,7 @@ const OutdatedTooltipContent = (props: TooltipProps) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div css={{ lineHeight: "1.6" }}>
|
||||
<div css={styles.bold}>Message</div>
|
||||
<div>
|
||||
{activeVersion ? (
|
||||
|
|
|
@ -47,13 +47,25 @@ export const WorkspaceStatusBadge: FC<WorkspaceStatusBadgeProps> = ({
|
|||
}
|
||||
placement="top"
|
||||
>
|
||||
<Pill role="status" className={className} icon={icon} type={type}>
|
||||
<Pill
|
||||
role="status"
|
||||
data-testid="build-status"
|
||||
className={className}
|
||||
icon={icon}
|
||||
type={type}
|
||||
>
|
||||
{text}
|
||||
</Pill>
|
||||
</FailureTooltip>
|
||||
</Cond>
|
||||
<Cond>
|
||||
<Pill role="status" className={className} icon={icon} type={type}>
|
||||
<Pill
|
||||
role="status"
|
||||
data-testid="build-status"
|
||||
className={className}
|
||||
icon={icon}
|
||||
type={type}
|
||||
>
|
||||
{text}
|
||||
</Pill>
|
||||
</Cond>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Button, { type ButtonProps } from "@mui/material/Button";
|
||||
import Button from "@mui/material/Button";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import CreateIcon from "@mui/icons-material/AddOutlined";
|
||||
|
@ -12,7 +12,6 @@ import type {
|
|||
} from "api/typesGenerated";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Alert, AlertDetail } from "components/Alert/Alert";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable";
|
||||
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import { PublishVersionData } from "pages/TemplateVersionEditorPage/types";
|
||||
|
@ -45,6 +44,14 @@ import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
|
|||
import CloseOutlined from "@mui/icons-material/CloseOutlined";
|
||||
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import {
|
||||
Topbar,
|
||||
TopbarAvatar,
|
||||
TopbarButton,
|
||||
TopbarData,
|
||||
TopbarDivider,
|
||||
TopbarIconButton,
|
||||
} from "components/FullPageLayout/Topbar";
|
||||
|
||||
type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab
|
||||
|
||||
|
@ -176,48 +183,26 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
|||
return (
|
||||
<>
|
||||
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
<Topbar
|
||||
css={{
|
||||
height: 48,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 2fr 1fr",
|
||||
alignItems: "center",
|
||||
}}
|
||||
data-testid="topbar"
|
||||
>
|
||||
<div>
|
||||
<Tooltip title="Back to the template">
|
||||
<IconButton
|
||||
<TopbarIconButton
|
||||
component={RouterLink}
|
||||
to={`/templates/${template.name}`}
|
||||
size="small"
|
||||
css={{
|
||||
padding: "0 16px",
|
||||
borderRadius: 0,
|
||||
height: 48,
|
||||
}}
|
||||
>
|
||||
<ArrowBackOutlined css={{ width: 20, height: 20 }} />
|
||||
</IconButton>
|
||||
<ArrowBackOutlined />
|
||||
</TopbarIconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
css={{
|
||||
fontSize: 13,
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
src={template.icon}
|
||||
variant="square"
|
||||
fitImage
|
||||
css={{ width: 16, height: 16 }}
|
||||
/>
|
||||
<TopbarData>
|
||||
<TopbarAvatar src={template.icon} />
|
||||
<RouterLink
|
||||
to={`/templates/${template.name}`}
|
||||
css={{
|
||||
|
@ -231,11 +216,11 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
|||
>
|
||||
{template.display_name || template.name}
|
||||
</RouterLink>
|
||||
<span css={{ color: theme.palette.divider }}>/</span>
|
||||
<TopbarDivider />
|
||||
<span css={{ color: theme.palette.text.secondary }}>
|
||||
{templateVersion.name}
|
||||
</span>
|
||||
</div>
|
||||
</TopbarData>
|
||||
|
||||
<div
|
||||
css={{
|
||||
|
@ -273,7 +258,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
|||
Publish
|
||||
</TopbarButton>
|
||||
</div>
|
||||
</div>
|
||||
</Topbar>
|
||||
|
||||
<div
|
||||
css={{
|
||||
|
@ -458,7 +443,6 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
|||
<div
|
||||
css={{
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
|
@ -644,22 +628,6 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const TopbarButton: FC<ButtonProps> = ({ children, ...buttonProps }) => {
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
css={{
|
||||
height: 28,
|
||||
fontSize: 13,
|
||||
borderRadius: 4,
|
||||
padding: "0 12px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
tab: (theme) => ({
|
||||
"&:not(:disabled)": {
|
||||
|
|
|
@ -9,25 +9,17 @@ import { Alert, AlertDetail } from "components/Alert/Alert";
|
|||
import { Margins } from "components/Margins/Margins";
|
||||
import { Resources } from "components/Resources/Resources";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import {
|
||||
FullWidthPageHeader,
|
||||
PageHeaderActions,
|
||||
PageHeaderTitle,
|
||||
PageHeaderSubtitle,
|
||||
} from "components/PageHeader/FullWidthPageHeader";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { DormantWorkspaceBanner } from "components/WorkspaceDeletion";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AgentRow } from "components/Resources/AgentRow";
|
||||
import { useLocalStorage } from "hooks";
|
||||
import { WorkspaceActions } from "pages/WorkspacePage/WorkspaceActions/WorkspaceActions";
|
||||
import {
|
||||
ActiveTransition,
|
||||
WorkspaceBuildProgress,
|
||||
} from "./WorkspaceBuildProgress";
|
||||
import { BuildsTable } from "./BuildsTable";
|
||||
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
|
||||
import { WorkspaceStats } from "./WorkspaceStats";
|
||||
import { WorkspaceTopbar } from "./WorkspaceTopbar/WorkspaceTopbar";
|
||||
|
||||
export type WorkspaceError =
|
||||
| "getBuildsError"
|
||||
|
@ -59,7 +51,6 @@ export interface WorkspaceProps {
|
|||
buildInfo?: TypesGen.BuildInfoResponse;
|
||||
sshPrefix?: string;
|
||||
template?: TypesGen.Template;
|
||||
quotaBudget?: number;
|
||||
canRetryDebugMode: boolean;
|
||||
handleBuildRetry: () => void;
|
||||
handleBuildRetryDebug: () => void;
|
||||
|
@ -159,51 +150,25 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<FullWidthPageHeader>
|
||||
<Stack direction="row" spacing={3} alignItems="center">
|
||||
<Avatar
|
||||
size="md"
|
||||
src={workspace.template_icon}
|
||||
variant={workspace.template_icon ? "square" : undefined}
|
||||
fitImage={Boolean(workspace.template_icon)}
|
||||
>
|
||||
{workspace.name}
|
||||
</Avatar>
|
||||
<div>
|
||||
<PageHeaderTitle>{workspace.name}</PageHeaderTitle>
|
||||
<PageHeaderSubtitle>{workspace.owner_name}</PageHeaderSubtitle>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<WorkspaceStats
|
||||
workspace={workspace}
|
||||
handleUpdate={handleUpdate}
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
/>
|
||||
|
||||
{canUpdateWorkspace && (
|
||||
<PageHeaderActions>
|
||||
<WorkspaceActions
|
||||
workspace={workspace}
|
||||
handleStart={handleStart}
|
||||
handleStop={handleStop}
|
||||
handleRestart={handleRestart}
|
||||
handleDelete={handleDelete}
|
||||
handleUpdate={handleUpdate}
|
||||
handleCancel={handleCancel}
|
||||
handleSettings={handleSettings}
|
||||
handleRetry={handleBuildRetry}
|
||||
handleRetryDebug={handleBuildRetryDebug}
|
||||
handleChangeVersion={handleChangeVersion}
|
||||
handleDormantActivate={handleDormantActivate}
|
||||
canRetryDebug={canRetryDebugMode}
|
||||
canChangeVersions={canChangeVersions}
|
||||
isUpdating={isUpdating}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</PageHeaderActions>
|
||||
)}
|
||||
</FullWidthPageHeader>
|
||||
<WorkspaceTopbar
|
||||
workspace={workspace}
|
||||
handleStart={handleStart}
|
||||
handleStop={handleStop}
|
||||
handleRestart={handleRestart}
|
||||
handleDelete={handleDelete}
|
||||
handleUpdate={handleUpdate}
|
||||
handleCancel={handleCancel}
|
||||
handleSettings={handleSettings}
|
||||
handleBuildRetry={handleBuildRetry}
|
||||
handleBuildRetryDebug={handleBuildRetryDebug}
|
||||
handleChangeVersion={handleChangeVersion}
|
||||
handleDormantActivate={handleDormantActivate}
|
||||
canRetryDebugMode={canRetryDebugMode}
|
||||
canChangeVersions={canChangeVersions}
|
||||
isUpdating={isUpdating}
|
||||
isRestarting={isRestarting}
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
/>
|
||||
|
||||
<Margins css={styles.content}>
|
||||
<Stack direction="column" css={styles.firstColumnSpacer} spacing={4}>
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
PopoverTrigger,
|
||||
usePopover,
|
||||
} from "components/Popover/Popover";
|
||||
import { TopbarButton } from "components/FullPageLayout/Topbar";
|
||||
|
||||
interface BuildParametersPopoverProps {
|
||||
workspace: Workspace;
|
||||
|
@ -51,14 +52,14 @@ export const BuildParametersPopover: FC<BuildParametersPopoverProps> = ({
|
|||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
<TopbarButton
|
||||
data-testid="build-parameters-button"
|
||||
disabled={disabled}
|
||||
color="neutral"
|
||||
css={{ paddingLeft: 0, paddingRight: 0 }}
|
||||
css={{ paddingLeft: 0, paddingRight: 0, minWidth: "28px !important" }}
|
||||
>
|
||||
<ExpandMoreOutlined css={{ fontSize: 16 }} />
|
||||
</Button>
|
||||
<ExpandMoreOutlined css={{ fontSize: 14 }} />
|
||||
</TopbarButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
horizontal="right"
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import Tooltip from "@mui/material/Tooltip";
|
||||
import Button from "@mui/material/Button";
|
||||
import LoadingButton from "@mui/lab/LoadingButton";
|
||||
import ButtonGroup from "@mui/material/ButtonGroup";
|
||||
import CloudQueueIcon from "@mui/icons-material/CloudQueue";
|
||||
import CropSquareIcon from "@mui/icons-material/CropSquare";
|
||||
|
@ -14,6 +12,7 @@ import RetryDebugIcon from "@mui/icons-material/BugReportOutlined";
|
|||
import { type FC } from "react";
|
||||
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
|
||||
import { BuildParametersPopover } from "./BuildParametersPopover";
|
||||
import { TopbarButton } from "components/FullPageLayout/Topbar";
|
||||
|
||||
interface ActionButtonProps {
|
||||
loading?: boolean;
|
||||
|
@ -27,15 +26,14 @@ export const UpdateButton: FC<ActionButtonProps> = ({
|
|||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingPosition="start"
|
||||
<TopbarButton
|
||||
disabled={loading}
|
||||
data-testid="workspace-update-button"
|
||||
startIcon={<CloudQueueIcon />}
|
||||
onClick={() => handleAction()}
|
||||
>
|
||||
{loading ? <>Updating…</> : <>Update…</>}
|
||||
</LoadingButton>
|
||||
</TopbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -44,14 +42,13 @@ export const ActivateButton: FC<ActionButtonProps> = ({
|
|||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingPosition="start"
|
||||
<TopbarButton
|
||||
disabled={loading}
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
onClick={() => handleAction()}
|
||||
>
|
||||
{loading ? <>Activating…</> : "Activate"}
|
||||
</LoadingButton>
|
||||
</TopbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -77,15 +74,13 @@ export const StartButton: FC<ActionButtonPropsWithWorkspace> = ({
|
|||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingPosition="start"
|
||||
<TopbarButton
|
||||
startIcon={<PlayCircleOutlineIcon />}
|
||||
onClick={() => handleAction()}
|
||||
disabled={disabled}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? <>Starting…</> : "Start"}
|
||||
</LoadingButton>
|
||||
</TopbarButton>
|
||||
<BuildParametersPopover
|
||||
workspace={workspace}
|
||||
disabled={loading}
|
||||
|
@ -106,15 +101,14 @@ export const StopButton: FC<ActionButtonProps> = ({
|
|||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingPosition="start"
|
||||
<TopbarButton
|
||||
disabled={loading}
|
||||
startIcon={<CropSquareIcon />}
|
||||
onClick={() => handleAction()}
|
||||
data-testid="workspace-stop-button"
|
||||
>
|
||||
{loading ? <>Stopping…</> : "Stop"}
|
||||
</LoadingButton>
|
||||
</TopbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -136,16 +130,14 @@ export const RestartButton: FC<ActionButtonPropsWithWorkspace> = ({
|
|||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingPosition="start"
|
||||
<TopbarButton
|
||||
startIcon={<ReplayIcon />}
|
||||
onClick={() => handleAction()}
|
||||
data-testid="workspace-restart-button"
|
||||
disabled={disabled}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? <>Restarting…</> : <>Restart…</>}
|
||||
</LoadingButton>
|
||||
</TopbarButton>
|
||||
<BuildParametersPopover
|
||||
workspace={workspace}
|
||||
disabled={loading}
|
||||
|
@ -163,9 +155,9 @@ export const RestartButton: FC<ActionButtonPropsWithWorkspace> = ({
|
|||
|
||||
export const CancelButton: FC<ActionButtonProps> = ({ handleAction }) => {
|
||||
return (
|
||||
<Button startIcon={<BlockIcon />} onClick={() => handleAction()}>
|
||||
<TopbarButton startIcon={<BlockIcon />} onClick={() => handleAction()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</TopbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -175,21 +167,9 @@ interface DisabledButtonProps {
|
|||
|
||||
export const DisabledButton: FC<DisabledButtonProps> = ({ label }) => {
|
||||
return (
|
||||
<Button startIcon={<OutlinedBlockIcon />} disabled>
|
||||
<TopbarButton startIcon={<OutlinedBlockIcon />} disabled>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadingProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const ActionLoadingButton: FC<LoadingProps> = ({ label }) => {
|
||||
return (
|
||||
<LoadingButton loading loadingPosition="start" startIcon={<ReplayIcon />}>
|
||||
{label}
|
||||
</LoadingButton>
|
||||
</TopbarButton>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -202,11 +182,11 @@ export const RetryButton: FC<RetryButtonProps> = ({
|
|||
debug = false,
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
<TopbarButton
|
||||
startIcon={debug ? <RetryDebugIcon /> : <RetryIcon />}
|
||||
onClick={() => handleAction()}
|
||||
>
|
||||
Retry{debug && " (Debug)"}
|
||||
</Button>
|
||||
</TopbarButton>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
import { type FC, type ReactNode, Fragment } from "react";
|
||||
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
|
||||
import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication";
|
||||
|
||||
import { workspaceUpdatePolicy } from "utils/workspace";
|
||||
import { type ActionType, abilitiesByWorkspaceStatus } from "./constants";
|
||||
|
||||
import {
|
||||
ActionLoadingButton,
|
||||
CancelButton,
|
||||
DisabledButton,
|
||||
StartButton,
|
||||
|
@ -22,14 +19,14 @@ import DuplicateIcon from "@mui/icons-material/FileCopyOutlined";
|
|||
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
|
||||
import HistoryIcon from "@mui/icons-material/HistoryOutlined";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
|
||||
|
||||
import {
|
||||
MoreMenu,
|
||||
MoreMenuContent,
|
||||
MoreMenuItem,
|
||||
MoreMenuTrigger,
|
||||
ThreeDotsButton,
|
||||
} from "components/MoreMenu/MoreMenu";
|
||||
import { TopbarIconButton } from "components/FullPageLayout/Topbar";
|
||||
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
|
||||
|
||||
export interface WorkspaceActionsProps {
|
||||
workspace: Workspace;
|
||||
|
@ -124,10 +121,10 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
|||
tooltipText={tooltipText}
|
||||
/>
|
||||
),
|
||||
deleting: <ActionLoadingButton label="Deleting" />,
|
||||
deleting: <DisabledButton label="Deleting" />,
|
||||
canceling: <DisabledButton label="Canceling..." />,
|
||||
deleted: <DisabledButton label="Deleted" />,
|
||||
pending: <ActionLoadingButton label="Pending..." />,
|
||||
pending: <DisabledButton label="Pending..." />,
|
||||
activate: <ActivateButton handleAction={handleDormantActivate} />,
|
||||
activating: <ActivateButton loading handleAction={handleDormantActivate} />,
|
||||
retry: <RetryButton handleAction={handleRetry} />,
|
||||
|
@ -136,7 +133,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
css={{ display: "flex", alignItems: "center", gap: 12 }}
|
||||
css={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||
data-testid="workspace-actions"
|
||||
>
|
||||
{canBeUpdated && (
|
||||
|
@ -153,13 +150,14 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
|||
|
||||
<MoreMenu>
|
||||
<MoreMenuTrigger>
|
||||
<ThreeDotsButton
|
||||
<TopbarIconButton
|
||||
title="More options"
|
||||
size="small"
|
||||
data-testid="workspace-options-button"
|
||||
aria-controls="workspace-options"
|
||||
disabled={!canAcceptJobs}
|
||||
/>
|
||||
>
|
||||
<MoreVertOutlined />
|
||||
</TopbarIconButton>
|
||||
</MoreMenuTrigger>
|
||||
|
||||
<MoreMenuContent id="workspace-options">
|
||||
|
|
|
@ -74,7 +74,7 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
|
|||
<AutoStopDisplay workspace={workspace} />
|
||||
) : (
|
||||
<ScheduleSettingsLink>
|
||||
{autostartDisplay(workspace.autostart_schedule)}
|
||||
Starts at {autostartDisplay(workspace.autostart_schedule)}
|
||||
</ScheduleSettingsLink>
|
||||
)}
|
||||
|
||||
|
@ -251,7 +251,7 @@ const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace }) => {
|
|||
: undefined,
|
||||
})}
|
||||
>
|
||||
{display.message}
|
||||
Stop {display.message}
|
||||
</ScheduleSettingsLink>
|
||||
</Tooltip>
|
||||
);
|
||||
|
@ -268,6 +268,7 @@ const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>(
|
|||
component={RouterLink}
|
||||
to="settings/schedule"
|
||||
css={{
|
||||
color: "inherit",
|
||||
"&:first-letter": {
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
|
@ -310,10 +311,6 @@ const isShutdownSoon = (workspace: Workspace): boolean => {
|
|||
return diff < oneHour;
|
||||
};
|
||||
|
||||
export const scheduleLabel = (workspace: Workspace) => {
|
||||
return isWorkspaceOn(workspace) ? "Stops" : "Starts at";
|
||||
};
|
||||
|
||||
const classNames = {
|
||||
paper: css`
|
||||
padding: 24px;
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
MockWorkspace,
|
||||
MockAppearanceConfig,
|
||||
MockBuildInfo,
|
||||
MockEntitlementsWithScheduling,
|
||||
MockExperiments,
|
||||
} from "testHelpers/entities";
|
||||
import { WorkspaceStats } from "./WorkspaceStats";
|
||||
import { DashboardProviderContext } from "components/Dashboard/DashboardProvider";
|
||||
|
||||
const MockedAppearance = {
|
||||
config: MockAppearanceConfig,
|
||||
isPreview: false,
|
||||
setPreview: () => {},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof WorkspaceStats> = {
|
||||
title: "pages/WorkspacePage/WorkspaceStats",
|
||||
component: WorkspaceStats,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<DashboardProviderContext.Provider
|
||||
value={{
|
||||
buildInfo: MockBuildInfo,
|
||||
entitlements: MockEntitlementsWithScheduling,
|
||||
experiments: MockExperiments,
|
||||
appearance: MockedAppearance,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</DashboardProviderContext.Provider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkspaceStats>;
|
||||
|
||||
export const Example: Story = {
|
||||
args: {
|
||||
workspace: MockWorkspace,
|
||||
},
|
||||
};
|
||||
|
||||
export const Outdated: Story = {
|
||||
args: {
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
outdated: true,
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1,140 +0,0 @@
|
|||
import { type Interpolation, type Theme } from "@emotion/react";
|
||||
import Link from "@mui/material/Link";
|
||||
import { WorkspaceOutdatedTooltip } from "components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
|
||||
import { type FC } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { getDisplayWorkspaceTemplateName } from "utils/workspace";
|
||||
import type { Workspace } from "api/typesGenerated";
|
||||
import { Stats, StatsItem } from "components/Stats/Stats";
|
||||
import { WorkspaceStatusText } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge";
|
||||
import { DormantDeletionStat } from "components/WorkspaceDeletion";
|
||||
import { workspaceQuota } from "api/queries/workspaceQuota";
|
||||
import { useQuery } from "react-query";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
WorkspaceScheduleControls,
|
||||
scheduleLabel,
|
||||
shouldDisplayScheduleControls,
|
||||
} from "./WorkspaceScheduleControls";
|
||||
|
||||
const Language = {
|
||||
workspaceDetails: "Workspace Details",
|
||||
templateLabel: "Template",
|
||||
costLabel: "Daily cost",
|
||||
updatePolicy: "Update policy",
|
||||
};
|
||||
|
||||
export interface WorkspaceStatsProps {
|
||||
workspace: Workspace;
|
||||
canUpdateWorkspace: boolean;
|
||||
handleUpdate: () => void;
|
||||
}
|
||||
|
||||
export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
|
||||
workspace,
|
||||
canUpdateWorkspace,
|
||||
handleUpdate,
|
||||
}) => {
|
||||
const displayTemplateName = getDisplayWorkspaceTemplateName(workspace);
|
||||
const quotaQuery = useQuery(workspaceQuota(workspace.owner_name));
|
||||
const quotaBudget = quotaQuery.data?.budget;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stats aria-label={Language.workspaceDetails} css={styles.stats}>
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label="Status"
|
||||
value={<WorkspaceStatusText workspace={workspace} />}
|
||||
/>
|
||||
<DormantDeletionStat workspace={workspace} />
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label={Language.templateLabel}
|
||||
value={
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/templates/${workspace.template_name}`}
|
||||
>
|
||||
{displayTemplateName}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label="Version"
|
||||
value={
|
||||
<span css={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/templates/${workspace.template_name}/versions/${workspace.latest_build.template_version_name}`}
|
||||
>
|
||||
{workspace.latest_build.template_version_name}
|
||||
</Link>
|
||||
|
||||
{workspace.outdated && (
|
||||
<WorkspaceOutdatedTooltip
|
||||
templateName={workspace.template_name}
|
||||
latestVersionId={workspace.template_active_version_id}
|
||||
onUpdateVersion={handleUpdate}
|
||||
ariaLabel="update version"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{shouldDisplayScheduleControls(workspace) && (
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label={scheduleLabel(workspace)}
|
||||
value={
|
||||
<WorkspaceScheduleControls
|
||||
workspace={workspace}
|
||||
canUpdateSchedule={canUpdateWorkspace}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{workspace.latest_build.daily_cost > 0 && (
|
||||
<StatsItem
|
||||
css={styles.statsItem}
|
||||
label={Language.costLabel}
|
||||
value={`${workspace.latest_build.daily_cost} ${
|
||||
quotaBudget ? `/ ${quotaBudget}` : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</Stats>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
stats: (theme) => ({
|
||||
padding: 0,
|
||||
border: 0,
|
||||
gap: 48,
|
||||
rowGap: 24,
|
||||
flex: 1,
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
},
|
||||
}),
|
||||
|
||||
statsItem: {
|
||||
flexDirection: "column",
|
||||
gap: 0,
|
||||
padding: 0,
|
||||
|
||||
"& > span:first-of-type": {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
|
@ -0,0 +1,82 @@
|
|||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { MockUser, MockWorkspace } from "testHelpers/entities";
|
||||
import { WorkspaceTopbar } from "./WorkspaceTopbar";
|
||||
import { withDashboardProvider } from "testHelpers/storybook";
|
||||
import { addDays } from "date-fns";
|
||||
import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota";
|
||||
|
||||
// We want a workspace without a deadline to not pollute the screenshot
|
||||
const baseWorkspace = {
|
||||
...MockWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
deadline: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof WorkspaceTopbar> = {
|
||||
title: "pages/WorkspacePage/WorkspaceTopbar",
|
||||
component: WorkspaceTopbar,
|
||||
decorators: [withDashboardProvider],
|
||||
args: {
|
||||
workspace: baseWorkspace,
|
||||
},
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
features: ["advanced_template_scheduling"],
|
||||
experiments: ["workspace_actions"],
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof WorkspaceTopbar>;
|
||||
|
||||
export const Example: Story = {};
|
||||
|
||||
export const Outdated: Story = {
|
||||
args: {
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
outdated: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Dormant: Story = {
|
||||
args: {
|
||||
workspace: {
|
||||
...baseWorkspace,
|
||||
deleting_at: addDays(new Date(), 7).toISOString(),
|
||||
latest_build: {
|
||||
...baseWorkspace.latest_build,
|
||||
status: "failed",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDeadline: Story = {
|
||||
args: {
|
||||
workspace: {
|
||||
...MockWorkspace,
|
||||
latest_build: {
|
||||
...MockWorkspace.latest_build,
|
||||
deadline: MockWorkspace.latest_build.deadline,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithQuota: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
{
|
||||
key: getWorkspaceQuotaQueryKey(MockUser.username),
|
||||
data: {
|
||||
credits_consumed: 2,
|
||||
budget: 40,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
|
@ -0,0 +1,255 @@
|
|||
import { Link as RouterLink } from "react-router-dom";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { WorkspaceActions } from "pages/WorkspacePage/WorkspaceActions/WorkspaceActions";
|
||||
import {
|
||||
Topbar,
|
||||
TopbarAvatar,
|
||||
TopbarData,
|
||||
TopbarDivider,
|
||||
TopbarIcon,
|
||||
TopbarIconButton,
|
||||
} from "components/FullPageLayout/Topbar";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
|
||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import { WorkspaceOutdatedTooltipContent } from "components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
|
||||
import { Popover, PopoverTrigger } from "components/Popover/Popover";
|
||||
import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined";
|
||||
import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import {
|
||||
WorkspaceScheduleControls,
|
||||
shouldDisplayScheduleControls,
|
||||
} from "../WorkspaceScheduleControls";
|
||||
import { workspaceQuota } from "api/queries/workspaceQuota";
|
||||
import { useQuery } from "react-query";
|
||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import InfoOutlined from "@mui/icons-material/InfoOutlined";
|
||||
import Link from "@mui/material/Link";
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider";
|
||||
import { displayDormantDeletion } from "utils/dormant";
|
||||
import DeleteOutline from "@mui/icons-material/DeleteOutline";
|
||||
|
||||
export type WorkspaceError =
|
||||
| "getBuildsError"
|
||||
| "buildError"
|
||||
| "cancellationError";
|
||||
|
||||
export type WorkspaceErrors = Partial<Record<WorkspaceError, unknown>>;
|
||||
|
||||
export interface WorkspaceProps {
|
||||
handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
|
||||
handleStop: () => void;
|
||||
handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
|
||||
handleDelete: () => void;
|
||||
handleUpdate: () => void;
|
||||
handleCancel: () => void;
|
||||
handleSettings: () => void;
|
||||
handleChangeVersion: () => void;
|
||||
handleDormantActivate: () => void;
|
||||
isUpdating: boolean;
|
||||
isRestarting: boolean;
|
||||
workspace: TypesGen.Workspace;
|
||||
canUpdateWorkspace: boolean;
|
||||
canChangeVersions: boolean;
|
||||
canRetryDebugMode: boolean;
|
||||
handleBuildRetry: () => void;
|
||||
handleBuildRetryDebug: () => void;
|
||||
}
|
||||
|
||||
export const WorkspaceTopbar = (props: WorkspaceProps) => {
|
||||
const {
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleRestart,
|
||||
handleDelete,
|
||||
handleUpdate,
|
||||
handleCancel,
|
||||
handleSettings,
|
||||
handleChangeVersion,
|
||||
handleDormantActivate,
|
||||
workspace,
|
||||
isUpdating,
|
||||
isRestarting,
|
||||
canUpdateWorkspace,
|
||||
canChangeVersions,
|
||||
canRetryDebugMode,
|
||||
handleBuildRetry,
|
||||
handleBuildRetryDebug,
|
||||
} = props;
|
||||
const theme = useTheme();
|
||||
|
||||
// Quota
|
||||
const hasDailyCost = workspace.latest_build.daily_cost > 0;
|
||||
const { data: quota } = useQuery({
|
||||
...workspaceQuota(workspace.owner_name),
|
||||
enabled: hasDailyCost,
|
||||
});
|
||||
|
||||
// Dormant
|
||||
const { entitlements, experiments } = useDashboard();
|
||||
const allowAdvancedScheduling =
|
||||
entitlements.features["advanced_template_scheduling"].enabled;
|
||||
// This check can be removed when https://github.com/coder/coder/milestone/19
|
||||
// is merged up
|
||||
const allowWorkspaceActions = experiments.includes("workspace_actions");
|
||||
const shouldDisplayDormantData = displayDormantDeletion(
|
||||
workspace,
|
||||
allowAdvancedScheduling,
|
||||
allowWorkspaceActions,
|
||||
);
|
||||
|
||||
return (
|
||||
<Topbar>
|
||||
<Tooltip title="Back to workspaces">
|
||||
<TopbarIconButton component={RouterLink} to="workspaces">
|
||||
<ArrowBackOutlined />
|
||||
</TopbarIconButton>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
columnGap: 24,
|
||||
rowGap: 8,
|
||||
flexWrap: "wrap",
|
||||
// 12px - It is needed to keep vertical spacing when the content is wrapped
|
||||
padding: "12px 0 12px 16px",
|
||||
}}
|
||||
>
|
||||
<TopbarData>
|
||||
<TopbarAvatar src={workspace.template_icon} />
|
||||
<span css={{ fontWeight: 500 }}>{workspace.name}</span>
|
||||
<TopbarDivider />
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/templates/${workspace.template_name}`}
|
||||
css={{ color: "inherit" }}
|
||||
>
|
||||
{workspace.template_display_name ?? workspace.template_name}
|
||||
</Link>
|
||||
|
||||
{workspace.outdated ? (
|
||||
<Popover mode="hover">
|
||||
<PopoverTrigger>
|
||||
{/* Added to give some bottom space from the popover content */}
|
||||
<div css={{ padding: "4px 0", margin: "-4px 0" }}>
|
||||
<Pill
|
||||
icon={
|
||||
<InfoOutlined
|
||||
css={{
|
||||
width: "12px !important",
|
||||
height: "12px !important",
|
||||
color: theme.palette.warning.light,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span css={{ color: theme.palette.warning.light }}>
|
||||
{workspace.latest_build.template_version_name}
|
||||
</span>
|
||||
</Pill>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<WorkspaceOutdatedTooltipContent
|
||||
templateName={workspace.template_name}
|
||||
latestVersionId={workspace.template_active_version_id}
|
||||
onUpdateVersion={handleUpdate}
|
||||
ariaLabel="update version"
|
||||
/>
|
||||
</Popover>
|
||||
) : (
|
||||
<Pill>{workspace.latest_build.template_version_name}</Pill>
|
||||
)}
|
||||
</TopbarData>
|
||||
|
||||
<TopbarData>
|
||||
<Tooltip title="Owner">
|
||||
<TopbarIcon>
|
||||
<PersonOutlineOutlined aria-label="Owner" />
|
||||
</TopbarIcon>
|
||||
</Tooltip>
|
||||
<span>{workspace.owner_name}</span>
|
||||
</TopbarData>
|
||||
|
||||
{shouldDisplayDormantData && (
|
||||
<TopbarData>
|
||||
<TopbarIcon>
|
||||
<DeleteOutline />
|
||||
</TopbarIcon>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={`/templates/${workspace.template_name}/settings/schedule`}
|
||||
title="Schedule settings"
|
||||
css={{ color: "inherit" }}
|
||||
>
|
||||
Deletion on {new Date(workspace.deleting_at!).toLocaleString()}
|
||||
</Link>
|
||||
</TopbarData>
|
||||
)}
|
||||
|
||||
{shouldDisplayScheduleControls(workspace) && (
|
||||
<TopbarData>
|
||||
<TopbarIcon>
|
||||
<Tooltip title="Schedule">
|
||||
<ScheduleOutlined aria-label="Schedule" />
|
||||
</Tooltip>
|
||||
</TopbarIcon>
|
||||
<WorkspaceScheduleControls
|
||||
workspace={workspace}
|
||||
canUpdateSchedule={canUpdateWorkspace}
|
||||
/>
|
||||
</TopbarData>
|
||||
)}
|
||||
|
||||
{quota && (
|
||||
<TopbarData>
|
||||
<TopbarIcon>
|
||||
<Tooltip title="Daily usage">
|
||||
<MonetizationOnOutlined aria-label="Daily usage" />
|
||||
</Tooltip>
|
||||
</TopbarIcon>
|
||||
<span>
|
||||
{workspace.latest_build.daily_cost}{" "}
|
||||
<span css={{ color: theme.palette.text.secondary }}>
|
||||
credits of
|
||||
</span>{" "}
|
||||
{quota.budget}
|
||||
</span>
|
||||
</TopbarData>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
css={{
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<WorkspaceStatusBadge workspace={workspace} />
|
||||
<WorkspaceActions
|
||||
workspace={workspace}
|
||||
handleStart={handleStart}
|
||||
handleStop={handleStop}
|
||||
handleRestart={handleRestart}
|
||||
handleDelete={handleDelete}
|
||||
handleUpdate={handleUpdate}
|
||||
handleCancel={handleCancel}
|
||||
handleSettings={handleSettings}
|
||||
handleRetry={handleBuildRetry}
|
||||
handleRetryDebug={handleBuildRetryDebug}
|
||||
handleChangeVersion={handleChangeVersion}
|
||||
handleDormantActivate={handleDormantActivate}
|
||||
canRetryDebug={canRetryDebugMode}
|
||||
canChangeVersions={canChangeVersions}
|
||||
isUpdating={isUpdating}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</div>
|
||||
</Topbar>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,48 @@
|
|||
import { DashboardProviderContext } from "components/Dashboard/DashboardProvider";
|
||||
import {
|
||||
MockAppearanceConfig,
|
||||
MockBuildInfo,
|
||||
MockEntitlements,
|
||||
} from "./entities";
|
||||
import { FC } from "react";
|
||||
import { StoryContext } from "@storybook/react";
|
||||
import * as _storybook_types from "@storybook/react";
|
||||
import { Entitlements } from "api/typesGenerated";
|
||||
import { withDefaultFeatures } from "api/api";
|
||||
|
||||
export const withDashboardProvider = (
|
||||
Story: FC,
|
||||
{ parameters }: StoryContext,
|
||||
) => {
|
||||
const { features = [], experiments = [] } = parameters;
|
||||
|
||||
const entitlements: Entitlements = {
|
||||
...MockEntitlements,
|
||||
features: withDefaultFeatures(
|
||||
features.reduce(
|
||||
(acc, feature) => {
|
||||
acc[feature] = { enabled: true, entitlement: "entitled" };
|
||||
return acc;
|
||||
},
|
||||
{} as Entitlements["features"],
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardProviderContext.Provider
|
||||
value={{
|
||||
buildInfo: MockBuildInfo,
|
||||
entitlements,
|
||||
experiments,
|
||||
appearance: {
|
||||
config: MockAppearanceConfig,
|
||||
isPreview: false,
|
||||
setPreview: () => {},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</DashboardProviderContext.Provider>
|
||||
);
|
||||
};
|
|
@ -1,6 +1,6 @@
|
|||
import * as TypesGen from "api/typesGenerated";
|
||||
import * as Mocks from "testHelpers/entities";
|
||||
import { displayDormantDeletion } from "./utils";
|
||||
import { displayDormantDeletion } from "./dormant";
|
||||
|
||||
describe("displayDormantDeletion", () => {
|
||||
const today = new Date();
|
Loading…
Reference in New Issue