mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
refactor(site): refactor workspace notifications (#11520)
This commit is contained in:
@@ -78,6 +78,7 @@ function withQuery(Story, { parameters }) {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -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>>;
|
||||
+150
@@ -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>>;
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user