feat(site): move history into sidebar (#11413)

This commit is contained in:
Bruno Quaresma
2024-01-05 13:32:05 -03:00
committed by GitHub
parent f0132b543d
commit c428395d71
18 changed files with 532 additions and 421 deletions
@@ -0,0 +1,96 @@
import { Interpolation, Theme, useTheme } from "@mui/material/styles";
import { ComponentProps, HTMLAttributes } from "react";
import { Link, LinkProps } from "react-router-dom";
import { TopbarIconButton } from "./Topbar";
export const Sidebar = (props: HTMLAttributes<HTMLDivElement>) => {
const theme = useTheme();
return (
<div
css={{
width: 260,
borderRight: `1px solid ${theme.palette.divider}`,
height: "100%",
overflow: "auto",
flexShrink: 0,
padding: "8px 0",
display: "flex",
flexDirection: "column",
gap: 1,
}}
{...props}
/>
);
};
export const SidebarLink = (props: LinkProps) => {
return <Link css={styles.sidebarItem} {...props} />;
};
export const SidebarItem = (props: HTMLAttributes<HTMLButtonElement>) => {
return <button css={styles.sidebarItem} {...props} />;
};
export const SidebarCaption = (props: HTMLAttributes<HTMLSpanElement>) => {
return (
<span
css={{
fontSize: 10,
lineHeight: 1.2,
padding: "12px 16px",
display: "block",
textTransform: "uppercase",
fontWeight: 500,
letterSpacing: "0.1em",
}}
{...props}
/>
);
};
export const SidebarIconButton = (
props: { isActive: boolean } & ComponentProps<typeof TopbarIconButton>,
) => {
const theme = useTheme();
return (
<TopbarIconButton
css={[
{ opacity: 0.75, "&:hover": { opacity: 1 } },
props.isActive && {
opacity: 1,
position: "relative",
"&::before": {
content: '""',
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 2,
backgroundColor: theme.palette.primary.main,
height: "100%",
},
},
]}
{...props}
/>
);
};
const styles = {
sidebarItem: (theme) => ({
fontSize: 13,
lineHeight: 1.2,
color: theme.palette.text.primary,
textDecoration: "none",
padding: "8px 16px",
display: "block",
textAlign: "left",
background: "none",
border: 0,
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
}),
} satisfies Record<string, Interpolation<Theme>>;
+3 -4
View File
@@ -560,10 +560,11 @@ const styles = {
}),
agentInfo: (theme) => ({
padding: "16px 32px",
padding: "24px 32px",
display: "flex",
gap: 16,
alignItems: "center",
gap: 48,
justifyContent: "space-between",
flexWrap: "wrap",
backgroundColor: theme.palette.background.paper,
@@ -586,9 +587,7 @@ const styles = {
agentButtons: (theme) => ({
display: "flex",
gap: 8,
justifyContent: "flex-end",
flexWrap: "wrap",
flex: 1,
[theme.breakpoints.down("md")]: {
marginLeft: 0,
+7 -1
View File
@@ -5,6 +5,7 @@ import type { WorkspaceAgent, WorkspaceResource } from "api/typesGenerated";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import { Stack } from "../Stack/Stack";
import { ResourceCard } from "./ResourceCard";
import { useTheme } from "@mui/material/styles";
const countAgents = (resource: WorkspaceResource) => {
return resource.agents ? resource.agents.length : 0;
@@ -19,6 +20,7 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
resources,
agentRow,
}) => {
const theme = useTheme();
const [shouldDisplayHideResources, setShouldDisplayHideResources] =
useState(false);
const displayResources = shouldDisplayHideResources
@@ -30,7 +32,11 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
const hasHideResources = resources.some((r) => r.hide);
return (
<Stack direction="column" spacing={0}>
<Stack
direction="column"
spacing={0}
css={{ background: theme.palette.background.default }}
>
{displayResources.map((resource) => (
<ResourceCard
key={resource.id}
@@ -0,0 +1,79 @@
import { Interpolation, Theme, useTheme } from "@emotion/react";
import Skeleton from "@mui/material/Skeleton";
import { WorkspaceBuild } from "api/typesGenerated";
import { BuildIcon } from "components/BuildIcon/BuildIcon";
import { createDayString } from "utils/createDayString";
import {
getDisplayWorkspaceBuildStatus,
getDisplayWorkspaceBuildInitiatedBy,
} from "utils/workspace";
export const WorkspaceBuildData = ({ build }: { build: WorkspaceBuild }) => {
const theme = useTheme();
const statusType = getDisplayWorkspaceBuildStatus(theme, build).type;
return (
<div css={styles.root}>
<BuildIcon
transition={build.transition}
css={{
width: 16,
height: 16,
color: theme.palette[statusType].light,
}}
/>
<div css={{ overflow: "hidden" }}>
<div
css={{
textTransform: "capitalize",
color: theme.palette.text.primary,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{build.transition} by{" "}
<span css={{ fontWeight: 500 }}>
{getDisplayWorkspaceBuildInitiatedBy(build)}
</span>
</div>
<div
css={{
fontSize: 12,
color: theme.palette.text.secondary,
marginTop: 2,
}}
>
{createDayString(build.created_at)}
</div>
</div>
</div>
);
};
export const WorkspaceBuildDataSkeleton = () => {
return (
<div css={styles.root}>
<Skeleton variant="circular" width={16} height={16} />
<div>
<Skeleton variant="text" width={94} height={16} />
<Skeleton
variant="text"
width={60}
height={14}
css={{ marginTop: 2 }}
/>
</div>
</div>
);
};
const styles = {
root: {
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 12,
lineHeight: "1.4",
},
} satisfies Record<string, Interpolation<Theme>>;
@@ -52,6 +52,7 @@ import {
TopbarDivider,
TopbarIconButton,
} from "components/FullPageLayout/Topbar";
import { Sidebar } from "components/FullPageLayout/Sidebar";
type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab
@@ -301,13 +302,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
</div>
)}
<div
css={{
width: 240,
borderRight: `1px solid ${theme.palette.divider}`,
flexShrink: 0,
}}
>
<Sidebar>
<div
css={{
height: 42,
@@ -409,7 +404,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
onRename={(file) => setRenameFileOpen(file)}
activePath={activePath}
/>
</div>
</Sidebar>
<div
css={{
@@ -12,16 +12,14 @@ import {
} from "components/PageHeader/FullWidthPageHeader";
import { Link } from "react-router-dom";
import { Stats, StatsItem } from "components/Stats/Stats";
import {
displayWorkspaceBuildDuration,
getDisplayWorkspaceBuildInitiatedBy,
getDisplayWorkspaceBuildStatus,
} from "utils/workspace";
import { displayWorkspaceBuildDuration } from "utils/workspace";
import { Sidebar, SidebarCaption, SidebarItem } from "./Sidebar";
import { BuildIcon } from "components/BuildIcon/BuildIcon";
import Skeleton from "@mui/material/Skeleton";
import { Alert } from "components/Alert/Alert";
import { DashboardFullPage } from "components/Dashboard/DashboardLayout";
import {
WorkspaceBuildData,
WorkspaceBuildDataSkeleton,
} from "components/WorkspaceBuild/WorkspaceBuildData";
const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
return [...logs].sort(
@@ -112,15 +110,20 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
<SidebarCaption>Builds</SidebarCaption>
{!builds &&
Array.from({ length: 15 }, (_, i) => (
<BuildSidebarItemSkeleton key={i} />
<SidebarItem key={i}>
<WorkspaceBuildDataSkeleton />
</SidebarItem>
))}
{builds?.map((build) => (
<BuildSidebarItem
<Link
key={build.id}
build={build}
active={build.build_number === activeBuildNumber}
/>
to={`/@${build.workspace_owner_name}/${build.workspace_name}/builds/${build.build_number}`}
>
<SidebarItem active={build.build_number === activeBuildNumber}>
<WorkspaceBuildData build={build} />
</SidebarItem>
</Link>
))}
</Sidebar>
@@ -167,78 +170,6 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
);
};
interface BuildSidebarItemProps {
build: WorkspaceBuild;
active: boolean;
}
const BuildSidebarItem: FC<BuildSidebarItemProps> = ({ build, active }) => {
const theme = useTheme();
const statusType = getDisplayWorkspaceBuildStatus(theme, build).type;
return (
<Link
key={build.id}
to={`/@${build.workspace_owner_name}/${build.workspace_name}/builds/${build.build_number}`}
>
<SidebarItem active={active}>
<div css={{ display: "flex", alignItems: "start", gap: 8 }}>
<BuildIcon
transition={build.transition}
css={{
width: 16,
height: 16,
color: theme.palette[statusType].light,
}}
/>
<div css={{ overflow: "hidden" }}>
<div
css={{
textTransform: "capitalize",
color: theme.palette.text.primary,
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{build.transition} by{" "}
<strong>{getDisplayWorkspaceBuildInitiatedBy(build)}</strong>
</div>
<div
css={{
fontSize: 12,
color: theme.palette.text.secondary,
marginTop: 2,
}}
>
{displayWorkspaceBuildDuration(build)}
</div>
</div>
</div>
</SidebarItem>
</Link>
);
};
const BuildSidebarItemSkeleton: FC = () => {
return (
<SidebarItem>
<div css={{ display: "flex", alignItems: "start", gap: 8 }}>
<Skeleton variant="circular" width={16} height={16} />
<div>
<Skeleton variant="text" width={94} height={16} />
<Skeleton
variant="text"
width={60}
height={14}
css={{ marginTop: 2 }}
/>
</div>
</div>
</SidebarItem>
);
};
const styles = {
stats: (theme) => ({
padding: 0,
@@ -1,31 +0,0 @@
import { Meta, StoryObj } from "@storybook/react";
import { MockBuilds } from "testHelpers/entities";
import { BuildsTable } from "./BuildsTable";
const meta: Meta<typeof BuildsTable> = {
title: "pages/WorkspacePage/BuildsTable",
component: BuildsTable,
};
export default meta;
type Story = StoryObj<typeof BuildsTable>;
export const Example: Story = {
args: {
builds: MockBuilds,
hasMoreBuilds: true,
},
};
export const Empty: Story = {
args: {
builds: [],
},
};
export const NoMoreBuilds: Story = {
args: {
builds: MockBuilds,
hasMoreBuilds: false,
},
};
@@ -1,80 +0,0 @@
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableRow from "@mui/material/TableRow";
import LoadingButton from "@mui/lab/LoadingButton";
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
import { type FC, type ReactNode } from "react";
import type * as TypesGen from "api/typesGenerated";
import { EmptyState } from "components/EmptyState/EmptyState";
import { TableLoader } from "components/TableLoader/TableLoader";
import { Timeline } from "components/Timeline/Timeline";
import { Stack } from "components/Stack/Stack";
import { BuildRow } from "./BuildRow";
export const Language = {
emptyMessage: "No builds found",
};
export interface BuildsTableProps {
children?: ReactNode;
builds: TypesGen.WorkspaceBuild[] | undefined;
onLoadMoreBuilds: () => void;
isLoadingMoreBuilds: boolean;
hasMoreBuilds: boolean;
}
export const BuildsTable: FC<BuildsTableProps> = ({
builds,
onLoadMoreBuilds,
isLoadingMoreBuilds,
hasMoreBuilds,
}) => {
return (
<Stack>
<TableContainer>
<Table data-testid="builds-table" aria-describedby="builds table">
<TableBody>
{builds ? (
<Timeline
items={builds}
getDate={(build) => new Date(build.created_at)}
row={(build) => <BuildRow key={build.id} build={build} />}
/>
) : (
<TableLoader />
)}
{builds && builds.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<div css={{ padding: 32 }}>
<EmptyState message={Language.emptyMessage} />
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{hasMoreBuilds && (
<LoadingButton
onClick={onLoadMoreBuilds}
loading={isLoadingMoreBuilds}
loadingPosition="start"
variant="outlined"
color="neutral"
startIcon={<ArrowDownwardOutlined />}
css={{
display: "inline-flex",
margin: "auto",
borderRadius: "9999px",
}}
>
Load previous builds
</LoadingButton>
)}
</Stack>
);
};
@@ -0,0 +1,64 @@
import ArrowDownwardOutlined from "@mui/icons-material/ArrowDownwardOutlined";
import LoadingButton from "@mui/lab/LoadingButton";
import { infiniteWorkspaceBuilds } from "api/queries/workspaceBuilds";
import { Workspace } from "api/typesGenerated";
import {
Sidebar,
SidebarCaption,
SidebarItem,
SidebarLink,
} from "components/FullPageLayout/Sidebar";
import {
WorkspaceBuildData,
WorkspaceBuildDataSkeleton,
} from "components/WorkspaceBuild/WorkspaceBuildData";
import { useInfiniteQuery } from "react-query";
export const HistorySidebar = ({ workspace }: { workspace: Workspace }) => {
const buildsQuery = useInfiniteQuery({
...infiniteWorkspaceBuilds(workspace?.id ?? ""),
enabled: workspace !== undefined,
});
const builds = buildsQuery.data?.pages.flat();
return (
<Sidebar>
<SidebarCaption>History</SidebarCaption>
{builds
? builds.map((build) => (
<SidebarLink
target="_blank"
key={build.id}
to={`/@${build.workspace_owner_name}/${build.workspace_name}/builds/${build.build_number}`}
>
<WorkspaceBuildData build={build} />
</SidebarLink>
))
: Array.from({ length: 15 }, (_, i) => (
<SidebarItem key={i}>
<WorkspaceBuildDataSkeleton />
</SidebarItem>
))}
{buildsQuery.hasNextPage && (
<div css={{ padding: 16 }}>
<LoadingButton
fullWidth
onClick={() => buildsQuery.fetchNextPage()}
loading={buildsQuery.isFetchingNextPage}
loadingPosition="start"
variant="outlined"
color="neutral"
startIcon={<ArrowDownwardOutlined />}
css={{
display: "inline-flex",
borderRadius: "9999px",
fontSize: 13,
}}
>
Show more builds
</LoadingButton>
</div>
)}
</Sidebar>
);
};
@@ -0,0 +1,29 @@
import { useTheme } from "@mui/material/styles";
import { Workspace } from "api/typesGenerated";
import { SidebarLink, SidebarCaption } from "components/FullPageLayout/Sidebar";
export const ResourcesSidebarContent = ({
workspace,
}: {
workspace: Workspace;
}) => {
const theme = useTheme();
return (
<>
<SidebarCaption>Resources</SidebarCaption>
{workspace.latest_build.resources.map((r) => (
<SidebarLink
key={r.id}
to={{ search: `r=${r.id}` }}
css={{ display: "flex", flexDirection: "column", lineHeight: 1.6 }}
>
<span css={{ fontWeight: 500 }}>{r.name}</span>
<span css={{ fontSize: 13, color: theme.palette.text.secondary }}>
{r.type}
</span>
</SidebarLink>
))}
</>
);
};
@@ -54,7 +54,6 @@ const meta: Meta<typeof Workspace> = {
withReactContext({
Context: WatchAgentMetadataContext,
initialState: (_: string): EventSource => {
// Need Bruno's help here.
return new EventSource();
},
}),
@@ -75,7 +74,6 @@ export const Running: Story = {
Mocks.MockWorkspaceImageResource,
Mocks.MockWorkspaceContainerResource,
],
builds: [Mocks.MockWorkspaceBuild],
canUpdateWorkspace: true,
workspaceErrors: {},
buildInfo: Mocks.MockBuildInfo,
+217 -164
View File
@@ -2,11 +2,10 @@ import { type Interpolation, type Theme } from "@emotion/react";
import Button from "@mui/material/Button";
import AlertTitle from "@mui/material/AlertTitle";
import { type FC, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useSearchParams } from "react-router-dom";
import dayjs from "dayjs";
import type * as TypesGen from "api/typesGenerated";
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 { ErrorAlert } from "components/Alert/ErrorAlert";
@@ -17,9 +16,14 @@ import {
ActiveTransition,
WorkspaceBuildProgress,
} from "./WorkspaceBuildProgress";
import { BuildsTable } from "./BuildsTable";
import { WorkspaceDeletedBanner } from "./WorkspaceDeletedBanner";
import { WorkspaceTopbar } from "./WorkspaceTopbar";
import { HistorySidebar } from "./HistorySidebar";
import { dashboardContentBottomPadding, navHeight } from "theme/constants";
import { bannerHeight } from "components/Dashboard/DeploymentBanner/DeploymentBannerView";
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
import { useTheme } from "@mui/material/styles";
import { SidebarIconButton } from "components/FullPageLayout/Sidebar";
export type WorkspaceError =
| "getBuildsError"
@@ -55,10 +59,6 @@ export interface WorkspaceProps {
handleBuildRetry: () => void;
handleBuildRetryDebug: () => void;
buildLogs?: React.ReactNode;
builds: TypesGen.WorkspaceBuild[] | undefined;
onLoadMoreBuilds: () => void;
isLoadingMoreBuilds: boolean;
hasMoreBuilds: boolean;
canAutostart: boolean;
}
@@ -79,7 +79,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
isUpdating,
isRestarting,
resources,
builds,
canUpdateWorkspace,
updateMessage,
canChangeVersions,
@@ -93,13 +93,13 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleBuildRetry,
handleBuildRetryDebug,
buildLogs,
onLoadMoreBuilds,
isLoadingMoreBuilds,
hasMoreBuilds,
canAutostart,
}) => {
const navigate = useNavigate();
const { saveLocal, getLocal } = useLocalStorage();
const theme = useTheme();
const [searchParams, setSearchParams] = useSearchParams();
const [showAlertPendingInQueue, setShowAlertPendingInQueue] = useState(false);
@@ -149,7 +149,18 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
template !== undefined ? ActiveTransition(template, workspace) : undefined;
return (
<>
<div
css={{
flex: 1,
display: "grid",
gridTemplate: `
"topbar topbar topbar" auto
"leftbar sidebar content" 1fr / auto auto 1fr
`,
maxHeight: `calc(100vh - ${navHeight + bannerHeight}px)`,
marginBottom: `-${dashboardContentBottomPadding}px`,
}}
>
<WorkspaceTopbar
workspace={workspace}
handleStart={handleStart}
@@ -170,175 +181,217 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
canUpdateWorkspace={canUpdateWorkspace}
/>
<Margins css={styles.content}>
<Stack direction="column" css={styles.firstColumnSpacer} spacing={4}>
{workspace.outdated &&
(requiresManualUpdate ? (
<Alert severity="warning">
<AlertTitle>
Autostart has been disabled for your workspace.
</AlertTitle>
<AlertDetail>
Autostart is unable to automatically update your workspace.
Manually update your workspace to reenable Autostart.
</AlertDetail>
</Alert>
) : (
<div
css={{
gridArea: "leftbar",
height: "100%",
overflowY: "auto",
borderRight: `1px solid ${theme.palette.divider}`,
}}
>
<SidebarIconButton
isActive={searchParams.get("sidebar") === "history"}
onClick={() => {
const sidebarOption = searchParams.get("sidebar");
if (sidebarOption === "history") {
searchParams.delete("sidebar");
} else {
searchParams.set("sidebar", "history");
}
setSearchParams(searchParams);
}}
>
<HistoryOutlined />
</SidebarIconButton>
</div>
{searchParams.get("sidebar") === "history" && (
<HistorySidebar workspace={workspace} />
)}
<div css={styles.content}>
<div css={styles.dotBackground}>
<Stack direction="column" css={styles.firstColumnSpacer} spacing={4}>
{workspace.outdated &&
(requiresManualUpdate ? (
<Alert severity="warning">
<AlertTitle>
Autostart has been disabled for your workspace.
</AlertTitle>
<AlertDetail>
Autostart is unable to automatically update your workspace.
Manually update your workspace to reenable Autostart.
</AlertDetail>
</Alert>
) : (
<Alert severity="info">
<AlertTitle>
An update is available for your workspace
</AlertTitle>
{updateMessage && <AlertDetail>{updateMessage}</AlertDetail>}
</Alert>
))}
{Boolean(workspaceErrors.buildError) && (
<ErrorAlert error={workspaceErrors.buildError} dismissible />
)}
{Boolean(workspaceErrors.cancellationError) && (
<ErrorAlert
error={workspaceErrors.cancellationError}
dismissible
/>
)}
{workspace.latest_build.status === "running" &&
!workspace.health.healthy && (
<Alert
severity="warning"
actions={
canUpdateWorkspace && (
<Button
variant="text"
size="small"
onClick={() => {
handleRestart();
}}
>
Restart
</Button>
)
}
>
<AlertTitle>Workspace is unhealthy</AlertTitle>
<AlertDetail>
Your workspace is running but{" "}
{workspace.health.failing_agents.length > 1
? `${workspace.health.failing_agents.length} agents are unhealthy`
: `1 agent is unhealthy`}
.
</AlertDetail>
</Alert>
)}
{workspace.latest_build.status === "deleted" && (
<WorkspaceDeletedBanner
handleClick={() => navigate(`/templates`)}
/>
)}
{/* <DormantWorkspaceBanner/> determines its own visibility */}
<DormantWorkspaceBanner
workspace={workspace}
shouldRedisplayBanner={
getLocal("dismissedWorkspace") !== workspace.id
}
onDismiss={() => saveLocal("dismissedWorkspace", workspace.id)}
/>
{showAlertPendingInQueue && (
<Alert severity="info">
<AlertTitle>
An update is available for your workspace
</AlertTitle>
{updateMessage && <AlertDetail>{updateMessage}</AlertDetail>}
</Alert>
))}
{Boolean(workspaceErrors.buildError) && (
<ErrorAlert error={workspaceErrors.buildError} dismissible />
)}
{Boolean(workspaceErrors.cancellationError) && (
<ErrorAlert error={workspaceErrors.cancellationError} dismissible />
)}
{workspace.latest_build.status === "running" &&
!workspace.health.healthy && (
<Alert
severity="warning"
actions={
canUpdateWorkspace && (
<Button
variant="text"
size="small"
onClick={() => {
handleRestart();
}}
>
Restart
</Button>
)
}
>
<AlertTitle>Workspace is unhealthy</AlertTitle>
<AlertTitle>Workspace build is pending</AlertTitle>
<AlertDetail>
Your workspace is running but{" "}
{workspace.health.failing_agents.length > 1
? `${workspace.health.failing_agents.length} agents are unhealthy`
: `1 agent is unhealthy`}
.
<div css={styles.alertPendingInQueue}>
This workspace build job is waiting for a provisioner to
become available. If you have been waiting for an extended
period of time, please contact your administrator for
assistance.
</div>
<div>
Position in queue:{" "}
<strong>{workspace.latest_build.job.queue_position}</strong>
</div>
</AlertDetail>
</Alert>
)}
{workspace.latest_build.status === "deleted" && (
<WorkspaceDeletedBanner
handleClick={() => navigate(`/templates`)}
/>
)}
{/* <DormantWorkspaceBanner/> determines its own visibility */}
<DormantWorkspaceBanner
workspace={workspace}
shouldRedisplayBanner={
getLocal("dismissedWorkspace") !== workspace.id
}
onDismiss={() => saveLocal("dismissedWorkspace", workspace.id)}
/>
{workspace.latest_build.job.error && (
<Alert
severity="error"
actions={
<Button
onClick={
canRetryDebugMode
? handleBuildRetryDebug
: handleBuildRetry
}
variant="text"
size="small"
>
Retry{canRetryDebugMode && " in debug mode"}
</Button>
}
>
<AlertTitle>Workspace build failed</AlertTitle>
<AlertDetail>{workspace.latest_build.job.error}</AlertDetail>
</Alert>
)}
{showAlertPendingInQueue && (
<Alert severity="info">
<AlertTitle>Workspace build is pending</AlertTitle>
<AlertDetail>
<div css={styles.alertPendingInQueue}>
This workspace build job is waiting for a provisioner to
become available. If you have been waiting for an extended
period of time, please contact your administrator for
assistance.
</div>
<div>
Position in queue:{" "}
<strong>{workspace.latest_build.job.queue_position}</strong>
</div>
</AlertDetail>
</Alert>
)}
{template?.deprecated && (
<Alert severity="warning">
<AlertTitle>Workspace using deprecated template</AlertTitle>
<AlertDetail>{template?.deprecation_message}</AlertDetail>
</Alert>
)}
{workspace.latest_build.job.error && (
<Alert
severity="error"
actions={
<Button
onClick={
canRetryDebugMode ? handleBuildRetryDebug : handleBuildRetry
}
variant="text"
size="small"
>
Retry{canRetryDebugMode && " in debug mode"}
</Button>
}
>
<AlertTitle>Workspace build failed</AlertTitle>
<AlertDetail>{workspace.latest_build.job.error}</AlertDetail>
</Alert>
)}
{transitionStats !== undefined && (
<WorkspaceBuildProgress
workspace={workspace}
transitionStats={transitionStats}
/>
)}
{template?.deprecated && (
<Alert severity="warning">
<AlertTitle>Workspace using deprecated template</AlertTitle>
<AlertDetail>{template?.deprecation_message}</AlertDetail>
</Alert>
)}
{buildLogs}
{transitionStats !== undefined && (
<WorkspaceBuildProgress
workspace={workspace}
transitionStats={transitionStats}
/>
)}
{buildLogs}
{typeof resources !== "undefined" && resources.length > 0 && (
<Resources
resources={resources}
agentRow={(agent) => (
<AgentRow
key={agent.id}
agent={agent}
workspace={workspace}
sshPrefix={sshPrefix}
showApps={canUpdateWorkspace}
showBuiltinApps={canUpdateWorkspace}
hideSSHButton={hideSSHButton}
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
serverVersion={buildInfo?.version || ""}
serverAPIVersion={buildInfo?.agent_api_version || ""}
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
/>
)}
/>
)}
{workspaceErrors.getBuildsError ? (
<ErrorAlert error={workspaceErrors.getBuildsError} />
) : (
<BuildsTable
builds={builds}
onLoadMoreBuilds={onLoadMoreBuilds}
isLoadingMoreBuilds={isLoadingMoreBuilds}
hasMoreBuilds={hasMoreBuilds}
/>
)}
</Stack>
</Margins>
</>
{resources && resources.length > 0 && (
<Resources
resources={resources}
agentRow={(agent) => (
<AgentRow
key={agent.id}
agent={agent}
workspace={workspace}
sshPrefix={sshPrefix}
showApps={canUpdateWorkspace}
showBuiltinApps={canUpdateWorkspace}
hideSSHButton={hideSSHButton}
hideVSCodeDesktopButton={hideVSCodeDesktopButton}
serverVersion={buildInfo?.version || ""}
serverAPIVersion={buildInfo?.agent_api_version || ""}
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
/>
)}
/>
)}
</Stack>
</div>
</div>
</div>
);
};
const styles = {
content: {
marginTop: 32,
padding: 24,
gridArea: "content",
overflowY: "auto",
},
dotBackground: (theme) => ({
padding: 24,
"--d": "1px",
background: `
radial-gradient(
circle at
var(--d)
var(--d),
${theme.palette.text.secondary} calc(var(--d) - 1px),
${theme.palette.background.default} var(--d)
)
0 0 / 24px 24px
`,
}),
actions: (theme) => ({
[theme.breakpoints.down("md")]: {
flexDirection: "column",
@@ -32,6 +32,7 @@ export const WorkspaceBuildLogsSection: FC<WorkspaceBuildLogsSectionProps> = ({
borderRadius: 8,
border: `1px solid ${theme.palette.divider}`,
overflow: "hidden",
background: theme.palette.background.default,
}}
>
<header
@@ -13,7 +13,6 @@ import {
MockOutdatedWorkspace,
MockTemplateVersionParameter1,
MockTemplateVersionParameter2,
MockBuilds,
MockUser,
MockDeploymentConfig,
MockWorkspaceBuildDelete,
@@ -317,18 +316,6 @@ describe("WorkspacePage", () => {
});
});
it("shows the timeline build", async () => {
await renderWorkspacePage(MockWorkspace);
const table = await screen.findByTestId("builds-table");
// Wait for the results to be loaded
await waitFor(async () => {
const rows = table.querySelectorAll("tbody > tr");
// Added +1 because of the date row
expect(rows).toHaveLength(MockBuilds.length + 1);
});
});
it("restart the workspace with one time parameters when having the confirmation dialog", async () => {
window.localStorage.removeItem(`${MockUser.id}_ignoredWarnings`);
jest.spyOn(api, "getWorkspaceParameters").mockResolvedValue({
+13 -18
View File
@@ -5,8 +5,8 @@ import { WorkspaceReadyPage } from "./WorkspaceReadyPage";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { useOrganizationId } from "hooks";
import { Margins } from "components/Margins/Margins";
import { useInfiniteQuery, useQuery, useQueryClient } from "react-query";
import { infiniteWorkspaceBuilds } from "api/queries/workspaceBuilds";
import { useQuery, useQueryClient } from "react-query";
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
import { templateByName } from "api/queries/templates";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import { checkAuthorization } from "api/queries/authCheck";
@@ -49,27 +49,29 @@ export const WorkspacePage: FC = () => {
});
const permissions = permissionsQuery.data as WorkspacePermissions | undefined;
// Builds
const buildsQuery = useInfiniteQuery({
...infiniteWorkspaceBuilds(workspace?.id ?? ""),
enabled: workspace !== undefined,
});
// Watch workspace changes
const updateWorkspaceData = useEffectEvent(
async (newWorkspaceData: Workspace) => {
if (!workspace) {
throw new Error(
"Applying an update for a workspace that is undefined.",
);
}
queryClient.setQueryData(
workspaceQueryOptions.queryKey,
newWorkspaceData,
);
const hasNewBuild =
newWorkspaceData.latest_build.id !== workspace!.latest_build.id;
newWorkspaceData.latest_build.id !== workspace.latest_build.id;
const lastBuildHasChanged =
newWorkspaceData.latest_build.status !== workspace!.latest_build.status;
newWorkspaceData.latest_build.status !== workspace.latest_build.status;
if (hasNewBuild || lastBuildHasChanged) {
await buildsQuery.refetch();
await queryClient.invalidateQueries(
workspaceBuildsKey(newWorkspaceData.id),
);
}
},
);
@@ -120,13 +122,6 @@ export const WorkspacePage: FC = () => {
workspace={workspace}
template={template}
permissions={permissions}
builds={buildsQuery.data?.pages.flat()}
buildsError={buildsQuery.error}
isLoadingMoreBuilds={buildsQuery.isFetchingNextPage}
onLoadMoreBuilds={async () => {
await buildsQuery.fetchNextPage();
}}
hasMoreBuilds={Boolean(buildsQuery.hasNextPage)}
/>
);
};
@@ -41,22 +41,12 @@ interface WorkspaceReadyPageProps {
template: TypesGen.Template;
workspace: TypesGen.Workspace;
permissions: WorkspacePermissions;
builds: TypesGen.WorkspaceBuild[] | undefined;
buildsError: unknown;
onLoadMoreBuilds: () => void;
isLoadingMoreBuilds: boolean;
hasMoreBuilds: boolean;
}
export const WorkspaceReadyPage = ({
workspace,
template,
permissions,
builds,
buildsError,
onLoadMoreBuilds,
isLoadingMoreBuilds,
hasMoreBuilds,
}: WorkspaceReadyPageProps): JSX.Element => {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -235,17 +225,12 @@ export const WorkspaceReadyPage = ({
}
}}
resources={workspace.latest_build.resources}
builds={builds}
onLoadMoreBuilds={onLoadMoreBuilds}
isLoadingMoreBuilds={isLoadingMoreBuilds}
hasMoreBuilds={hasMoreBuilds}
canUpdateWorkspace={canUpdateWorkspace}
updateMessage={latestVersion?.message}
canChangeVersions={canChangeVersions}
hideSSHButton={featureVisibility["browser_only"]}
hideVSCodeDesktopButton={featureVisibility["browser_only"]}
workspaceErrors={{
getBuildsError: buildsError,
buildError:
restartBuildError ??
startWorkspaceMutation.error ??
@@ -101,7 +101,7 @@ export const WorkspaceTopbar = (props: WorkspaceProps) => {
);
return (
<Topbar>
<Topbar css={{ gridArea: "topbar" }}>
<Tooltip title="Back to workspaces">
<TopbarIconButton component={RouterLink} to="/workspaces">
<ArrowBackOutlined />
+4
View File
@@ -94,6 +94,10 @@ export const components = {
"& .MuiLoadingButton-loadingIndicator": {
width: 14,
height: 14,
// Idk why but I found the loading indicator in the loading buttons
// does not align with the start icon from the regular button so this
// is a visual adjustment.
left: -6,
},
"& .MuiLoadingButton-loadingIndicator .MuiCircularProgress-root": {