diff --git a/site/.storybook/preview.jsx b/site/.storybook/preview.jsx index 21d8d64e79ae7..54e2c3f2f7b1d 100644 --- a/site/.storybook/preview.jsx +++ b/site/.storybook/preview.jsx @@ -78,6 +78,7 @@ function withQuery(Story, { parameters }) { defaultOptions: { queries: { staleTime: Infinity, + retry: false, }, }, }); diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 5ee5f9a654436..8c4584ae34895 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -370,7 +370,6 @@ export const AppRouter: FC = () => { {/* In order for the 404 page to work properly the routes that start with top level parameter must be fully qualified. */} - } /> } @@ -413,6 +412,7 @@ export const AppRouter: FC = () => { {/* Pages that don't have the dashboard layout */} + } /> } diff --git a/site/src/api/queries/workspaceQuota.ts b/site/src/api/queries/workspaceQuota.ts index 32c94eeb5ad39..f43adf616688e 100644 --- a/site/src/api/queries/workspaceQuota.ts +++ b/site/src/api/queries/workspaceQuota.ts @@ -12,7 +12,7 @@ export const workspaceQuota = (username: string) => { }; }; -const getWorkspaceResolveAutostartQueryKey = (workspaceId: string) => [ +export const getWorkspaceResolveAutostartQueryKey = (workspaceId: string) => [ workspaceId, "workspaceResolveAutostart", ]; diff --git a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx deleted file mode 100644 index 6c03ccdbad74a..0000000000000 --- a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { formatDistanceToNow } from "date-fns"; -import { ReactNode, type FC } from "react"; -import type { Workspace } from "api/typesGenerated"; -import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"; -import { Alert } from "components/Alert/Alert"; - -export enum Count { - Singular, - Multiple, -} - -interface DormantWorkspaceBannerProps { - workspace: Workspace; - onDismiss: () => void; - shouldRedisplayBanner: boolean; -} - -export const DormantWorkspaceBanner: FC = ({ - workspace, - onDismiss, - shouldRedisplayBanner, -}) => { - const experimentEnabled = useIsWorkspaceActionsEnabled(); - - if ( - // Only show this if the experiment is included. - !experimentEnabled || - !workspace.dormant_at || - // Banners should be redisplayed after dismissal when additional workspaces are newly scheduled for deletion - !shouldRedisplayBanner - ) { - return null; - } - - const formatDate = (dateStr: string, timestamp: boolean): string => { - const date = new Date(dateStr); - return date.toLocaleDateString(undefined, { - month: "long", - day: "numeric", - year: "numeric", - ...(timestamp ? { hour: "numeric", minute: "numeric" } : {}), - }); - }; - - const alertText = (): ReactNode => { - if (workspace.deleting_at && workspace.dormant_at) { - return ( - <> - This workspace has not been used for{" "} - {formatDistanceToNow(Date.parse(workspace.last_used_at))} and was - marked dormant on {formatDate(workspace.dormant_at, false)}. It is - scheduled to be deleted on {formatDate(workspace.deleting_at, true)}. - To keep it you must activate the workspace. - - ); - } else if (workspace.dormant_at) { - return ( - <> - This workspace has not been used for{" "} - {formatDistanceToNow(Date.parse(workspace.last_used_at))} and was - marked dormant on {formatDate(workspace.dormant_at, false)}. It is not - scheduled for auto-deletion but will become a candidate if - auto-deletion is enabled on this template. To keep it you must - activate the workspace. - - ); - } - return ""; - }; - - return ( - - {alertText()} - - ); -}; diff --git a/site/src/components/WorkspaceDeletion/index.ts b/site/src/components/WorkspaceDeletion/index.ts deleted file mode 100644 index 20c01e31d8f09..0000000000000 --- a/site/src/components/WorkspaceDeletion/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./DormantDeletionText"; -export * from "./DormantWorkspaceBanner"; diff --git a/site/src/components/WorkspaceDeletion/DormantDeletionText.tsx b/site/src/components/WorkspaceStatusBadge/DormantDeletionText.tsx similarity index 100% rename from site/src/components/WorkspaceDeletion/DormantDeletionText.tsx rename to site/src/components/WorkspaceStatusBadge/DormantDeletionText.tsx diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 798b70bfde592..1b40462adcf6d 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -9,7 +9,7 @@ import { type FC, type ReactNode } from "react"; import type { Workspace } from "api/typesGenerated"; import { Pill } from "components/Pill/Pill"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; -import { DormantDeletionText } from "components/WorkspaceDeletion"; +import { DormantDeletionText } from "./DormantDeletionText"; import { getDisplayWorkspaceStatus } from "utils/workspace"; import { useClassName } from "hooks/useClassName"; import { formatDistanceToNow } from "date-fns"; diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 341a310226979..47d1fad733552 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -9,6 +9,7 @@ import EventSource from "eventsourcemock"; import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext"; import { DashboardProviderContext } from "components/Dashboard/DashboardProvider"; import { WorkspaceBuildLogsSection } from "pages/WorkspacePage/WorkspaceBuildLogsSection"; +import { WorkspacePermissions } from "./permissions"; const MockedAppearance = { config: Mocks.MockAppearanceConfig, @@ -16,8 +17,16 @@ const MockedAppearance = { setPreview: () => {}, }; +const permissions: WorkspacePermissions = { + readWorkspace: true, + updateWorkspace: true, + updateTemplate: true, + viewDeploymentValues: true, +}; + const meta: Meta = { title: "pages/WorkspacePage/Workspace", + args: { permissions }, component: Workspace, decorators: [ (Story) => ( @@ -68,8 +77,6 @@ export const Running: Story = { workspace: Mocks.MockWorkspace, handleStart: action("start"), handleStop: action("stop"), - canUpdateWorkspace: true, - workspaceErrors: {}, buildInfo: Mocks.MockBuildInfo, template: Mocks.MockTemplate, }, @@ -78,7 +85,10 @@ export const Running: Story = { export const WithoutUpdateAccess: Story = { args: { ...Running.args, - canUpdateWorkspace: false, + permissions: { + ...permissions, + updateWorkspace: false, + }, }, }; @@ -110,18 +120,6 @@ export const Stopping: Story = { }, }; -export const Failed: Story = { - args: { - ...Running.args, - workspace: Mocks.MockFailedWorkspace, - workspaceErrors: { - buildError: Mocks.mockApiError({ - message: "A workspace build is already active.", - }), - }, - }, -}; - export const FailedWithLogs: Story = { args: { ...Running.args, @@ -186,70 +184,6 @@ export const Canceled: Story = { }, }; -export const Outdated: Story = { - args: { - ...Running.args, - workspace: Mocks.MockOutdatedWorkspace, - }, -}; - -export const CantAutostart: Story = { - args: { - ...Running.args, - canAutostart: false, - workspace: Mocks.MockOutdatedRunningWorkspaceRequireActiveVersion, - }, -}; - -export const GetBuildsError: Story = { - args: { - ...Running.args, - workspaceErrors: { - getBuildsError: Mocks.mockApiError({ - message: "There is a problem fetching builds.", - }), - }, - }, -}; - -export const CancellationError: Story = { - args: { - ...Failed.args, - workspaceErrors: { - cancellationError: Mocks.mockApiError({ - message: "Job could not be canceled.", - }), - }, - buildLogs: , - }, -}; - -export const Deprecated: Story = { - args: { - ...Running.args, - template: { - ...Mocks.MockTemplate, - deprecated: true, - deprecation_message: - "Template deprecated due to reasons. [Learn more](#)", - }, - }, -}; - -export const Unhealthy: Story = { - args: { - ...Running.args, - workspace: { - ...Mocks.MockWorkspace, - latest_build: { ...Mocks.MockWorkspace.latest_build, status: "running" }, - health: { - healthy: false, - failing_agents: [], - }, - }, - }, -}; - function makeFailedBuildLogs(): ProvisionerJobLog[] { return [ { diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 314d64ec25404..87e19d3197976 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -1,16 +1,12 @@ 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 { type FC } from "react"; import { useNavigate } from "react-router-dom"; -import dayjs from "dayjs"; import type * as TypesGen from "api/typesGenerated"; import { Alert, AlertDetail } from "components/Alert/Alert"; -import { Stack } from "components/Stack/Stack"; -import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { DormantWorkspaceBanner } from "components/WorkspaceDeletion"; import { AgentRow } from "components/Resources/AgentRow"; -import { useLocalStorage, useTab } from "hooks"; +import { useTab } from "hooks"; import { ActiveTransition, WorkspaceBuildProgress, @@ -18,23 +14,14 @@ import { 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"; import HubOutlined from "@mui/icons-material/HubOutlined"; import { ResourcesSidebar } from "./ResourcesSidebar"; import { ResourceCard } from "components/Resources/ResourceCard"; +import { WorkspacePermissions } from "./permissions"; import { resourceOptionValue, useResourcesNav } from "./useResourcesNav"; -import { MemoizedInlineMarkdown } from "components/Markdown/Markdown"; - -export type WorkspaceError = - | "getBuildsError" - | "buildError" - | "cancellationError"; - -export type WorkspaceErrors = Partial>; export interface WorkspaceProps { handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void; @@ -49,12 +36,9 @@ export interface WorkspaceProps { isUpdating: boolean; isRestarting: boolean; workspace: TypesGen.Workspace; - canUpdateWorkspace: boolean; - updateMessage?: string; canChangeVersions: boolean; hideSSHButton?: boolean; hideVSCodeDesktopButton?: boolean; - workspaceErrors: WorkspaceErrors; buildInfo?: TypesGen.BuildInfoResponse; sshPrefix?: string; template: TypesGen.Template; @@ -62,7 +46,8 @@ export interface WorkspaceProps { handleBuildRetry: () => void; handleBuildRetryDebug: () => void; buildLogs?: React.ReactNode; - canAutostart: boolean; + latestVersion?: TypesGen.TemplateVersion; + permissions: WorkspacePermissions; isOwner: boolean; } @@ -82,10 +67,7 @@ export const Workspace: FC = ({ workspace, isUpdating, isRestarting, - canUpdateWorkspace, - updateMessage, canChangeVersions, - workspaceErrors, hideSSHButton, hideVSCodeDesktopButton, buildInfo, @@ -95,57 +77,13 @@ export const Workspace: FC = ({ handleBuildRetry, handleBuildRetryDebug, buildLogs, - canAutostart, + latestVersion, + permissions, isOwner, }) => { const navigate = useNavigate(); - const { saveLocal, getLocal } = useLocalStorage(); const theme = useTheme(); - const [showAlertPendingInQueue, setShowAlertPendingInQueue] = useState(false); - - // 2023-11-15 - MES - This effect will be called every single render because - // "now" will always change and invalidate the dependency array. Need to - // figure out if this effect really should run every render (possibly meaning - // no dependency array at all), or how to get the array stabilized (ideal) - const now = dayjs(); - useEffect(() => { - if ( - workspace.latest_build.status !== "pending" || - workspace.latest_build.job.queue_size === 0 - ) { - if (!showAlertPendingInQueue) { - return; - } - - const hideTimer = setTimeout(() => { - setShowAlertPendingInQueue(false); - }, 250); - return () => { - clearTimeout(hideTimer); - }; - } - - const t = Math.max( - 0, - 5000 - dayjs().diff(dayjs(workspace.latest_build.created_at)), - ); - const showTimer = setTimeout(() => { - setShowAlertPendingInQueue(true); - }, t); - - return () => { - clearTimeout(showTimer); - }; - }, [workspace, now, showAlertPendingInQueue]); - - const updateRequired = - (workspace.template_require_active_version || - workspace.automatic_updates === "always") && - workspace.outdated; - const autoStartFailing = workspace.autostart_schedule && !canAutostart; - const requiresManualUpdate = updateRequired && autoStartFailing; - const transitionStats = template !== undefined ? ActiveTransition(template, workspace) : undefined; @@ -176,8 +114,6 @@ export const Workspace: FC = ({ "topbar topbar topbar" auto "leftbar sidebar content" 1fr / auto auto 1fr `, - maxHeight: `calc(100vh - ${navHeight + bannerHeight}px)`, - marginBottom: `-${dashboardContentBottomPadding}px`, }} > = ({ canChangeVersions={canChangeVersions} isUpdating={isUpdating} isRestarting={isRestarting} - canUpdateWorkspace={canUpdateWorkspace} + canUpdateWorkspace={permissions.updateWorkspace} isOwner={isOwner} + template={template} + permissions={permissions} + latestVersion={latestVersion} />
= ({
- - {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 - -
- 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 && ( = ({ )} - {template?.deprecated && ( - - - This workspace uses a deprecated template - - - - {template?.deprecation_message} - - - - )} - {transitionStats !== undefined && ( = ({ agent={agent} workspace={workspace} sshPrefix={sshPrefix} - showApps={canUpdateWorkspace} - showBuiltinApps={canUpdateWorkspace} + showApps={permissions.updateWorkspace} + showBuiltinApps={permissions.updateWorkspace} hideSSHButton={hideSSHButton} hideVSCodeDesktopButton={hideVSCodeDesktopButton} serverVersion={buildInfo?.version || ""} @@ -400,7 +240,7 @@ export const Workspace: FC = ({ )} /> )} - +
@@ -420,7 +260,7 @@ const styles = { dotBackground: (theme) => ({ minHeight: "100%", - padding: 24, + padding: 23, "--d": "1px", background: ` radial-gradient( @@ -440,12 +280,4 @@ const styles = { flexDirection: "column", }, }), - - firstColumnSpacer: { - flex: 2, - }, - - alertPendingInQueue: { - marginBottom: 12, - }, } satisfies Record>; diff --git a/site/src/pages/WorkspacePage/WorkspaceNotifications/Notifications.tsx b/site/src/pages/WorkspacePage/WorkspaceNotifications/Notifications.tsx new file mode 100644 index 0000000000000..ed6636e9ab57b --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceNotifications/Notifications.tsx @@ -0,0 +1,130 @@ +import { FC, ReactNode } from "react"; +import { Pill } from "components/Pill/Pill"; +import { + Popover, + PopoverContent, + PopoverTrigger, + usePopover, +} from "components/Popover/Popover"; +import { Interpolation, Theme, useTheme } from "@emotion/react"; +import Button, { ButtonProps } from "@mui/material/Button"; +import { ThemeRole } from "theme/experimental"; +import { AlertProps } from "components/Alert/Alert"; + +export type NotificationItem = { + title: string; + severity: AlertProps["severity"]; + detail?: ReactNode; + actions?: ReactNode; +}; + +type NotificationsProps = { + items: NotificationItem[]; + severity: ThemeRole; + icon: ReactNode; + isDefaultOpen?: boolean; +}; + +export const Notifications: FC = ({ + items, + severity, + icon, + isDefaultOpen, +}) => { + const theme = useTheme(); + + return ( + + +
+ +
+
+ + {items.map((n) => ( + + ))} + +
+ ); +}; + +const NotificationPill = (props: NotificationsProps) => { + const { items, severity, icon } = props; + const popover = usePopover(); + + return ( + ({ + "& svg": { color: theme.experimental.roles[severity].outline }, + borderColor: popover.isOpen + ? theme.experimental.roles[severity].outline + : undefined, + })} + > + {items.length} + + ); +}; + +const NotificationItem: FC<{ notification: NotificationItem }> = (props) => { + const { notification } = props; + + return ( +
+

{notification.title}

+ {notification.detail && ( +

{notification.detail}

+ )} +
{notification.actions}
+
+ ); +}; + +export const NotificationActionButton: FC = (props) => { + return ( +