diff --git a/site/src/components/FullPageLayout/Sidebar.tsx b/site/src/components/FullPageLayout/Sidebar.tsx new file mode 100644 index 0000000000000..df75826c8c6bf --- /dev/null +++ b/site/src/components/FullPageLayout/Sidebar.tsx @@ -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) => { + const theme = useTheme(); + return ( +
+ ); +}; + +export const SidebarLink = (props: LinkProps) => { + return ; +}; + +export const SidebarItem = (props: HTMLAttributes) => { + return
)} -
+
= ({ onRename={(file) => setRenameFileOpen(file)} activePath={activePath} /> -
+
{ return [...logs].sort( @@ -112,15 +110,20 @@ export const WorkspaceBuildPageView: FC = ({ Builds {!builds && Array.from({ length: 15 }, (_, i) => ( - + + + ))} {builds?.map((build) => ( - + to={`/@${build.workspace_owner_name}/${build.workspace_name}/builds/${build.build_number}`} + > + + + + ))} @@ -167,78 +170,6 @@ export const WorkspaceBuildPageView: FC = ({ ); }; -interface BuildSidebarItemProps { - build: WorkspaceBuild; - active: boolean; -} - -const BuildSidebarItem: FC = ({ build, active }) => { - const theme = useTheme(); - const statusType = getDisplayWorkspaceBuildStatus(theme, build).type; - - return ( - - -
- -
-
- {build.transition} by{" "} - {getDisplayWorkspaceBuildInitiatedBy(build)} -
-
- {displayWorkspaceBuildDuration(build)} -
-
-
-
- - ); -}; - -const BuildSidebarItemSkeleton: FC = () => { - return ( - -
- -
- - -
-
-
- ); -}; - const styles = { stats: (theme) => ({ padding: 0, diff --git a/site/src/pages/WorkspacePage/BuildsTable.stories.tsx b/site/src/pages/WorkspacePage/BuildsTable.stories.tsx deleted file mode 100644 index 7a86c78faf970..0000000000000 --- a/site/src/pages/WorkspacePage/BuildsTable.stories.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; -import { MockBuilds } from "testHelpers/entities"; -import { BuildsTable } from "./BuildsTable"; - -const meta: Meta = { - title: "pages/WorkspacePage/BuildsTable", - component: BuildsTable, -}; - -export default meta; -type Story = StoryObj; - -export const Example: Story = { - args: { - builds: MockBuilds, - hasMoreBuilds: true, - }, -}; - -export const Empty: Story = { - args: { - builds: [], - }, -}; - -export const NoMoreBuilds: Story = { - args: { - builds: MockBuilds, - hasMoreBuilds: false, - }, -}; diff --git a/site/src/pages/WorkspacePage/BuildsTable.tsx b/site/src/pages/WorkspacePage/BuildsTable.tsx deleted file mode 100644 index 30637967911f8..0000000000000 --- a/site/src/pages/WorkspacePage/BuildsTable.tsx +++ /dev/null @@ -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 = ({ - builds, - onLoadMoreBuilds, - isLoadingMoreBuilds, - hasMoreBuilds, -}) => { - return ( - - - - - {builds ? ( - new Date(build.created_at)} - row={(build) => } - /> - ) : ( - - )} - - {builds && builds.length === 0 && ( - - -
- -
-
-
- )} -
-
-
- {hasMoreBuilds && ( - } - css={{ - display: "inline-flex", - margin: "auto", - borderRadius: "9999px", - }} - > - Load previous builds - - )} -
- ); -}; diff --git a/site/src/pages/WorkspacePage/HistorySidebar.tsx b/site/src/pages/WorkspacePage/HistorySidebar.tsx new file mode 100644 index 0000000000000..27c757b3fde2a --- /dev/null +++ b/site/src/pages/WorkspacePage/HistorySidebar.tsx @@ -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 ( + + History + {builds + ? builds.map((build) => ( + + + + )) + : Array.from({ length: 15 }, (_, i) => ( + + + + ))} + {buildsQuery.hasNextPage && ( +
+ buildsQuery.fetchNextPage()} + loading={buildsQuery.isFetchingNextPage} + loadingPosition="start" + variant="outlined" + color="neutral" + startIcon={} + css={{ + display: "inline-flex", + borderRadius: "9999px", + fontSize: 13, + }} + > + Show more builds + +
+ )} +
+ ); +}; diff --git a/site/src/pages/WorkspacePage/ResourcesSidebarContent.tsx b/site/src/pages/WorkspacePage/ResourcesSidebarContent.tsx new file mode 100644 index 0000000000000..ebc43cf73dbaf --- /dev/null +++ b/site/src/pages/WorkspacePage/ResourcesSidebarContent.tsx @@ -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 ( + <> + Resources + {workspace.latest_build.resources.map((r) => ( + + {r.name} + + {r.type} + + + ))} + + ); +}; diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 5a86338f6b1f4..12d1a39cb2b13 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -54,7 +54,6 @@ const meta: Meta = { 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, diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 33fe39c92aff2..fdb5eabc95c61 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -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> = ({ isUpdating, isRestarting, resources, - builds, + canUpdateWorkspace, updateMessage, canChangeVersions, @@ -93,13 +93,13 @@ export const Workspace: FC> = ({ 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> = ({ template !== undefined ? ActiveTransition(template, workspace) : undefined; return ( - <> +
> = ({ canUpdateWorkspace={canUpdateWorkspace} /> - - - {workspace.outdated && - (requiresManualUpdate ? ( - - - Autostart has been disabled for your workspace. - +
+ { + const sidebarOption = searchParams.get("sidebar"); + if (sidebarOption === "history") { + searchParams.delete("sidebar"); + } else { + searchParams.set("sidebar", "history"); + } + setSearchParams(searchParams); + }} + > + + +
+ + {searchParams.get("sidebar") === "history" && ( + + )} + +
+
+ + {workspace.outdated && + (requiresManualUpdate ? ( + + + Autostart has been disabled for your workspace. + + + Autostart is unable to automatically update your workspace. + Manually update your workspace to reenable Autostart. + + + ) : ( + + + An update is available for your workspace + + {updateMessage && {updateMessage}} + + ))} + + {Boolean(workspaceErrors.buildError) && ( + + )} + + {Boolean(workspaceErrors.cancellationError) && ( + + )} + + {workspace.latest_build.status === "running" && + !workspace.health.healthy && ( + { + handleRestart(); + }} + > + Restart + + ) + } + > + Workspace is unhealthy + + Your workspace is running but{" "} + {workspace.health.failing_agents.length > 1 + ? `${workspace.health.failing_agents.length} agents are unhealthy` + : `1 agent is unhealthy`} + . + + + )} + + {workspace.latest_build.status === "deleted" && ( + navigate(`/templates`)} + /> + )} + {/* determines its own visibility */} + saveLocal("dismissedWorkspace", workspace.id)} + /> + + {showAlertPendingInQueue && ( + + Workspace build is pending - Autostart is unable to automatically update your workspace. - Manually update your workspace to reenable Autostart. +
+ 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. +
+
+ Position in queue:{" "} + {workspace.latest_build.job.queue_position} +
- ) : ( - - - An update is available for your workspace - - {updateMessage && {updateMessage}} - - ))} - - {Boolean(workspaceErrors.buildError) && ( - - )} - - {Boolean(workspaceErrors.cancellationError) && ( - - )} + )} - {workspace.latest_build.status === "running" && - !workspace.health.healthy && ( + {workspace.latest_build.job.error && ( { - handleRestart(); - }} - > - Restart - - ) + } > - Workspace is unhealthy - - Your workspace is running but{" "} - {workspace.health.failing_agents.length > 1 - ? `${workspace.health.failing_agents.length} agents are unhealthy` - : `1 agent is unhealthy`} - . - + Workspace build failed + {workspace.latest_build.job.error} )} - {workspace.latest_build.status === "deleted" && ( - navigate(`/templates`)} - /> - )} - {/* determines its own visibility */} - saveLocal("dismissedWorkspace", workspace.id)} - /> - - {showAlertPendingInQueue && ( - - Workspace build is pending - -
- 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. -
-
- Position in queue:{" "} - {workspace.latest_build.job.queue_position} -
-
-
- )} - - {workspace.latest_build.job.error && ( - - Retry{canRetryDebugMode && " in debug mode"} - - } - > - Workspace build failed - {workspace.latest_build.job.error} - - )} - - {template?.deprecated && ( - - Workspace using deprecated template - {template?.deprecation_message} - - )} - - {transitionStats !== undefined && ( - - )} - - {buildLogs} - - {typeof resources !== "undefined" && resources.length > 0 && ( - ( - - )} - /> - )} - - {workspaceErrors.getBuildsError ? ( - - ) : ( - - )} -
- - + {template?.deprecated && ( + + Workspace using deprecated template + {template?.deprecation_message} + + )} + + {transitionStats !== undefined && ( + + )} + + {buildLogs} + + {resources && resources.length > 0 && ( + ( + + )} + /> + )} + +
+
+
); }; 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", diff --git a/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx index ceb491277a00d..7460021b52bb8 100644 --- a/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceBuildLogsSection.tsx @@ -32,6 +32,7 @@ export const WorkspaceBuildLogsSection: FC = ({ borderRadius: 8, border: `1px solid ${theme.palette.divider}`, overflow: "hidden", + background: theme.palette.background.default, }} >
{ }); }); - 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({ diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index dfc124d2509a0..7905faf24425a 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -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)} /> ); }; diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index e917deafa2b74..eecbe295a243b 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -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 ?? diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx index c963ebc8b683f..0a01c1bab4eaa 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.tsx @@ -101,7 +101,7 @@ export const WorkspaceTopbar = (props: WorkspaceProps) => { ); return ( - + diff --git a/site/src/theme/mui.ts b/site/src/theme/mui.ts index c4c9a2db3e84a..96316509ebc6f 100644 --- a/site/src/theme/mui.ts +++ b/site/src/theme/mui.ts @@ -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": {