refactor(site): refactor workspace notifications (#11520)

This commit is contained in:
Bruno Quaresma
2024-01-12 15:55:31 -03:00
committed by GitHub
parent bdefd4e2e6
commit 130d5d68a0
16 changed files with 616 additions and 395 deletions
+1
View File
@@ -78,6 +78,7 @@ function withQuery(Story, { parameters }) {
defaultOptions: {
queries: {
staleTime: Infinity,
retry: false,
},
},
});
+1 -1
View File
@@ -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. */}
<Route path="/:username/:workspace" element={<WorkspacePage />} />
<Route
path="/:username/:workspace/builds/:buildNumber"
element={<WorkspaceBuildPage />}
@@ -413,6 +412,7 @@ export const AppRouter: FC = () => {
</Route>
{/* Pages that don't have the dashboard layout */}
<Route path="/:username/:workspace" element={<WorkspacePage />} />
<Route
path="/templates/:template/versions/:version/edit"
element={<TemplateVersionEditorPage />}
+1 -1
View File
@@ -12,7 +12,7 @@ export const workspaceQuota = (username: string) => {
};
};
const getWorkspaceResolveAutostartQueryKey = (workspaceId: string) => [
export const getWorkspaceResolveAutostartQueryKey = (workspaceId: string) => [
workspaceId,
"workspaceResolveAutostart",
];
@@ -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<DormantWorkspaceBannerProps> = ({
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 (
<Alert severity="warning" onDismiss={onDismiss} dismissible>
{alertText()}
</Alert>
);
};
@@ -1,2 +0,0 @@
export * from "./DormantDeletionText";
export * from "./DormantWorkspaceBanner";
@@ -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";
@@ -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<typeof Workspace> = {
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: <WorkspaceBuildLogsSection logs={makeFailedBuildLogs()} />,
},
};
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 [
{
+16 -184
View File
@@ -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<Record<WorkspaceError, unknown>>;
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<WorkspaceProps> = ({
workspace,
isUpdating,
isRestarting,
canUpdateWorkspace,
updateMessage,
canChangeVersions,
workspaceErrors,
hideSSHButton,
hideVSCodeDesktopButton,
buildInfo,
@@ -95,57 +77,13 @@ export const Workspace: FC<WorkspaceProps> = ({
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<WorkspaceProps> = ({
"topbar topbar topbar" auto
"leftbar sidebar content" 1fr / auto auto 1fr
`,
maxHeight: `calc(100vh - ${navHeight + bannerHeight}px)`,
marginBottom: `-${dashboardContentBottomPadding}px`,
}}
>
<WorkspaceTopbar
@@ -197,8 +133,11 @@ export const Workspace: FC<WorkspaceProps> = ({
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
canUpdateWorkspace={canUpdateWorkspace}
canUpdateWorkspace={permissions.updateWorkspace}
isOwner={isOwner}
template={template}
permissions={permissions}
latestVersion={latestVersion}
/>
<div
@@ -243,98 +182,12 @@ export const Workspace: FC<WorkspaceProps> = ({
<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>
)}
<div css={{ display: "flex", flexDirection: "column", gap: 24 }}>
{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>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>
)}
{workspace.latest_build.job.error && (
<Alert
@@ -358,19 +211,6 @@ export const Workspace: FC<WorkspaceProps> = ({
</Alert>
)}
{template?.deprecated && (
<Alert severity="warning">
<AlertTitle>
This workspace uses a deprecated template
</AlertTitle>
<AlertDetail>
<MemoizedInlineMarkdown>
{template?.deprecation_message}
</MemoizedInlineMarkdown>
</AlertDetail>
</Alert>
)}
{transitionStats !== undefined && (
<WorkspaceBuildProgress
workspace={workspace}
@@ -389,8 +229,8 @@ export const Workspace: FC<WorkspaceProps> = ({
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<WorkspaceProps> = ({
)}
/>
)}
</Stack>
</div>
</div>
</div>
</div>
@@ -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<string, Interpolation<Theme>>;
@@ -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<NotificationsProps> = ({
items,
severity,
icon,
isDefaultOpen,
}) => {
const theme = useTheme();
return (
<Popover mode="hover" isDefaultOpen={isDefaultOpen}>
<PopoverTrigger>
<div css={styles.pillContainer}>
<NotificationPill items={items} severity={severity} icon={icon} />
</div>
</PopoverTrigger>
<PopoverContent
horizontal="right"
css={{
"& .MuiPaper-root": {
borderColor: theme.experimental.roles[severity].outline,
maxWidth: 400,
},
}}
>
{items.map((n) => (
<NotificationItem notification={n} key={n.title} />
))}
</PopoverContent>
</Popover>
);
};
const NotificationPill = (props: NotificationsProps) => {
const { items, severity, icon } = props;
const popover = usePopover();
return (
<Pill
icon={icon}
css={(theme) => ({
"& svg": { color: theme.experimental.roles[severity].outline },
borderColor: popover.isOpen
? theme.experimental.roles[severity].outline
: undefined,
})}
>
{items.length}
</Pill>
);
};
const NotificationItem: FC<{ notification: NotificationItem }> = (props) => {
const { notification } = props;
return (
<article css={styles.notificationItem}>
<h4 css={{ margin: 0, fontWeight: 500 }}>{notification.title}</h4>
{notification.detail && (
<p css={styles.notificationDetail}>{notification.detail}</p>
)}
<div css={{ marginTop: 8 }}>{notification.actions}</div>
</article>
);
};
export const NotificationActionButton: FC<ButtonProps> = (props) => {
return (
<Button
variant="text"
css={{
textDecoration: "underline",
padding: 0,
height: "auto",
minWidth: "auto",
"&:hover": { background: "none", textDecoration: "underline" },
}}
{...props}
/>
);
};
const styles = {
// Adds some spacing from the popover content
pillContainer: {
padding: "8px 0",
},
notificationItem: (theme) => ({
padding: 20,
lineHeight: "1.5",
borderTop: `1px solid ${theme.palette.divider}`,
"&:first-child": {
borderTop: 0,
},
}),
notificationDetail: (theme) => ({
margin: 0,
color: theme.palette.text.secondary,
lineHeight: 1.6,
display: "block",
marginTop: 8,
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -0,0 +1,150 @@
import {
MockOutdatedWorkspace,
MockTemplate,
MockTemplateVersion,
MockWorkspace,
} from "testHelpers/entities";
import { WorkspaceNotifications } from "./WorkspaceNotifications";
import type { Meta, StoryObj } from "@storybook/react";
import { withDashboardProvider } from "testHelpers/storybook";
import { getWorkspaceResolveAutostartQueryKey } from "api/queries/workspaceQuota";
const defaultPermissions = {
readWorkspace: true,
updateTemplate: true,
updateWorkspace: true,
viewDeploymentValues: true,
};
const meta: Meta<typeof WorkspaceNotifications> = {
title: "components/WorkspaceNotifications",
component: WorkspaceNotifications,
args: {
latestVersion: MockTemplateVersion,
template: MockTemplate,
workspace: MockWorkspace,
permissions: defaultPermissions,
},
decorators: [withDashboardProvider],
parameters: {
queries: [
{
key: getWorkspaceResolveAutostartQueryKey(MockOutdatedWorkspace.id),
data: {
parameter_mismatch: false,
},
},
],
features: ["advanced_template_scheduling"],
},
};
export default meta;
type Story = StoryObj<typeof WorkspaceNotifications>;
export const Outdated: Story = {
args: {
workspace: MockOutdatedWorkspace,
defaultOpen: "info",
},
};
export const RequiresManualUpdate: Story = {
args: {
workspace: {
...MockOutdatedWorkspace,
automatic_updates: "always",
autostart_schedule: "daily",
},
defaultOpen: "warning",
},
parameters: {
queries: [
{
key: getWorkspaceResolveAutostartQueryKey(MockOutdatedWorkspace.id),
data: {
parameter_mismatch: true,
},
},
],
},
};
export const Unhealthy: Story = {
args: {
workspace: {
...MockWorkspace,
health: {
...MockWorkspace.health,
healthy: false,
},
latest_build: {
...MockWorkspace.latest_build,
status: "running",
},
},
defaultOpen: "warning",
},
};
export const UnhealthyWithoutUpdatePermission: Story = {
args: {
...Unhealthy.args,
permissions: {
...defaultPermissions,
updateWorkspace: false,
},
},
};
const DormantWorkspace = {
...MockWorkspace,
dormant_at: new Date("2020-01-01T00:00:00Z").toISOString(),
};
export const Dormant: Story = {
args: {
defaultOpen: "warning",
workspace: DormantWorkspace,
},
};
export const DormantWithDeletingDate: Story = {
args: {
...Dormant.args,
workspace: {
...DormantWorkspace,
deleting_at: new Date("2020-10-01T00:00:00Z").toISOString(),
},
},
};
export const PendingInQueue: Story = {
args: {
workspace: {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
status: "pending",
job: {
...MockWorkspace.latest_build.job,
queue_size: 10,
queue_position: 3,
},
},
},
defaultOpen: "info",
},
};
export const TemplateDeprecated: Story = {
args: {
template: {
...MockTemplate,
deprecated: true,
deprecation_message:
"Template deprecated due to reasons. [Learn more](#)",
},
defaultOpen: "warning",
},
};
@@ -0,0 +1,249 @@
import { workspaceResolveAutostart } from "api/queries/workspaceQuota";
import { Template, TemplateVersion, Workspace } from "api/typesGenerated";
import { FC, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { WorkspacePermissions } from "../permissions";
import dayjs from "dayjs";
import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider";
import formatDistanceToNow from "date-fns/formatDistanceToNow";
import InfoOutlined from "@mui/icons-material/InfoOutlined";
import WarningRounded from "@mui/icons-material/WarningRounded";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
import {
NotificationActionButton,
NotificationItem,
Notifications,
} from "./Notifications";
import { Interpolation, Theme } from "@emotion/react";
type WorkspaceNotificationsProps = {
workspace: Workspace;
template: Template;
permissions: WorkspacePermissions;
onRestartWorkspace: () => void;
onUpdateWorkspace: () => void;
onActivateWorkspace: () => void;
latestVersion?: TemplateVersion;
// Used for storybook
defaultOpen?: "info" | "warning";
};
export const WorkspaceNotifications: FC<WorkspaceNotificationsProps> = ({
workspace,
template,
latestVersion,
permissions,
defaultOpen,
onRestartWorkspace,
onUpdateWorkspace,
onActivateWorkspace,
}) => {
const notifications: NotificationItem[] = [];
// Outdated
const canAutostartQuery = useQuery(workspaceResolveAutostart(workspace.id));
const isParameterMismatch =
canAutostartQuery.data?.parameter_mismatch ?? false;
const canAutostart = !isParameterMismatch;
const updateRequired =
(workspace.template_require_active_version ||
workspace.automatic_updates === "always") &&
workspace.outdated;
const autoStartFailing = workspace.autostart_schedule && !canAutostart;
const requiresManualUpdate = updateRequired && autoStartFailing;
if (workspace.outdated && latestVersion) {
const actions = (
<NotificationActionButton onClick={onUpdateWorkspace}>
Update
</NotificationActionButton>
);
if (requiresManualUpdate) {
notifications.push({
title: "Autostart has been disabled for your workspace.",
severity: "warning",
detail:
"Autostart is unable to automatically update your workspace. Manually update your workspace to reenable Autostart.",
actions,
});
} else {
notifications.push({
title: "An update is available for your workspace",
severity: "info",
detail: latestVersion.message,
actions,
});
}
}
// Unhealthy
if (
workspace.latest_build.status === "running" &&
!workspace.health.healthy
) {
notifications.push({
title: "Workspace is unhealthy",
severity: "warning",
detail: (
<>
Your workspace is running but{" "}
{workspace.health.failing_agents.length > 1
? `${workspace.health.failing_agents.length} agents are unhealthy`
: `1 agent is unhealthy`}
.
</>
),
actions: permissions.updateWorkspace ? (
<NotificationActionButton onClick={onRestartWorkspace}>
Restart
</NotificationActionButton>
) : undefined,
});
}
// Dormant
const areActionsEnabled = useIsWorkspaceActionsEnabled();
if (areActionsEnabled && workspace.dormant_at) {
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 actions = (
<NotificationActionButton onClick={onActivateWorkspace}>
Activate
</NotificationActionButton>
);
notifications.push({
actions,
title: "Workspace is dormant",
severity: "warning",
detail: workspace.deleting_at ? (
<>
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.
</>
) : (
<>
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.
</>
),
});
}
// Pending in Queue
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]);
if (showAlertPendingInQueue) {
notifications.push({
title: "Workspace build is pending",
severity: "info",
detail: (
<>
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.
<span css={{ display: "block", marginTop: 12 }}>
Position in queue:{" "}
<strong>{workspace.latest_build.job.queue_position}</strong>
</span>
</>
),
});
}
// Deprecated
if (template.deprecated) {
notifications.push({
title: "This workspace uses a deprecated template",
severity: "warning",
detail: (
<MemoizedInlineMarkdown>
{template.deprecation_message}
</MemoizedInlineMarkdown>
),
});
}
const infoNotifications = notifications.filter((n) => n.severity === "info");
const warningNotifications = notifications.filter(
(n) => n.severity === "warning",
);
return (
<div css={styles.notificationsGroup}>
{infoNotifications.length > 0 && (
<Notifications
isDefaultOpen={defaultOpen === "info"}
items={infoNotifications}
severity="info"
icon={<InfoOutlined />}
/>
)}
{warningNotifications.length > 0 && (
<Notifications
isDefaultOpen={defaultOpen === "warning"}
items={warningNotifications}
severity="warning"
icon={<WarningRounded />}
/>
)}
</div>
);
};
const styles = {
notificationsGroup: {
display: "flex",
alignItems: "center",
gap: 12,
},
} satisfies Record<string, Interpolation<Theme>>;
+20 -20
View File
@@ -14,6 +14,7 @@ import { WorkspacePermissions, workspaceChecks } from "./permissions";
import { watchWorkspace } from "api/api";
import { Workspace } from "api/typesGenerated";
import { useEffectEvent } from "hooks/hookPolyfills";
import { Navbar } from "components/Dashboard/Navbar/Navbar";
export const WorkspacePage: FC = () => {
const queryClient = useQueryClient();
@@ -102,27 +103,26 @@ export const WorkspacePage: FC = () => {
workspaceQuery.error ?? templateQuery.error ?? permissionsQuery.error;
const isLoading = !workspace || !template || !permissions;
if (pageError) {
return (
<Margins>
<ErrorAlert
error={pageError}
css={{ marginTop: 16, marginBottom: 16 }}
/>
</Margins>
);
}
if (isLoading) {
return <Loader />;
}
return (
<WorkspaceReadyPage
workspace={workspace}
template={template}
permissions={permissions}
/>
<div css={{ height: "100%", display: "flex", flexDirection: "column" }}>
<Navbar />
{pageError ? (
<Margins>
<ErrorAlert
error={pageError}
css={{ marginTop: 16, marginBottom: 16 }}
/>
</Margins>
) : isLoading ? (
<Loader />
) : (
<WorkspaceReadyPage
workspace={workspace}
template={template}
permissions={permissions}
/>
)}
</div>
);
};
@@ -33,7 +33,6 @@ import { getErrorMessage } from "api/errors";
import { displayError } from "components/GlobalSnackbar/utils";
import { deploymentConfig, deploymentSSHConfig } from "api/queries/deployment";
import { WorkspacePermissions } from "./permissions";
import { workspaceResolveAutostart } from "api/queries/workspaceQuota";
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
import dayjs from "dayjs";
import { useMe } from "hooks";
@@ -64,7 +63,7 @@ export const WorkspaceReadyPage = ({
// Debug mode
const { data: deploymentValues } = useQuery({
...deploymentConfig(),
enabled: permissions?.viewDeploymentValues,
enabled: permissions.viewDeploymentValues,
});
// Build logs
@@ -80,19 +79,10 @@ export const WorkspaceReadyPage = ({
open: boolean;
buildParameters?: TypesGen.WorkspaceBuildParameter[];
}>({ open: false });
const {
mutate: mutateRestartWorkspace,
error: restartBuildError,
isLoading: isRestarting,
} = useMutation({
mutationFn: restartWorkspace,
});
// Auto start
const canAutostartResponse = useQuery(
workspaceResolveAutostart(workspace.id),
);
const canAutostart = !canAutostartResponse.data?.parameter_mismatch ?? false;
const { mutate: mutateRestartWorkspace, isLoading: isRestarting } =
useMutation({
mutationFn: restartWorkspace,
});
// SSH Prefix
const sshPrefixQuery = useQuery(deploymentSSHConfig());
@@ -111,7 +101,7 @@ export const WorkspaceReadyPage = ({
}, []);
// Change version
const canChangeVersions = Boolean(permissions?.updateTemplate);
const canChangeVersions = permissions.updateTemplate;
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
const changeVersionMutation = useMutation(
changeVersion(workspace, queryClient),
@@ -128,7 +118,6 @@ export const WorkspaceReadyPage = ({
});
// Update workspace
const canUpdateWorkspace = Boolean(permissions?.updateWorkspace);
const [isConfirmingUpdate, setIsConfirmingUpdate] = useState(false);
const updateWorkspaceMutation = useMutation(
updateWorkspace(workspace, queryClient),
@@ -136,7 +125,7 @@ export const WorkspaceReadyPage = ({
// If a user can update the template then they can force a delete
// (via orphan).
const canUpdateTemplate = Boolean(permissions?.updateTemplate);
const canUpdateTemplate = Boolean(permissions.updateTemplate);
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const deleteWorkspaceMutation = useMutation(
deleteWorkspace(workspace, queryClient),
@@ -193,6 +182,7 @@ export const WorkspaceReadyPage = ({
</Helmet>
<Workspace
permissions={permissions}
isUpdating={updateWorkspaceMutation.isLoading}
isRestarting={isRestarting}
workspace={workspace}
@@ -229,20 +219,10 @@ export const WorkspaceReadyPage = ({
displayError(message);
}
}}
canUpdateWorkspace={canUpdateWorkspace}
updateMessage={latestVersion?.message}
latestVersion={latestVersion}
canChangeVersions={canChangeVersions}
hideSSHButton={featureVisibility["browser_only"]}
hideVSCodeDesktopButton={featureVisibility["browser_only"]}
workspaceErrors={{
buildError:
restartBuildError ??
startWorkspaceMutation.error ??
stopWorkspaceMutation.error ??
deleteWorkspaceMutation.error ??
updateWorkspaceMutation.error,
cancellationError: cancelBuildMutation.error,
}}
buildInfo={buildInfo}
sshPrefix={sshPrefixQuery.data?.hostname_prefix}
template={template}
@@ -251,7 +231,6 @@ export const WorkspaceReadyPage = ({
<WorkspaceBuildLogsSection logs={buildLogs} />
)
}
canAutostart={canAutostart}
isOwner={isOwner}
/>
@@ -1,5 +1,10 @@
import { Meta, StoryObj } from "@storybook/react";
import { MockUser, MockWorkspace } from "testHelpers/entities";
import {
MockTemplate,
MockTemplateVersion,
MockUser,
MockWorkspace,
} from "testHelpers/entities";
import { WorkspaceTopbar } from "./WorkspaceTopbar";
import { withDashboardProvider } from "testHelpers/storybook";
import { addDays } from "date-fns";
@@ -20,6 +25,8 @@ const meta: Meta<typeof WorkspaceTopbar> = {
decorators: [withDashboardProvider],
args: {
workspace: baseWorkspace,
template: MockTemplate,
latestVersion: MockTemplateVersion,
},
parameters: {
layout: "fullscreen",
@@ -30,6 +30,8 @@ import { Popover, PopoverTrigger } from "components/Popover/Popover";
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
import { AvatarData } from "components/AvatarData/AvatarData";
import { ExternalAvatar } from "components/Avatar/Avatar";
import { WorkspaceNotifications } from "./WorkspaceNotifications/WorkspaceNotifications";
import { WorkspacePermissions } from "./permissions";
export type WorkspaceError =
| "getBuildsError"
@@ -57,6 +59,9 @@ export interface WorkspaceProps {
handleBuildRetry: () => void;
handleBuildRetryDebug: () => void;
isOwner: boolean;
template: TypesGen.Template;
permissions: WorkspacePermissions;
latestVersion?: TypesGen.TemplateVersion;
}
export const WorkspaceTopbar = (props: WorkspaceProps) => {
@@ -79,6 +84,9 @@ export const WorkspaceTopbar = (props: WorkspaceProps) => {
handleBuildRetry,
handleBuildRetryDebug,
isOwner,
template,
latestVersion,
permissions,
} = props;
const theme = useTheme();
@@ -247,6 +255,15 @@ export const WorkspaceTopbar = (props: WorkspaceProps) => {
gap: 12,
}}
>
<WorkspaceNotifications
workspace={workspace}
template={template}
latestVersion={latestVersion}
permissions={permissions}
onRestartWorkspace={handleRestart}
onUpdateWorkspace={handleUpdate}
onActivateWorkspace={handleDormantActivate}
/>
<WorkspaceStatusBadge workspace={workspace} />
<WorkspaceActions
workspace={workspace}