Skip to content

feat: add extra workspace actions in the workspaces table #17775

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 13, 2025
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,10 +482,10 @@ class ApiMethods {
return response.data;
};

checkAuthorization = async (
checkAuthorization = async <TResponse extends TypesGen.AuthorizationResponse>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned about why we have a generic for a function that checks anything auth-based. Does the server really give back multiple return types based on what parameters we give it?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'm a little concerned that with how the function is set up right now, we force anyone calling this function to pass in the correct type parameter. To some degree that's okay, since we have such a specific type constraint on here, I have to wonder if this is asking for trouble, not just as far as complexity, but also in terms of some other employee passing the wrong type in a few months from now

Two things come to mind:

  • I'm wondering if there's a way to redefine params so that we can infer the return type automatically. With less need to pass in types manually, there's less room for things to go wrong
  • I'm also wondering if it's worth updating the generic definition to TResponse extends TypesGen.AuthorizationResponse = TypesGen.AuthorizationResponse. That way, most users get back the base AuthorizationResponse type by default (which I'd imagine would be okay 99% of the time, since we haven't needed this constraint until now), and then if someone needs something more specific, they have the ability to pass in the type, and override the default return value

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is not possible right now since the response is dynamic and depends on the request parameters. However, I do agree it would be better to have something more predictable to avoid these kinda of errors.

params: TypesGen.AuthorizationRequest,
): Promise<TypesGen.AuthorizationResponse> => {
const response = await this.axios.post<TypesGen.AuthorizationResponse>(
) => {
const response = await this.axios.post<TResponse>(
"/api/v2/authcheck",
params,
);
Expand Down
11 changes: 8 additions & 3 deletions site/src/api/queries/authCheck.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { API } from "api/api";
import type { AuthorizationRequest } from "api/typesGenerated";
import type {
AuthorizationRequest,
AuthorizationResponse,
} from "api/typesGenerated";

const AUTHORIZATION_KEY = "authorization";

export const getAuthorizationKey = (req: AuthorizationRequest) =>
[AUTHORIZATION_KEY, req] as const;

export const checkAuthorization = (req: AuthorizationRequest) => {
export const checkAuthorization = <TResponse extends AuthorizationResponse>(
req: AuthorizationRequest,
) => {
return {
queryKey: getAuthorizationKey(req),
queryFn: () => API.checkAuthorization(req),
queryFn: () => API.checkAuthorization<TResponse>(req),
};
};
1 change: 1 addition & 0 deletions site/src/api/queries/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const deploymentConfig = () => {
return {
queryKey: deploymentConfigQueryKey,
queryFn: API.getDeploymentConfig,
staleTime: Number.POSITIVE_INFINITY,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just making sure: are we guaranteed that the config won't ever change after the initial request from the server? I know we already disable a lot of the RQ background refetch functionality, so I'm wondering if there's any harm in letting the data be marked as stale, since I'd think the data would still be refetched relatively infrequently

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeap! To change these deployment configs even coderd would need to be restarted 😄

};
};

Expand Down
7 changes: 6 additions & 1 deletion site/src/api/queries/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,14 @@ export const templateVersionByName = (
};
};

export const templateVersionsQueryKey = (templateId: string) => [
"templateVersions",
templateId,
];

export const templateVersions = (templateId: string) => {
return {
queryKey: ["templateVersions", templateId],
queryKey: templateVersionsQueryKey(templateId),
queryFn: () => API.getTemplateVersions(templateId),
};
};
Expand Down
16 changes: 16 additions & 0 deletions site/src/api/queries/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,17 @@ import type {
WorkspacesResponse,
} from "api/typesGenerated";
import type { Dayjs } from "dayjs";
import {
type WorkspacePermissions,
workspaceChecks,
} from "modules/workspaces/permissions";
import type { ConnectionStatus } from "pages/TerminalPage/types";
import type {
QueryClient,
QueryOptions,
UseMutationOptions,
} from "react-query";
import { checkAuthorization } from "./authCheck";
import { disabledRefetchOptions } from "./util";
import { workspaceBuildsKey } from "./workspaceBuilds";

Expand Down Expand Up @@ -390,3 +395,14 @@ export const workspaceUsage = (options: WorkspaceUsageOptions) => {
refetchIntervalInBackground: true,
};
};

export const workspacePermissions = (workspace?: Workspace) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we add an explicit return type here? There's enough going on that I think it'd help communicate what this is supposed to be

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see too much value on that since it is dynamic and other parts of the application would throw an error. But, if it is a must for you, I can do it for sure.

return {
...checkAuthorization<WorkspacePermissions>({
checks: workspace ? workspaceChecks(workspace) : {},
}),
queryKey: ["workspaces", workspace?.id, "permissions"],
enabled: !!workspace,
staleTime: Number.POSITIVE_INFINITY,
};
};
2 changes: 1 addition & 1 deletion site/src/components/DropdownMenu/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export const DropdownMenuItem = forwardRef<
[
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-2 text-sm text-content-secondary font-medium outline-none transition-colors",
"focus:bg-surface-secondary focus:text-content-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&>svg]:size-4 [&>svg]:shrink-0",
"[&>svg]:size-4 [&>svg]:shrink-0 no-underline",
inset && "pl-8",
],
className,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from "@storybook/react";
import { templateVersionsQueryKey } from "api/queries/templates";
import {
MockTemplateVersion,
MockTemplateVersionWithMarkdownMessage,
MockWorkspace,
} from "testHelpers/entities";
import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog";

