diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index e97894c4afb21..51c2887cd1e4a 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -86,6 +86,7 @@ "automatic_updates": "never", "allow_renames": false, "favorite": false, - "next_start_at": "====[timestamp]=====" + "next_start_at": "====[timestamp]=====", + "is_prebuild": false } ] diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9ca9c6ed64350..7a3bd8a0d913a 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e47798c1629fd..ded07f40f1163 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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" diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 05eae8f5145e6..32b412946907e 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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 } diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index c776f2cf5a473..871a9d5b3fd31 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -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 { diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index d0bbc2c079daa..053a738413060 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -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", diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index a43a5f2c8fe18..debcb421e02e3 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -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", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0b6148f796f6b..47a2984d374a2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx new file mode 100644 index 0000000000000..e576e479d27c7 --- /dev/null +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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", + }, + , + ), + }); +} + +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, + }, + ]; +} diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 597b20173fafa..4c8526a4cda6b 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -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 = () => { )} - {template && ( - { - 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); - - if ( - data.autostopChanged && - getAutostop(workspace).autostopEnabled - ) { - setIsConfirmingApply(true); - } - }} - /> - )} + {template && + (workspace.is_prebuild ? ( + + Prebuilt workspaces ignore workspace-level scheduling until they are + claimed. For prebuilt workspace specific scheduling refer to the{" "} + + Prebuilt Workspaces Scheduling + + documentation page. + + ) : ( + { + 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); + + if ( + data.autostopChanged && + getAutostop(workspace).autostopEnabled + ) { + setIsConfirmingApply(true); + } + }} + /> + ))}