mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: show update messages on workspace page (#9705)
This commit is contained in:
@@ -49,3 +49,17 @@ export const templateExamples = (orgId: string) => {
|
||||
queryFn: () => API.getTemplateExamples(orgId),
|
||||
};
|
||||
};
|
||||
|
||||
export const templateVersion = (versionId: string) => {
|
||||
return {
|
||||
queryKey: ["templateVersion", versionId],
|
||||
queryFn: () => API.getTemplateVersion(versionId),
|
||||
};
|
||||
};
|
||||
|
||||
export const templateVersions = (templateId: string) => {
|
||||
return {
|
||||
queryKey: ["templateVersions", templateId],
|
||||
queryFn: () => API.getTemplateVersions(templateId),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import InfoIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { colors } from "theme/colors";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getTemplate, getTemplateVersion } from "api/api";
|
||||
import { templateVersion } from "api/queries/templates";
|
||||
import Box from "@mui/material/Box";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import Link from "@mui/material/Link";
|
||||
@@ -25,7 +25,7 @@ export const Language = {
|
||||
|
||||
interface TooltipProps {
|
||||
onUpdateVersion: () => void;
|
||||
templateId: string;
|
||||
latestVersionId: string;
|
||||
templateName: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
@@ -33,20 +33,11 @@ interface TooltipProps {
|
||||
export const WorkspaceOutdatedTooltip: FC<TooltipProps> = ({
|
||||
onUpdateVersion,
|
||||
ariaLabel,
|
||||
templateId,
|
||||
latestVersionId,
|
||||
templateName,
|
||||
}) => {
|
||||
const styles = useStyles();
|
||||
const { data: activeVersion } = useQuery({
|
||||
queryFn: async () => {
|
||||
const template = await getTemplate(templateId);
|
||||
const activeVersion = await getTemplateVersion(
|
||||
template.active_version_id,
|
||||
);
|
||||
return activeVersion;
|
||||
},
|
||||
queryKey: ["templates", templateId, "activeVersion"],
|
||||
});
|
||||
const { data: activeVersion } = useQuery(templateVersion(latestVersionId));
|
||||
|
||||
return (
|
||||
<HelpTooltip
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface WorkspaceProps {
|
||||
builds?: TypesGen.WorkspaceBuild[];
|
||||
templateWarnings?: TypesGen.TemplateVersionWarning[];
|
||||
canUpdateWorkspace: boolean;
|
||||
updateMessage?: string;
|
||||
canRetryDebugMode: boolean;
|
||||
canChangeVersions: boolean;
|
||||
hideSSHButton?: boolean;
|
||||
@@ -93,6 +94,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
resources,
|
||||
builds,
|
||||
canUpdateWorkspace,
|
||||
updateMessage,
|
||||
canRetryDebugMode,
|
||||
canChangeVersions,
|
||||
workspaceErrors,
|
||||
@@ -219,6 +221,12 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
||||
className={styles.firstColumnSpacer}
|
||||
spacing={4}
|
||||
>
|
||||
{workspace.outdated && (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>An update is available for your workspace</AlertTitle>
|
||||
{updateMessage && <AlertDetail>{updateMessage}</AlertDetail>}
|
||||
</Alert>
|
||||
)}
|
||||
{buildError}
|
||||
{cancellationError}
|
||||
{workspace.latest_build.status === "running" &&
|
||||
|
||||
@@ -24,13 +24,13 @@ export const UpdateButton: FC<WorkspaceAction> = ({
|
||||
return (
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingIndicator="Updating..."
|
||||
loadingIndicator={<>Updating…</>}
|
||||
loadingPosition="start"
|
||||
data-testid="workspace-update-button"
|
||||
startIcon={<CloudQueueIcon />}
|
||||
onClick={handleAction}
|
||||
>
|
||||
Update
|
||||
Update…
|
||||
</LoadingButton>
|
||||
);
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export const ActivateButton: FC<WorkspaceAction> = ({
|
||||
return (
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingIndicator="Activating..."
|
||||
loadingIndicator={<>Activating…</>}
|
||||
loadingPosition="start"
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
onClick={handleAction}
|
||||
@@ -70,7 +70,7 @@ export const StartButton: FC<
|
||||
>
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingIndicator="Starting..."
|
||||
loadingIndicator={<>Starting…</>}
|
||||
loadingPosition="start"
|
||||
startIcon={<PlayCircleOutlineIcon />}
|
||||
onClick={() => handleAction()}
|
||||
@@ -90,7 +90,7 @@ export const StopButton: FC<WorkspaceAction> = ({ handleAction, loading }) => {
|
||||
return (
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingIndicator="Stopping..."
|
||||
loadingIndicator={<>Stopping…</>}
|
||||
loadingPosition="start"
|
||||
startIcon={<CropSquareIcon />}
|
||||
onClick={handleAction}
|
||||
@@ -119,13 +119,13 @@ export const RestartButton: FC<
|
||||
>
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingIndicator="Restarting..."
|
||||
loadingIndicator={<>Restarting…</>}
|
||||
loadingPosition="start"
|
||||
startIcon={<ReplayIcon />}
|
||||
onClick={() => handleAction()}
|
||||
data-testid="workspace-restart-button"
|
||||
>
|
||||
Restart
|
||||
Restart…
|
||||
</LoadingButton>
|
||||
<BuildParametersPopover
|
||||
workspace={workspace}
|
||||
|
||||
@@ -148,12 +148,15 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
||||
{canChangeVersions && (
|
||||
<MenuItem onClick={onMenuItemClick(handleChangeVersion)}>
|
||||
<HistoryOutlined />
|
||||
Change version
|
||||
Change version…
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onClick={onMenuItemClick(handleDelete)}>
|
||||
<MenuItem
|
||||
onClick={onMenuItemClick(handleDelete)}
|
||||
data-testid="delete-button"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
Delete
|
||||
Delete…
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
@@ -24,13 +24,13 @@ import {
|
||||
MockEntitlementsWithScheduling,
|
||||
MockDeploymentConfig,
|
||||
} from "testHelpers/entities";
|
||||
import * as api from "../../api/api";
|
||||
import { Workspace } from "../../api/typesGenerated";
|
||||
import * as api from "api/api";
|
||||
import { Workspace } from "api/typesGenerated";
|
||||
import {
|
||||
renderWithAuth,
|
||||
waitForLoaderToBeRemoved,
|
||||
} from "../../testHelpers/renderHelpers";
|
||||
import { server } from "../../testHelpers/server";
|
||||
} from "testHelpers/renderHelpers";
|
||||
import { server } from "testHelpers/server";
|
||||
import { WorkspacePage } from "./WorkspacePage";
|
||||
|
||||
// It renders the workspace page and waits for it be loaded
|
||||
@@ -113,7 +113,7 @@ describe("WorkspacePage", () => {
|
||||
await user.click(trigger);
|
||||
|
||||
// Click on delete
|
||||
const button = await screen.findByText("Delete");
|
||||
const button = await screen.findByTestId("delete-button");
|
||||
await user.click(button);
|
||||
|
||||
// Get dialog and confirm
|
||||
@@ -172,28 +172,6 @@ describe("WorkspacePage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("requests a stop without confirmation when the user presses Restart", async () => {
|
||||
const stopWorkspaceMock = jest
|
||||
.spyOn(api, "stopWorkspace")
|
||||
.mockResolvedValueOnce(MockWorkspaceBuild);
|
||||
window.localStorage.setItem(
|
||||
`${MockUser.id}_ignoredWarnings`,
|
||||
JSON.stringify({ restart: new Date().toISOString() }),
|
||||
);
|
||||
|
||||
// Render
|
||||
await renderWorkspacePage();
|
||||
|
||||
// Actions
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("workspace-restart-button"));
|
||||
|
||||
// Assertions
|
||||
await waitFor(() => {
|
||||
expect(stopWorkspaceMock).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("requests cancellation when the user presses Cancel", async () => {
|
||||
server.use(
|
||||
rest.get(
|
||||
@@ -409,44 +387,4 @@ describe("WorkspacePage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("restart the workspace with one time parameters without the confirmation dialog", async () => {
|
||||
window.localStorage.setItem(
|
||||
`${MockUser.id}_ignoredWarnings`,
|
||||
JSON.stringify({
|
||||
restart: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
jest.spyOn(api, "getWorkspaceParameters").mockResolvedValue({
|
||||
templateVersionRichParameters: [
|
||||
{
|
||||
...MockTemplateVersionParameter1,
|
||||
ephemeral: true,
|
||||
name: "rebuild",
|
||||
description: "Rebuild",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
buildParameters: [{ name: "rebuild", value: "false" }],
|
||||
});
|
||||
const restartWorkspaceSpy = jest.spyOn(api, "restartWorkspace");
|
||||
const user = userEvent.setup();
|
||||
await renderWorkspacePage();
|
||||
await user.click(screen.getByTestId("build-parameters-button"));
|
||||
const buildParametersForm = await screen.findByTestId(
|
||||
"build-parameters-form",
|
||||
);
|
||||
const rebuildField = within(buildParametersForm).getByLabelText("Rebuild", {
|
||||
exact: false,
|
||||
});
|
||||
await user.clear(rebuildField);
|
||||
await user.type(rebuildField, "true");
|
||||
await user.click(screen.getByTestId("build-parameters-submit"));
|
||||
await waitFor(() => {
|
||||
expect(restartWorkspaceSpy).toBeCalledWith({
|
||||
workspace: MockWorkspace,
|
||||
buildParameters: [{ name: "rebuild", value: "true" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,17 +23,17 @@ import {
|
||||
import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog";
|
||||
import { ChangeVersionDialog } from "./ChangeVersionDialog";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { getTemplateVersions, restartWorkspace } from "api/api";
|
||||
import { restartWorkspace } from "api/api";
|
||||
import {
|
||||
ConfirmDialog,
|
||||
ConfirmDialogProps,
|
||||
} from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { useMe } from "hooks/useMe";
|
||||
import Checkbox from "@mui/material/Checkbox";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
import { workspaceBuildMachine } from "xServices/workspaceBuild/workspaceBuildXService";
|
||||
import * as TypesGen from "api/typesGenerated";
|
||||
import { WorkspaceBuildLogsSection } from "./WorkspaceBuildLogsSection";
|
||||
import { templateVersion, templateVersions } from "api/queries/templates";
|
||||
import { Alert } from "components/Alert/Alert";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
|
||||
interface WorkspaceReadyPageProps {
|
||||
workspaceState: StateFrom<typeof workspaceMachine>;
|
||||
@@ -54,7 +54,7 @@ export const WorkspaceReadyPage = ({
|
||||
const {
|
||||
workspace,
|
||||
template,
|
||||
templateVersion,
|
||||
templateVersion: currentVersion,
|
||||
deploymentValues,
|
||||
builds,
|
||||
getBuildsError,
|
||||
@@ -76,18 +76,21 @@ export const WorkspaceReadyPage = ({
|
||||
const favicon = getFaviconByStatus(workspace.latest_build);
|
||||
const navigate = useNavigate();
|
||||
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
|
||||
const { data: templateVersions } = useQuery({
|
||||
queryKey: ["template", "versions", workspace.template_id],
|
||||
queryFn: () => getTemplateVersions(workspace.template_id),
|
||||
enabled: changeVersionDialogOpen,
|
||||
});
|
||||
const [isConfirmingUpdate, setIsConfirmingUpdate] = useState(false);
|
||||
const [confirmingRestart, setConfirmingRestart] = useState<{
|
||||
open: boolean;
|
||||
buildParameters?: TypesGen.WorkspaceBuildParameter[];
|
||||
}>({ open: false });
|
||||
const user = useMe();
|
||||
const { isWarningIgnored, ignoreWarning } = useIgnoreWarnings(user.id);
|
||||
|
||||
const { data: allVersions } = useQuery({
|
||||
...templateVersions(workspace.template_id),
|
||||
enabled: changeVersionDialogOpen,
|
||||
});
|
||||
const { data: latestVersion } = useQuery({
|
||||
...templateVersion(workspace.template_active_version_id),
|
||||
enabled: workspace.outdated,
|
||||
});
|
||||
|
||||
const buildLogs = useBuildLogs(workspace);
|
||||
const shouldDisplayBuildLogs =
|
||||
hasJobError(workspace) ||
|
||||
@@ -105,6 +108,7 @@ export const WorkspaceReadyPage = ({
|
||||
useEffect(() => {
|
||||
bannerSend({ type: "REFRESH_WORKSPACE", workspace });
|
||||
}, [bannerSend, workspace]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -150,18 +154,10 @@ export const WorkspaceReadyPage = ({
|
||||
handleStop={() => workspaceSend({ type: "STOP" })}
|
||||
handleDelete={() => workspaceSend({ type: "ASK_DELETE" })}
|
||||
handleRestart={(buildParameters) => {
|
||||
if (isWarningIgnored("restart")) {
|
||||
mutateRestartWorkspace({ workspace, buildParameters });
|
||||
} else {
|
||||
setConfirmingRestart({ open: true, buildParameters });
|
||||
}
|
||||
setConfirmingRestart({ open: true, buildParameters });
|
||||
}}
|
||||
handleUpdate={() => {
|
||||
if (isWarningIgnored("update")) {
|
||||
workspaceSend({ type: "UPDATE" });
|
||||
} else {
|
||||
setIsConfirmingUpdate(true);
|
||||
}
|
||||
setIsConfirmingUpdate(true);
|
||||
}}
|
||||
handleCancel={() => workspaceSend({ type: "CANCEL" })}
|
||||
handleSettings={() => navigate("settings")}
|
||||
@@ -173,6 +169,7 @@ export const WorkspaceReadyPage = ({
|
||||
resources={workspace.latest_build.resources}
|
||||
builds={builds}
|
||||
canUpdateWorkspace={canUpdateWorkspace}
|
||||
updateMessage={latestVersion?.message}
|
||||
canRetryDebugMode={canRetryDebugMode}
|
||||
canChangeVersions={canUpdateTemplate}
|
||||
hideSSHButton={featureVisibility["browser_only"]}
|
||||
@@ -186,7 +183,7 @@ export const WorkspaceReadyPage = ({
|
||||
sshPrefix={sshPrefix}
|
||||
template={template}
|
||||
quotaBudget={quota?.budget}
|
||||
templateWarnings={templateVersion?.warnings}
|
||||
templateWarnings={currentVersion?.warnings}
|
||||
buildLogs={
|
||||
shouldDisplayBuildLogs && (
|
||||
<WorkspaceBuildLogsSection logs={buildLogs} />
|
||||
@@ -218,9 +215,9 @@ export const WorkspaceReadyPage = ({
|
||||
}}
|
||||
/>
|
||||
<ChangeVersionDialog
|
||||
templateVersions={templateVersions?.reverse()}
|
||||
templateVersions={allVersions?.reverse()}
|
||||
template={template}
|
||||
defaultTemplateVersion={templateVersions?.find(
|
||||
defaultTemplateVersion={allVersions?.find(
|
||||
(v) => workspace.latest_build.template_version_id === v.id,
|
||||
)}
|
||||
open={changeVersionDialogOpen}
|
||||
@@ -237,25 +234,29 @@ export const WorkspaceReadyPage = ({
|
||||
/>
|
||||
<WarningDialog
|
||||
open={isConfirmingUpdate}
|
||||
onConfirm={(shouldIgnore) => {
|
||||
if (shouldIgnore) {
|
||||
ignoreWarning("update");
|
||||
}
|
||||
onConfirm={() => {
|
||||
workspaceSend({ type: "UPDATE" });
|
||||
setIsConfirmingUpdate(false);
|
||||
}}
|
||||
onClose={() => setIsConfirmingUpdate(false)}
|
||||
title="Confirm update"
|
||||
title="Update and restart?"
|
||||
confirmText="Update"
|
||||
description="Are you sure you want to update your workspace? Updating your workspace will stop all running processes and delete non-persistent data."
|
||||
description={
|
||||
<Stack>
|
||||
<p>
|
||||
Restarting your workspace will stop all running processes and{" "}
|
||||
<strong>delete non-persistent data</strong>.
|
||||
</p>
|
||||
{latestVersion && (
|
||||
<Alert severity="info">{latestVersion.message}</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<WarningDialog
|
||||
open={confirmingRestart.open}
|
||||
onConfirm={(shouldIgnore) => {
|
||||
if (shouldIgnore) {
|
||||
ignoreWarning("restart");
|
||||
}
|
||||
onConfirm={() => {
|
||||
mutateRestartWorkspace({
|
||||
workspace,
|
||||
buildParameters: confirmingRestart.buildParameters,
|
||||
@@ -263,84 +264,26 @@ export const WorkspaceReadyPage = ({
|
||||
setConfirmingRestart({ open: false });
|
||||
}}
|
||||
onClose={() => setConfirmingRestart({ open: false })}
|
||||
title="Confirm restart"
|
||||
title="Restart your workspace?"
|
||||
confirmText="Restart"
|
||||
description="Are you sure you want to restart your workspace? Updating your workspace will stop all running processes and delete non-persistent data."
|
||||
description={
|
||||
<>
|
||||
Restarting your workspace will stop all running processes and{" "}
|
||||
<strong>delete non-persistent data</strong>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type IgnoredWarnings = Record<string, string>;
|
||||
|
||||
const useIgnoreWarnings = (prefix: string) => {
|
||||
const ignoredWarningsJSON = localStorage.getItem(`${prefix}_ignoredWarnings`);
|
||||
let ignoredWarnings: IgnoredWarnings | undefined;
|
||||
if (ignoredWarningsJSON) {
|
||||
ignoredWarnings = JSON.parse(ignoredWarningsJSON);
|
||||
}
|
||||
|
||||
const isWarningIgnored = (warningId: string) => {
|
||||
return Boolean(ignoredWarnings?.[warningId]);
|
||||
};
|
||||
|
||||
const ignoreWarning = (warningId: string) => {
|
||||
if (!ignoredWarnings) {
|
||||
ignoredWarnings = {};
|
||||
}
|
||||
ignoredWarnings[warningId] = new Date().toISOString();
|
||||
localStorage.setItem(
|
||||
`${prefix}_ignoredWarnings`,
|
||||
JSON.stringify(ignoredWarnings),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
isWarningIgnored,
|
||||
ignoreWarning,
|
||||
};
|
||||
};
|
||||
|
||||
const WarningDialog: FC<
|
||||
Pick<
|
||||
ConfirmDialogProps,
|
||||
"open" | "onClose" | "title" | "confirmText" | "description"
|
||||
> & { onConfirm: (shouldIgnore: boolean) => void }
|
||||
> = ({ open, onConfirm, onClose, title, confirmText, description }) => {
|
||||
const [shouldIgnore, setShouldIgnore] = useState(false);
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
type="info"
|
||||
hideCancel={false}
|
||||
open={open}
|
||||
onConfirm={() => {
|
||||
onConfirm(shouldIgnore);
|
||||
}}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
confirmText={confirmText}
|
||||
description={
|
||||
<>
|
||||
<div>{description}</div>
|
||||
<FormControlLabel
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
}}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
onChange={(e) => {
|
||||
setShouldIgnore(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Don't show me this message again"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
"open" | "onClose" | "title" | "confirmText" | "description" | "onConfirm"
|
||||
>
|
||||
> = (props) => {
|
||||
return <ConfirmDialog type="info" hideCancel={false} {...props} />;
|
||||
};
|
||||
|
||||
const useBuildLogs = (workspace: TypesGen.Workspace) => {
|
||||
|
||||
@@ -103,7 +103,7 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
|
||||
{workspace.outdated && (
|
||||
<WorkspaceOutdatedTooltip
|
||||
templateName={workspace.template_name}
|
||||
templateId={workspace.template_id}
|
||||
latestVersionId={workspace.template_active_version_id}
|
||||
onUpdateVersion={handleUpdate}
|
||||
ariaLabel="update version"
|
||||
/>
|
||||
|
||||
@@ -177,7 +177,9 @@ export const WorkspacesTable: FC<WorkspacesTableProps> = ({
|
||||
{workspace.outdated && (
|
||||
<WorkspaceOutdatedTooltip
|
||||
templateName={workspace.template_name}
|
||||
templateId={workspace.template_id}
|
||||
latestVersionId={
|
||||
workspace.template_active_version_id
|
||||
}
|
||||
onUpdateVersion={() => {
|
||||
onUpdateWorkspace(workspace);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user