const noMessage = {
...MockTemplateVersion,
id: "no-message",
message: "",
};

const meta: Meta<typeof ChangeWorkspaceVersionDialog> = {
title: "modules/workspaces/ChangeWorkspaceVersionDialog",
component: ChangeWorkspaceVersionDialog,
args: {
open: true,
workspace: MockWorkspace,
},
parameters: {
queries: [
{
key: templateVersionsQueryKey(MockWorkspace.template_id),
data: [
MockTemplateVersion,
MockTemplateVersionWithMarkdownMessage,
noMessage,
],
},
],
},
};

export default meta;
type Story = StoryObj<typeof ChangeWorkspaceVersionDialog>;

export const NoVersionSelected: Story = {};

export const NoMessage: Story = {
args: {
workspace: {
...MockWorkspace,
template_active_version_id: noMessage.id,
},
},
};

export const ShortMessage: Story = {
args: {
workspace: {
...MockWorkspace,
template_active_version_id: MockTemplateVersion.id,
},
},
};

export const LongMessage: Story = {
args: {
workspace: {
...MockWorkspace,
template_active_version_id: MockTemplateVersionWithMarkdownMessage.id,
},
},
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we rename these stories? When I'm looking at them in the list of stories, it's not clear why they're different, especially when the only input that changed is a template ID

Maybe something like PlaintextMessage and MarkdownMessage?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, just be aware it is not in the scope of this PR. It was just a renaming.

Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import AlertTitle from "@mui/material/AlertTitle";
import Autocomplete from "@mui/material/Autocomplete";
import CircularProgress from "@mui/material/CircularProgress";
import TextField from "@mui/material/TextField";
import type { Template, TemplateVersion } from "api/typesGenerated";
import { templateVersions } from "api/queries/templates";
import type { TemplateVersion, Workspace } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData";
Expand All @@ -15,41 +16,43 @@ import { Pill } from "components/Pill/Pill";
import { Stack } from "components/Stack/Stack";
import { InfoIcon } from "lucide-react";
import { TemplateUpdateMessage } from "modules/templates/TemplateUpdateMessage";
import { type FC, useRef, useState } from "react";
import { type FC, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { createDayString } from "utils/createDayString";

export type ChangeVersionDialogProps = DialogProps & {
template: Template | undefined;
templateVersions: TemplateVersion[] | undefined;
defaultTemplateVersion: TemplateVersion | undefined;
export type ChangeWorkspaceVersionDialogProps = DialogProps & {
workspace: Workspace;
onClose: () => void;
onConfirm: (templateVersion: TemplateVersion) => void;
onConfirm: (version: TemplateVersion) => void;
};

export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
onConfirm,
onClose,
template,
templateVersions,
defaultTemplateVersion,
...dialogProps
}) => {
export const ChangeWorkspaceVersionDialog: FC<
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's worth blocking on this, but I think it's worth thinking about for the future: do we want to limit the number of versions we show by default? Right now, we're loading everything, which makes the UI chug for a second, and I think that realistically, most users aren't going to be switching to anything that's more than 10 versions older than the most recent one

We have a lot of versions from the past two years, and I could see some customers having more than us, just because their companies are so much bigger

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally agree with you, but I have to say it is not on the scope for this PR. Feel free to open an issue tho

ChangeWorkspaceVersionDialogProps
> = ({ workspace, onClose, onConfirm, ...dialogProps }) => {
const { data: versions } = useQuery({
...templateVersions(workspace.template_id),
select: (data) => data.reverse(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug: we don't want the mutate any data that we select. Normally I'd recommend that we use data.toReversed, but since we just had a customer complain about their browsers not being recent enough to support it, we should swap to [...data].reverse()

});
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false);
const selectedTemplateVersion = useRef<TemplateVersion | undefined>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, yeah, good call on getting rid of the ref, since reading from that isn't render-safe

defaultTemplateVersion,
const activeVersion = versions?.find(
(v) => workspace.template_active_version_id === v.id,
);
const version = selectedTemplateVersion.current;
const validTemplateVersions = templateVersions?.filter((version) => {
return version.job.status === "succeeded";
});
const [selectedVersion, setSelectedVersion] = useState<TemplateVersion>();
const validVersions = versions?.filter((v) => v.job.status === "succeeded");

useEffect(() => {
if (activeVersion) {
setSelectedVersion(activeVersion);
}
}, [activeVersion]);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a user input race condition. What happens if:

  1. The user selects a version, and is ready to hit the Confirm button
  2. The list of versions gets invalidated, either in this component, or in another component that's using the same pocket in the query cache
  3. The value of activeVersion changes, and updates the value of activeVersion. The user is now selecting a version that they didn't explicitly select
  4. If the user hits the Change button without realizing that their selected version changed, they'll switch to a version that they didn't want

I feel like it'd actually be better if we got rid of this useEffect call entirely and didn't replace it with anything

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anything, I feel like maybe we could change things so that if there's a new version, that makes us display a message somewhere. That way, their selection stays the same, but the user knows that they can go back to the dropdown and try a new option that's there

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!


return (
<ConfirmDialog
{...dialogProps}
onClose={onClose}
onConfirm={() => {
if (selectedTemplateVersion.current) {
onConfirm(selectedTemplateVersion.current);
if (selectedVersion) {
onConfirm(selectedVersion);
}
}}
hideCancel={false}
Expand All @@ -60,18 +63,17 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
description={
<Stack>
<p>You are about to change the version of this workspace.</p>
{validTemplateVersions ? (
{validVersions ? (
<>
<FormFields>
<Autocomplete
disableClearable
options={validTemplateVersions}
defaultValue={defaultTemplateVersion}
options={validVersions}
defaultValue={activeVersion}
id="template-version-autocomplete"
open={isAutocompleteOpen}
onChange={(_, newTemplateVersion) => {
selectedTemplateVersion.current =
newTemplateVersion ?? undefined;
setSelectedVersion(newTemplateVersion);
}}
onOpen={() => {
setIsAutocompleteOpen(true);
Expand Down Expand Up @@ -112,9 +114,8 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
/>
)}
</Stack>
{template?.active_version_id === option.id && (
<Pill type="success">Active</Pill>
)}
{workspace.template_active_version_id ===
option.id && <Pill type="success">Active</Pill>}
</Stack>
}
subtitle={createDayString(option.created_at)}
Expand All @@ -131,9 +132,7 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
...params.InputProps,
endAdornment: (
<>
{!templateVersions ? (
<CircularProgress size={16} />
) : null}
{!versions && <CircularProgress size={16} />}
{params.InputProps.endAdornment}
</>
),
Expand All @@ -144,16 +143,16 @@ export const ChangeVersionDialog: FC<ChangeVersionDialogProps> = ({
)}
/>
</FormFields>
{version && (
{selectedVersion && (
<>
{version.message && (
{selectedVersion.message && (
<TemplateUpdateMessage>
{version.message}
{selectedVersion.message}
</TemplateUpdateMessage>
)}
<Alert severity="info">
<AlertTitle>
Published by {version.created_by.username}
Published by {selectedVersion.created_by.username}
</AlertTitle>
</Alert>
</>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { withDesktopViewport } from "testHelpers/storybook";
import { DownloadLogsDialog } from "./DownloadLogsDialog";

const meta: Meta<typeof DownloadLogsDialog> = {
title: "pages/WorkspacePage/DownloadLogsDialog",
title: "modules/workspaces/DownloadLogsDialog",
component: DownloadLogsDialog,
args: {
open: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,9 @@ const DownloadingItem: FC<DownloadingItemProps> = ({ file, giveUpTimeMs }) => {
function humanBlobSize(size: number) {
const BLOB_SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
let i = 0;
while (size > 1024 && i < BLOB_SIZE_UNITS.length) {
size /= 1024;
let sizeIterator = size;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch

Copy link
Member

@Parkreiner Parkreiner May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not the biggest fan of changing parameters either, but in this case, this would never be a bug, since numbers are primitive values

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to say it was a Biome catch haha.

while (sizeIterator > 1024 && i < BLOB_SIZE_UNITS.length) {
sizeIterator /= 1024;
Comment on lines +224 to +226
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's nothing wrong with the changes you made here, but if we're dealing with numbers that are big enough that we're expecting to need to divide it by 1024 multiple times, do we want to update anything to involve Number.MAX_SAFE_INTEGER or the big int data type?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is only to humanize the number, so it is a fixed value of 1024

i++;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MockFailedWorkspace, MockWorkspace } from "testHelpers/entities";
import { daysAgo } from "utils/time";
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";

const meta: Meta<typeof WorkspaceDeleteDialog> = {
title: "pages/WorkspacePage/WorkspaceDeleteDialog",
title: "modules/workspaces/WorkspaceDeleteDialog",
component: WorkspaceDeleteDialog,
args: {
workspace: MockWorkspace,
canUpdateTemplate: false,
workspace: {
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
created_at: daysAgo(2),
},
},
canDeleteFailedWorkspace: false,
isOpen: true,
onCancel: () => {},
onConfirm: () => {},
workspaceBuildDateStr: "2 days ago",
},
};

Expand All @@ -30,14 +34,14 @@ export const Unhealthy: Story = {
// Should look the same as `Example`
export const AdminView: Story = {
args: {
canUpdateTemplate: true,
canDeleteFailedWorkspace: true,
},
};

// Should show the `--orphan` option
export const UnhealthyAdminView: Story = {
args: {
workspace: MockFailedWorkspace,
canUpdateTemplate: true,
canDeleteFailedWorkspace: true,
},
};
Loading