fix(site): exclude workspace schedule settings for prebuilt workspaces (#18826)

## Description

This PR updates the UI to avoid rendering workspace schedule settings
(autostop, autostart, etc.) for prebuilt workspaces. Instead, it
displays an informational message with a link to the relevant
documentation.

## Changes

* Introduce `IsPrebuild` parameter to `convertWorkspace` to indicate
whether the workspace is a prebuild.
* Prevent the Workspace Schedule settings form from rendering in the UI
for prebuilt workspaces.
* Display an info alert with a link to documentation when viewing a
prebuilt workspace.

<img width="2980" height="864" alt="Screenshot 2025-07-10 at 13 16 13"
src="https://github.com/user-attachments/assets/5f831c21-50bb-4e05-beea-dbeb930ddff8"
/>


Relates with: https://github.com/coder/coder/pull/18762

---------

Co-authored-by: BrunoQuaresma <bruno_nonato_quaresma@hotmail.com>
This commit is contained in:
Susana Ferreira
2025-07-15 14:11:04 +01:00
committed by GitHub
parent e4d3453e2b
commit dad033ee3d
11 changed files with 218 additions and 68 deletions

View File

@ -86,6 +86,7 @@
"automatic_updates": "never",
"allow_renames": false,
"favorite": false,
"next_start_at": "====[timestamp]====="
"next_start_at": "====[timestamp]=====",
"is_prebuild": false
}
]

4
coderd/apidoc/docs.go generated
View File

@ -17653,6 +17653,10 @@ const docTemplate = `{
"type": "string",
"format": "uuid"
},
"is_prebuild": {
"description": "IsPrebuild indicates whether the workspace is a prebuilt workspace.\nPrebuilt workspaces are owned by the prebuilds system user and have specific behavior,\nsuch as being managed differently from regular workspaces.\nOnce a prebuilt workspace is claimed by a user, it transitions to a regular workspace,\nand IsPrebuild returns false.",
"type": "boolean"
},
"last_used_at": {
"type": "string",
"format": "date-time"

View File

@ -16116,6 +16116,10 @@
"type": "string",
"format": "uuid"
},
"is_prebuild": {
"description": "IsPrebuild indicates whether the workspace is a prebuilt workspace.\nPrebuilt workspaces are owned by the prebuilds system user and have specific behavior,\nsuch as being managed differently from regular workspaces.\nOnce a prebuilt workspace is claimed by a user, it transitions to a regular workspace,\nand IsPrebuild returns false.",
"type": "boolean"
},
"last_used_at": {
"type": "string",
"format": "date-time"

View File

@ -2231,6 +2231,7 @@ func convertWorkspace(
if latestAppStatus.ID == uuid.Nil {
appStatus = nil
}
return codersdk.Workspace{
ID: workspace.ID,
CreatedAt: workspace.CreatedAt,
@ -2265,6 +2266,7 @@ func convertWorkspace(
AllowRenames: allowRenames,
Favorite: requesterFavorite,
NextStartAt: nextStartAt,
IsPrebuild: workspace.IsPrebuild(),
}, nil
}

View File

@ -66,6 +66,12 @@ type Workspace struct {
AllowRenames bool `json:"allow_renames"`
Favorite bool `json:"favorite"`
NextStartAt *time.Time `json:"next_start_at" format:"date-time"`
// IsPrebuild indicates whether the workspace is a prebuilt workspace.
// Prebuilt workspaces are owned by the prebuilds system user and have specific behavior,
// such as being managed differently from regular workspaces.
// Once a prebuilt workspace is claimed by a user, it transitions to a regular workspace,
// and IsPrebuild returns false.
IsPrebuild bool `json:"is_prebuild"`
}
func (w Workspace) FullName() string {

View File

@ -8667,6 +8667,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"healthy": false
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_prebuild": true,
"last_used_at": "2019-08-24T14:15:22Z",
"latest_app_status": {
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
@ -8906,38 +8907,39 @@ If the schedule is empty, the user will be updated to use the default schedule.|
### Properties
| Name | Type | Required | Restrictions | Description |
|---------------------------------------------|------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `allow_renames` | boolean | false | | |
| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | |
| `autostart_schedule` | string | false | | |
| `created_at` | string | false | | |
| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. |
| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time_til_ field on its template. |
| `favorite` | boolean | false | | |
| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. |
| `id` | string | false | | |
| `last_used_at` | string | false | | |
| `latest_app_status` | [codersdk.WorkspaceAppStatus](#codersdkworkspaceappstatus) | false | | |
| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | |
| `name` | string | false | | |
| `next_start_at` | string | false | | |
| `organization_id` | string | false | | |
| `organization_name` | string | false | | |
| `outdated` | boolean | false | | |
| `owner_avatar_url` | string | false | | |
| `owner_id` | string | false | | |
| `owner_name` | string | false | | Owner name is the username of the owner of the workspace. |
| `template_active_version_id` | string | false | | |
| `template_allow_user_cancel_workspace_jobs` | boolean | false | | |
| `template_display_name` | string | false | | |
| `template_icon` | string | false | | |
| `template_id` | string | false | | |
| `template_name` | string | false | | |
| `template_require_active_version` | boolean | false | | |
| `template_use_classic_parameter_flow` | boolean | false | | |
| `ttl_ms` | integer | false | | |
| `updated_at` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|---------------------------------------------|------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `allow_renames` | boolean | false | | |
| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | |
| `autostart_schedule` | string | false | | |
| `created_at` | string | false | | |
| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. |
| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time_til_ field on its template. |
| `favorite` | boolean | false | | |
| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. |
| `id` | string | false | | |
| `is_prebuild` | boolean | false | | Is prebuild indicates whether the workspace is a prebuilt workspace. Prebuilt workspaces are owned by the prebuilds system user and have specific behavior, such as being managed differently from regular workspaces. Once a prebuilt workspace is claimed by a user, it transitions to a regular workspace, and IsPrebuild returns false. |
| `last_used_at` | string | false | | |
| `latest_app_status` | [codersdk.WorkspaceAppStatus](#codersdkworkspaceappstatus) | false | | |
| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | |
| `name` | string | false | | |
| `next_start_at` | string | false | | |
| `organization_id` | string | false | | |
| `organization_name` | string | false | | |
| `outdated` | boolean | false | | |
| `owner_avatar_url` | string | false | | |
| `owner_id` | string | false | | |
| `owner_name` | string | false | | Owner name is the username of the owner of the workspace. |
| `template_active_version_id` | string | false | | |
| `template_allow_user_cancel_workspace_jobs` | boolean | false | | |
| `template_display_name` | string | false | | |
| `template_icon` | string | false | | |
| `template_id` | string | false | | |
| `template_name` | string | false | | |
| `template_require_active_version` | boolean | false | | |
| `template_use_classic_parameter_flow` | boolean | false | | |
| `ttl_ms` | integer | false | | |
| `updated_at` | string | false | | |
#### Enumerated Values
@ -10505,6 +10507,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"healthy": false
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_prebuild": true,
"last_used_at": "2019-08-24T14:15:22Z",
"latest_app_status": {
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",

View File

@ -67,6 +67,7 @@ of the template will be used.
"healthy": false
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_prebuild": true,
"last_used_at": "2019-08-24T14:15:22Z",
"latest_app_status": {
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
@ -353,6 +354,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
"healthy": false
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_prebuild": true,
"last_used_at": "2019-08-24T14:15:22Z",
"latest_app_status": {
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
@ -664,6 +666,7 @@ of the template will be used.
"healthy": false
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_prebuild": true,
"last_used_at": "2019-08-24T14:15:22Z",
"latest_app_status": {
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
@ -953,6 +956,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
"healthy": false
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_prebuild": true,
"last_used_at": "2019-08-24T14:15:22Z",
"latest_app_status": {
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
@ -1223,6 +1227,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
"healthy": false
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_prebuild": true,
"last_used_at": "2019-08-24T14:15:22Z",
"latest_app_status": {
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",
@ -1625,6 +1630,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \
"healthy": false
},
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"is_prebuild": true,
"last_used_at": "2019-08-24T14:15:22Z",
"latest_app_status": {
"agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978",

View File

@ -3448,6 +3448,7 @@ export interface Workspace {
readonly allow_renames: boolean;
readonly favorite: boolean;
readonly next_start_at: string | null;
readonly is_prebuild: boolean;
}
// From codersdk/workspaceagents.go

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from "@storybook/react";
import { getAuthorizationKey } from "api/queries/authCheck";
import { templateByNameKey } from "api/queries/templates";
import { workspaceByOwnerAndNameKey } from "api/queries/workspaces";
import type { Workspace } from "api/typesGenerated";
import {
reactRouterNestedAncestors,
reactRouterParameters,
} from "storybook-addon-remix-react-router";
import {
MockPrebuiltWorkspace,
MockTemplate,
MockUserOwner,
MockWorkspace,
} from "testHelpers/entities";
import { withAuthProvider, withDashboardProvider } from "testHelpers/storybook";
import { WorkspaceSettingsLayout } from "../WorkspaceSettingsLayout";
import WorkspaceSchedulePage from "./WorkspaceSchedulePage";
const meta = {
title: "pages/WorkspaceSchedulePage",
component: WorkspaceSchedulePage,
decorators: [withAuthProvider, withDashboardProvider],
parameters: {
layout: "fullscreen",
user: MockUserOwner,
},
} satisfies Meta<typeof WorkspaceSchedulePage>;
export default meta;
type Story = StoryObj<typeof WorkspaceSchedulePage>;
export const RegularWorkspace: Story = {
parameters: {
reactRouter: workspaceRouterParameters(MockWorkspace),
queries: workspaceQueries(MockWorkspace),
},
};
export const PrebuiltWorkspace: Story = {
parameters: {
reactRouter: workspaceRouterParameters(MockPrebuiltWorkspace),
queries: workspaceQueries(MockPrebuiltWorkspace),
},
};
function workspaceRouterParameters(workspace: Workspace) {
return reactRouterParameters({
location: {
pathParams: {
username: `@${workspace.owner_name}`,
workspace: workspace.name,
},
},
routing: reactRouterNestedAncestors(
{
path: "/:username/:workspace/settings/schedule",
},
<WorkspaceSettingsLayout />,
),
});
}
function workspaceQueries(workspace: Workspace) {
return [
{
key: workspaceByOwnerAndNameKey(workspace.owner_name, workspace.name),
data: workspace,
},
{
key: getAuthorizationKey({
checks: {
updateWorkspace: {
object: {
resource_type: "workspace",
resource_id: MockWorkspace.id,
owner_id: MockWorkspace.owner_id,
},
action: "update",
},
},
}),
data: { updateWorkspace: true },
},
{
key: templateByNameKey(
MockWorkspace.organization_id,
MockWorkspace.template_name,
),
data: MockTemplate,
},
];
}

View File

@ -7,6 +7,7 @@ import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { Link } from "components/Link/Link";
import { Loader } from "components/Loader/Loader";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import dayjs from "dayjs";
@ -20,6 +21,7 @@ import { type FC, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useParams } from "react-router-dom";
import { docs } from "utils/docs";
import { pageTitle } from "utils/page";
import { WorkspaceScheduleForm } from "./WorkspaceScheduleForm";
import {
@ -32,7 +34,7 @@ const permissionsToCheck = (workspace: TypesGen.Workspace) =>
updateWorkspace: {
object: {
resource_type: "workspace",
resourceId: workspace.id,
resource_id: workspace.id,
owner_id: workspace.owner_id,
},
action: "update",
@ -94,42 +96,62 @@ const WorkspaceSchedulePage: FC = () => {
</Alert>
)}
{template && (
<WorkspaceScheduleForm
template={template}
error={submitScheduleMutation.error}
initialValues={{
...getAutostart(workspace),
...getAutostop(workspace),
}}
isLoading={submitScheduleMutation.isPending}
defaultTTL={dayjs.duration(template.default_ttl_ms, "ms").asHours()}
onCancel={() => {
navigate(`/@${username}/${workspaceName}`);
}}
onSubmit={async (values) => {
const data = {
workspace,
autostart: formValuesToAutostartRequest(values),
ttl: formValuesToTTLRequest(values),
autostartChanged: scheduleChanged(
getAutostart(workspace),
values,
),
autostopChanged: scheduleChanged(getAutostop(workspace), values),
};
{template &&
(workspace.is_prebuild ? (
<Alert severity="info">
Prebuilt workspaces ignore workspace-level scheduling until they are
claimed. For prebuilt workspace specific scheduling refer to the{" "}
<Link
title="Prebuilt Workspaces Scheduling"
href={docs(
"/admin/templates/extending-templates/prebuilt-workspaces#scheduling",
)}
target="_blank"
rel="noreferrer"
>
Prebuilt Workspaces Scheduling
</Link>
documentation page.
</Alert>
) : (
<WorkspaceScheduleForm
template={template}
error={submitScheduleMutation.error}
initialValues={{
...getAutostart(workspace),
...getAutostop(workspace),
}}
isLoading={submitScheduleMutation.isPending}
defaultTTL={dayjs.duration(template.default_ttl_ms, "ms").asHours()}
onCancel={() => {
navigate(`/@${username}/${workspaceName}`);
}}
onSubmit={async (values) => {
const data = {
workspace,
autostart: formValuesToAutostartRequest(values),
ttl: formValuesToTTLRequest(values),
autostartChanged: scheduleChanged(
getAutostart(workspace),
values,
),
autostopChanged: scheduleChanged(
getAutostop(workspace),
values,
),
};
await submitScheduleMutation.mutateAsync(data);
await submitScheduleMutation.mutateAsync(data);
if (
data.autostopChanged &&
getAutostop(workspace).autostopEnabled
) {
setIsConfirmingApply(true);
}
}}
/>
)}
if (
data.autostopChanged &&
getAutostop(workspace).autostopEnabled
) {
setIsConfirmingApply(true);
}
}}
/>
))}
<ConfirmDialog
open={isConfirmingApply}

View File

@ -1411,6 +1411,14 @@ export const MockWorkspace: TypesGen.Workspace = {
deleting_at: null,
dormant_at: null,
next_start_at: null,
is_prebuild: false,
};
export const MockPrebuiltWorkspace = {
...MockWorkspace,
owner_name: "prebuilds",
name: "prebuilt-workspace",
is_prebuild: true,
};
export const MockFavoriteWorkspace: TypesGen.Workspace = {