Compare commits

...

3 Commits

Author SHA1 Message Date
Cian Johnston
b906c16b3b chore: revert breaking changes relating to WorkspaceOwnerName (#18304)
Cherry-picks following commits:

*
f974add373
reverts
d63417b542
*
d779126ee3
reverts
2ec7404197

---------

Co-authored-by: Bruno Quaresma <bruno@coder.com>
2025-06-10 14:00:45 +01:00
Stephen Kirby
3a68676b84 chore: cherry-pick bug fixes for release 2.23 (#18219)
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
Co-authored-by: Jaayden Halko <jaayden.halko@gmail.com>
Co-authored-by: Steven Masley <stevenmasley@gmail.com>
Co-authored-by: Ethan <39577870+ethanndickson@users.noreply.github.com>
2025-06-03 14:27:57 -05:00
gcp-cherry-pick-bot[bot]
d3b6863ae9 docs: add link for Coder Desktop docs on workspace page (cherry-pick #18202) (#18204)
Co-authored-by: Atif Ali <atif@coder.com>
2025-06-03 19:19:35 +05:00
32 changed files with 432 additions and 299 deletions

View File

@@ -1594,12 +1594,14 @@ func writeCoderConnectNetInfo(ctx context.Context, networkInfoDir string) error
// Converts workspace name input to owner/workspace.agent format
// Possible valid input formats:
// workspace
// workspace.agent
// owner/workspace
// owner--workspace
// owner/workspace--agent
// owner/workspace.agent
// owner--workspace--agent
// owner--workspace.agent
// agent.workspace.owner - for parity with Coder Connect
func normalizeWorkspaceInput(input string) string {
// Split on "/", "--", and "."
parts := workspaceNameRe.Split(input, -1)
@@ -1608,8 +1610,15 @@ func normalizeWorkspaceInput(input string) string {
case 1:
return input // "workspace"
case 2:
if strings.Contains(input, ".") {
return fmt.Sprintf("%s.%s", parts[0], parts[1]) // "workspace.agent"
}
return fmt.Sprintf("%s/%s", parts[0], parts[1]) // "owner/workspace"
case 3:
// If the only separator is a dot, it's the Coder Connect format
if !strings.Contains(input, "/") && !strings.Contains(input, "--") {
return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0]) // "owner/workspace.agent"
}
return fmt.Sprintf("%s/%s.%s", parts[0], parts[1], parts[2]) // "owner/workspace.agent"
default:
return input // Fallback

View File

@@ -107,12 +107,14 @@ func TestSSH(t *testing.T) {
cases := []string{
"myworkspace",
"myworkspace.dev",
"myuser/myworkspace",
"myuser--myworkspace",
"myuser/myworkspace--dev",
"myuser/myworkspace.dev",
"myuser--myworkspace--dev",
"myuser--myworkspace.dev",
"dev.myworkspace.myuser",
}
for _, tc := range cases {

View File

@@ -23,7 +23,7 @@
"workspace_id": "===========[workspace ID]===========",
"workspace_name": "test-workspace",
"workspace_owner_id": "==========[first user ID]===========",
"workspace_owner_username": "testuser",
"workspace_owner_name": "testuser",
"template_version_id": "============[version ID]============",
"template_version_name": "===========[version name]===========",
"build_number": 1,

5
coderd/apidoc/docs.go generated
View File

@@ -17002,6 +17002,7 @@ const docTemplate = `{
"format": "uuid"
},
"owner_name": {
"description": "OwnerName is the username of the owner of the workspace.",
"type": "string"
},
"template_active_version_id": {
@@ -17847,9 +17848,7 @@ const docTemplate = `{
"format": "uuid"
},
"workspace_owner_name": {
"type": "string"
},
"workspace_owner_username": {
"description": "WorkspaceOwnerName is the username of the owner of the workspace.",
"type": "string"
}
}

View File

@@ -15507,6 +15507,7 @@
"format": "uuid"
},
"owner_name": {
"description": "OwnerName is the username of the owner of the workspace.",
"type": "string"
},
"template_active_version_id": {
@@ -16297,9 +16298,7 @@
"format": "uuid"
},
"workspace_owner_name": {
"type": "string"
},
"workspace_owner_username": {
"description": "WorkspaceOwnerName is the username of the owner of the workspace.",
"type": "string"
}
}

View File

@@ -462,7 +462,7 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
if getWorkspaceErr != nil {
return ""
}
return fmt.Sprintf("/@%s/%s", workspace.OwnerUsername, workspace.Name)
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
case database.ResourceTypeWorkspaceApp:
if additionalFields.WorkspaceOwner != "" && additionalFields.WorkspaceName != "" {
@@ -472,7 +472,7 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
if getWorkspaceErr != nil {
return ""
}
return fmt.Sprintf("/@%s/%s", workspace.OwnerUsername, workspace.Name)
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
case database.ResourceTypeOauth2ProviderApp:
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", alog.AuditLog.ResourceID)

View File

@@ -860,7 +860,7 @@ func New(options *Options) *API {
next.ServeHTTP(w, r)
})
},
// httpmw.CSRF(options.DeploymentValues.HTTPCookies),
httpmw.CSRF(options.DeploymentValues.HTTPCookies),
)
// This incurs a performance hit from the middleware, but is required to make sure

View File

@@ -15,6 +15,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/coder/v2/provisioner/echo"
@@ -211,6 +212,86 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
require.Zero(t, setup.api.FileCache.Count())
})
t.Run("RebuildParameters", func(t *testing.T) {
t.Parallel()
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf")
require.NoError(t, err)
modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules"))
require.NoError(t, err)
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: dynamicParametersTerraformSource,
modulesArchive: modulesArchive,
plan: nil,
static: nil,
})
ctx := testutil.Context(t, testutil.WaitMedium)
stream := setup.stream
previews := stream.Chan()
// Should see the output of the module represented
preview := testutil.RequireReceive(ctx, t, previews)
require.Equal(t, -1, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Len(t, preview.Parameters, 1)
require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name)
require.True(t, preview.Parameters[0].Value.Valid)
require.Equal(t, "CL", preview.Parameters[0].Value.Value)
_ = stream.Close(websocket.StatusGoingAway)
wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(request *codersdk.CreateWorkspaceRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{
Name: preview.Parameters[0].Name,
Value: "GO",
},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
params, err := setup.client.WorkspaceBuildParameters(ctx, wrk.LatestBuild.ID)
require.NoError(t, err)
require.Len(t, params, 1)
require.Equal(t, "jetbrains_ide", params[0].Name)
require.Equal(t, "GO", params[0].Value)
// A helper function to assert params
doTransition := func(t *testing.T, trans codersdk.WorkspaceTransition) {
t.Helper()
fooVal := coderdtest.RandomUsername(t)
bld, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: setup.template.ActiveVersionID,
Transition: trans,
RichParameterValues: []codersdk.WorkspaceBuildParameter{
// No validation, so this should work as is.
// Overwrite the value on each transition
{Name: "foo", Value: fooVal},
},
EnableDynamicParameters: ptr.Ref(true),
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
latestParams, err := setup.client.WorkspaceBuildParameters(ctx, bld.ID)
require.NoError(t, err)
require.ElementsMatch(t, latestParams, []codersdk.WorkspaceBuildParameter{
{Name: "jetbrains_ide", Value: "GO"},
{Name: "foo", Value: fooVal},
})
}
// Restart the workspace, then delete. Asserting params on all builds.
doTransition(t, codersdk.WorkspaceTransitionStop)
doTransition(t, codersdk.WorkspaceTransitionStart)
doTransition(t, codersdk.WorkspaceTransitionDelete)
})
t.Run("BadOwner", func(t *testing.T) {
t.Parallel()
@@ -266,9 +347,10 @@ type setupDynamicParamsTestParams struct {
}
type dynamicParamsTest struct {
client *codersdk.Client
api *coderd.API
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
client *codersdk.Client
api *coderd.API
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
template codersdk.Template
}
func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest {
@@ -300,7 +382,7 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files)
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
_ = coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
ctx := testutil.Context(t, testutil.WaitShort)
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, version.ID)
@@ -321,9 +403,10 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
})
return dynamicParamsTest{
client: ownerClient,
stream: stream,
api: api,
client: ownerClient,
api: api,
stream: stream,
template: tpl,
}
}

View File

@@ -1095,8 +1095,7 @@ func (api *API) convertWorkspaceBuild(
CreatedAt: build.CreatedAt,
UpdatedAt: build.UpdatedAt,
WorkspaceOwnerID: workspace.OwnerID,
WorkspaceOwnerName: workspace.OwnerName,
WorkspaceOwnerUsername: workspace.OwnerUsername,
WorkspaceOwnerName: workspace.OwnerUsername,
WorkspaceOwnerAvatarURL: workspace.OwnerAvatarUrl,
WorkspaceID: build.WorkspaceID,
WorkspaceName: workspace.Name,

View File

@@ -78,8 +78,7 @@ func TestWorkspaceBuild(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast)
wb, err := client.WorkspaceBuild(testutil.Context(t, testutil.WaitShort), workspace.LatestBuild.ID)
require.NoError(t, err)
require.Equal(t, up.Username, wb.WorkspaceOwnerUsername)
require.Equal(t, up.Name, wb.WorkspaceOwnerName)
require.Equal(t, up.Username, wb.WorkspaceOwnerName)
require.Equal(t, up.AvatarURL, wb.WorkspaceOwnerAvatarURL)
}

View File

@@ -623,6 +623,11 @@ func (b *Builder) getParameters() (names, values []string, err error) {
return nil, nil, BuildError{http.StatusBadRequest, "Unable to build workspace with unsupported parameters", err}
}
lastBuildParameterValues := db2sdk.WorkspaceBuildParameters(lastBuildParameters)
resolver := codersdk.ParameterResolver{
Rich: lastBuildParameterValues,
}
// Dynamic parameters skip all parameter validation.
// Deleting a workspace also should skip parameter validation.
// Pass the user's input as is.
@@ -632,19 +637,34 @@ func (b *Builder) getParameters() (names, values []string, err error) {
// conditional parameter existence, the static frame of reference
// is not sufficient. So assume the user is correct, or pull in the
// dynamic param code to find the actual parameters.
latestValues := make(map[string]string, len(b.richParameterValues))
for _, latest := range b.richParameterValues {
latestValues[latest.Name] = latest.Value
}
// Merge the inputs with values from the previous build.
for _, last := range lastBuildParameterValues {
// TODO: Ideally we use the resolver here and look at parameter
// fields such as 'ephemeral'. This requires loading the terraform
// files. For now, just send the previous inputs as is.
if _, exists := latestValues[last.Name]; exists {
// latestValues take priority, so skip this previous value.
continue
}
names = append(names, last.Name)
values = append(values, last.Value)
}
for _, value := range b.richParameterValues {
names = append(names, value.Name)
values = append(values, value.Value)
}
b.parameterNames = &names
b.parameterValues = &values
return names, values, nil
}
resolver := codersdk.ParameterResolver{
Rich: db2sdk.WorkspaceBuildParameters(lastBuildParameters),
}
for _, templateVersionParameter := range templateVersionParameters {
tvp, err := db2sdk.TemplateVersionParameter(templateVersionParameter)
if err != nil {

View File

@@ -51,14 +51,14 @@ const (
// WorkspaceBuild is an at-point representation of a workspace state.
// BuildNumbers start at 1 and increase by 1 for each subsequent build
type WorkspaceBuild struct {
ID uuid.UUID `json:"id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
WorkspaceName string `json:"workspace_name"`
WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"`
WorkspaceOwnerName string `json:"workspace_owner_name,omitempty"`
WorkspaceOwnerUsername string `json:"workspace_owner_username"`
ID uuid.UUID `json:"id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
WorkspaceName string `json:"workspace_name"`
WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"`
// WorkspaceOwnerName is the username of the owner of the workspace.
WorkspaceOwnerName string `json:"workspace_owner_name"`
WorkspaceOwnerAvatarURL string `json:"workspace_owner_avatar_url,omitempty"`
TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"`
TemplateVersionName string `json:"template_version_name"`

View File

@@ -26,10 +26,11 @@ const (
// Workspace is a deployment of a template. It references a specific
// version and can be updated.
type Workspace struct {
ID uuid.UUID `json:"id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
ID uuid.UUID `json:"id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
// OwnerName is the username of the owner of the workspace.
OwnerName string `json:"owner_name"`
OwnerAvatarURL string `json:"owner_avatar_url"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
@@ -49,7 +50,6 @@ type Workspace struct {
AutostartSchedule *string `json:"autostart_schedule,omitempty"`
TTLMillis *int64 `json:"ttl_ms,omitempty"`
LastUsedAt time.Time `json:"last_used_at" format:"date-time"`
// DeletingAt 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.

View File

@@ -225,8 +225,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```
@@ -461,8 +460,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```
@@ -1176,8 +1174,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```
@@ -1485,8 +1482,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
]
```
@@ -1658,8 +1654,7 @@ Status Code **200**
| `» workspace_name` | string | false | | |
| `» workspace_owner_avatar_url` | string | false | | |
| `» workspace_owner_id` | string(uuid) | false | | |
| `» workspace_owner_name` | string | false | | |
| `» workspace_owner_username` | string | false | | |
| `» workspace_owner_name` | string | false | | Workspace owner name is the username of the owner of the workspace. |
#### Enumerated Values
@@ -1972,8 +1967,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```

View File

@@ -8409,8 +8409,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -8456,7 +8455,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `outdated` | boolean | false | | |
| `owner_avatar_url` | string | false | | |
| `owner_id` | string | false | | |
| `owner_name` | 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 | | |
@@ -9401,39 +9400,37 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|------------------------------|-------------------------------------------------------------------|----------|--------------|-------------|
| `build_number` | integer | false | | |
| `created_at` | string | false | | |
| `daily_cost` | integer | false | | |
| `deadline` | string | false | | |
| `id` | string | false | | |
| `initiator_id` | string | false | | |
| `initiator_name` | string | false | | |
| `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | |
| `matched_provisioners` | [codersdk.MatchedProvisioners](#codersdkmatchedprovisioners) | false | | |
| `max_deadline` | string | false | | |
| `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | |
| `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | |
| `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | |
| `template_version_id` | string | false | | |
| `template_version_name` | string | false | | |
| `template_version_preset_id` | string | false | | |
| `transition` | [codersdk.WorkspaceTransition](#codersdkworkspacetransition) | false | | |
| `updated_at` | string | false | | |
| `workspace_id` | string | false | | |
| `workspace_name` | string | false | | |
| `workspace_owner_avatar_url` | string | false | | |
| `workspace_owner_id` | string | false | | |
| `workspace_owner_name` | string | false | | |
| `workspace_owner_username` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|------------------------------|-------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------|
| `build_number` | integer | false | | |
| `created_at` | string | false | | |
| `daily_cost` | integer | false | | |
| `deadline` | string | false | | |
| `id` | string | false | | |
| `initiator_id` | string | false | | |
| `initiator_name` | string | false | | |
| `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | |
| `matched_provisioners` | [codersdk.MatchedProvisioners](#codersdkmatchedprovisioners) | false | | |
| `max_deadline` | string | false | | |
| `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | |
| `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | |
| `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | |
| `template_version_id` | string | false | | |
| `template_version_name` | string | false | | |
| `template_version_preset_id` | string | false | | |
| `transition` | [codersdk.WorkspaceTransition](#codersdkworkspacetransition) | false | | |
| `updated_at` | string | false | | |
| `workspace_id` | string | false | | |
| `workspace_name` | string | false | | |
| `workspace_owner_avatar_url` | string | false | | |
| `workspace_owner_id` | string | false | | |
| `workspace_owner_name` | string | false | | Workspace owner name is the username of the owner of the workspace. |
#### Enumerated Values
@@ -10112,8 +10109,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",

View File

@@ -280,8 +280,7 @@ of the template will be used.
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -565,8 +564,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -876,8 +874,7 @@ of the template will be used.
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -1147,8 +1144,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -1433,8 +1429,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -1834,8 +1829,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",

View File

@@ -1165,7 +1165,7 @@ class ApiMethods {
)
) {
const { job } = await this.getWorkspaceBuildByNumber(
build.workspace_owner_username,
build.workspace_owner_name,
build.workspace_name,
build.build_number,
);

View File

@@ -279,7 +279,7 @@ const updateWorkspaceBuild = async (
queryClient: QueryClient,
) => {
const workspaceKey = workspaceByOwnerAndNameKey(
build.workspace_owner_username,
build.workspace_owner_name,
build.workspace_name,
);
const previousData = queryClient.getQueryData<Workspace>(workspaceKey);

View File

@@ -3622,8 +3622,7 @@ export interface WorkspaceBuild {
readonly workspace_id: string;
readonly workspace_name: string;
readonly workspace_owner_id: string;
readonly workspace_owner_name?: string;
readonly workspace_owner_username: string;
readonly workspace_owner_name: string;
readonly workspace_owner_avatar_url?: string;
readonly template_version_id: string;
readonly template_version_name: string;

View File

@@ -12,27 +12,30 @@ const meta: Meta<typeof FeatureStageBadge> = {
export default meta;
type Story = StoryObj<typeof FeatureStageBadge>;
export const MediumBeta: Story = {
args: {
size: "md",
},
};
export const SmallBeta: Story = {
args: {
size: "sm",
contentType: "beta",
},
};
export const LargeBeta: Story = {
args: {
size: "lg",
},
};
export const MediumExperimental: Story = {
export const MediumBeta: Story = {
args: {
size: "md",
contentType: "experimental",
contentType: "beta",
},
};
export const SmallEarlyAccess: Story = {
args: {
size: "sm",
contentType: "early_access",
},
};
export const MediumEarlyAccess: Story = {
args: {
size: "md",
contentType: "early_access",
},
};

View File

@@ -1,9 +1,12 @@
import type { Interpolation, Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import { visuallyHidden } from "@mui/utils";
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
import { Popover, PopoverTrigger } from "components/deprecated/Popover/Popover";
import { Link } from "components/Link/Link";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { FC, HTMLAttributes, ReactNode } from "react";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
/**
@@ -11,132 +14,73 @@ import { docs } from "utils/docs";
* ensure that we can't accidentally make typos when writing the badge text.
*/
export const featureStageBadgeTypes = {
early_access: "early access",
beta: "beta",
experimental: "experimental",
} as const satisfies Record<string, ReactNode>;
type FeatureStageBadgeProps = Readonly<
Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
contentType: keyof typeof featureStageBadgeTypes;
labelText?: string;
size?: "sm" | "md" | "lg";
showTooltip?: boolean;
size?: "sm" | "md";
}
>;
const badgeColorClasses = {
early_access: "bg-surface-orange text-content-warning",
beta: "bg-surface-sky text-highlight-sky",
} as const;
const badgeSizeClasses = {
sm: "text-xs font-medium px-2 py-1",
md: "text-base px-2 py-1",
} as const;
export const FeatureStageBadge: FC<FeatureStageBadgeProps> = ({
contentType,
labelText = "",
size = "md",
showTooltip = true, // This is a temporary until the deprecated popover is removed
className,
...delegatedProps
}) => {
const colorClasses = badgeColorClasses[contentType];
const sizeClasses = badgeSizeClasses[size];
return (
<Popover mode="hover">
<PopoverTrigger>
{({ isOpen }) => (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span
css={[
styles.badge,
size === "sm" && styles.badgeSmallText,
size === "lg" && styles.badgeLargeText,
isOpen && styles.badgeHover,
]}
className={cn(
"block max-w-fit cursor-default flex-shrink-0 leading-none whitespace-nowrap border rounded-md transition-colors duration-200 ease-in-out bg-transparent border-solid border-transparent",
sizeClasses,
colorClasses,
className,
)}
{...delegatedProps}
>
<span style={visuallyHidden}> (This is a</span>
<span className="sr-only"> (This is a</span>
<span className="first-letter:uppercase">
{labelText && `${labelText} `}
{featureStageBadgeTypes[contentType]}
</span>
<span style={visuallyHidden}> feature)</span>
<span className="sr-only"> feature)</span>
</span>
)}
</PopoverTrigger>
{showTooltip && (
<HelpTooltipContent
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
>
<p css={styles.tooltipDescription}>
</TooltipTrigger>
<TooltipContent align="start" className="max-w-xs text-sm">
<p className="m-0">
This feature has not yet reached general availability (GA).
</p>
<Link
href={docs("/install/releases/feature-stages")}
target="_blank"
rel="noreferrer"
css={styles.tooltipLink}
className="font-semibold"
>
Learn about feature stages
<span style={visuallyHidden}> (link opens in new tab)</span>
<span className="sr-only"> (link opens in new tab)</span>
</Link>
</HelpTooltipContent>
)}
</Popover>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const styles = {
badge: (theme) => ({
// Base type is based on a span so that the element can be placed inside
// more types of HTML elements without creating invalid markdown, but we
// still want the default display behavior to be div-like
display: "block",
maxWidth: "fit-content",
// Base style assumes that medium badges will be the default
fontSize: "0.75rem",
cursor: "default",
flexShrink: 0,
padding: "4px 8px",
lineHeight: 1,
whiteSpace: "nowrap",
border: `1px solid ${theme.branding.featureStage.border}`,
color: theme.branding.featureStage.text,
backgroundColor: theme.branding.featureStage.background,
borderRadius: "6px",
transition:
"color 0.2s ease-in-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out",
}),
badgeHover: (theme) => ({
color: theme.branding.featureStage.hover.text,
borderColor: theme.branding.featureStage.hover.border,
backgroundColor: theme.branding.featureStage.hover.background,
}),
badgeLargeText: {
fontSize: "1rem",
},
badgeSmallText: {
// Have to beef up font weight so that the letters still maintain the
// same relative thickness as all our other main UI text
fontWeight: 500,
fontSize: "0.625rem",
},
tooltipTitle: (theme) => ({
color: theme.palette.text.primary,
fontWeight: 600,
fontFamily: "inherit",
fontSize: 18,
margin: 0,
lineHeight: 1,
paddingBottom: "8px",
}),
tooltipDescription: {
margin: 0,
lineHeight: 1.4,
paddingBottom: "8px",
},
tooltipLink: {
fontWeight: 600,
lineHeight: 1.2,
},
} as const satisfies Record<string, Interpolation<Theme>>;

View File

@@ -562,11 +562,11 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
const classNames = {
paper: (css, theme) => css`
padding: 0;
width: 404px;
color: ${theme.palette.text.secondary};
margin-top: 4px;
`,
padding: 0;
width: 404px;
color: ${theme.palette.text.secondary};
margin-top: 4px;
`,
} satisfies Record<string, ClassName>;
const styles = {

View File

@@ -73,6 +73,9 @@ export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
>
Connect via JetBrains Gateway
</HelpTooltipLink>
<HelpTooltipLink href={docs("/user-guides/desktop")}>
Connect via Coder Desktop
</HelpTooltipLink>
<HelpTooltipLink href={docs("/user-guides/workspace-access#ssh")}>
SSH configuration
</HelpTooltipLink>

View File

@@ -84,6 +84,7 @@ export const DynamicParameter: FC<DynamicParameterProps> = ({
value={value}
onChange={onChange}
disabled={disabled}
isPreset={isPreset}
/>
) : (
<ParameterField
@@ -231,6 +232,7 @@ interface DebouncedParameterFieldProps {
onChange: (value: string) => void;
disabled?: boolean;
id: string;
isPreset?: boolean;
}
const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
@@ -239,6 +241,7 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
onChange,
disabled,
id,
isPreset,
}) => {
const [localValue, setLocalValue] = useState(
value !== undefined ? value : validValue(parameter.value),
@@ -251,19 +254,26 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
// This is necessary in the case of fields being set by preset parameters
useEffect(() => {
if (value !== undefined && value !== prevValueRef.current) {
if (isPreset && value !== undefined && value !== prevValueRef.current) {
setLocalValue(value);
prevValueRef.current = value;
}
}, [value]);
}, [value, isPreset]);
useEffect(() => {
if (prevDebouncedValueRef.current !== undefined) {
// Only call onChangeEvent if debouncedLocalValue is different from the previously committed value
// and it's not the initial undefined state.
if (
prevDebouncedValueRef.current !== undefined &&
prevDebouncedValueRef.current !== debouncedLocalValue
) {
onChangeEvent(debouncedLocalValue);
}
// Update the ref to the current debounced value for the next comparison
prevDebouncedValueRef.current = debouncedLocalValue;
}, [debouncedLocalValue, onChangeEvent]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeTextarea = useEffectEvent(() => {
@@ -513,7 +523,9 @@ const ParameterField: FC<ParameterFieldProps> = ({
max={parameter.validations[0]?.validation_max ?? 100}
disabled={disabled}
/>
<span className="w-4 font-medium">{parameter.value.value}</span>
<span className="w-4 font-medium">
{Number.isFinite(Number(value)) ? value : "0"}
</span>
</div>
);
case "error":

View File

@@ -3,12 +3,12 @@ import type { FriendlyDiagnostic, PreviewParameter } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import { Pill } from "components/Pill/Pill";
import {
Select,
SelectContent,
@@ -353,21 +353,39 @@ export const CreateWorkspacePageViewExperimental: FC<
</div>
<div className="flex flex-col gap-6 max-w-screen-md mx-auto">
<header className="flex flex-col items-start gap-3 mt-10">
<div className="flex items-center gap-2">
<Avatar
variant="icon"
size="md"
src={template.icon}
fallback={template.name}
/>
<p className="text-base font-medium m-0">
{template.display_name.length > 0
? template.display_name
: template.name}
</p>
<div className="flex items-center gap-2 justify-between w-full">
<span className="flex items-center gap-2">
<Avatar
variant="icon"
size="md"
src={template.icon}
fallback={template.name}
/>
<p className="text-base font-medium m-0">
{template.display_name.length > 0
? template.display_name
: template.name}
</p>
{template.deprecated && (
<Badge variant="warning" size="sm">
Deprecated
</Badge>
)}
</span>
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
<Undo2 />
Classic workspace creation
</Button>
)}
</div>
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl font-semibold m-0">New workspace</h1>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
@@ -389,19 +407,11 @@ export const CreateWorkspacePageViewExperimental: FC<
</Tooltip>
</TooltipProvider>
</span>
{template.deprecated && <Pill type="warning">Deprecated</Pill>}
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
<Undo2 />
Use the classic workspace creation flow
</Button>
)}
<FeatureStageBadge
contentType={"early_access"}
size="sm"
labelText="Dynamic parameters"
/>
</header>
<form
@@ -555,7 +565,7 @@ export const CreateWorkspacePageViewExperimental: FC<
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Label className="text-sm">Preset</Label>
<FeatureStageBadge contentType={"beta"} size="md" />
<FeatureStageBadge contentType={"beta"} size="sm" />
</div>
<div className="flex flex-col gap-4">
<div className="max-w-lg">

View File

@@ -53,7 +53,7 @@ export const Section: FC<SectionProps> = ({
{featureStage && (
<FeatureStageBadge
contentType={featureStage}
size="lg"
size="md"
css={{ marginBottom: "5px" }}
/>
)}

View File

@@ -205,7 +205,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
fontWeight: 600,
}}
>
{`coder rm ${`${build.workspace_owner_username}/${build.workspace_name}`} --orphan`}
{`coder rm ${`${build.workspace_owner_name}/${build.workspace_name}`} --orphan`}
</code>{" "}
to delete the workspace skipping resource destruction.
</div>

View File

@@ -117,18 +117,18 @@ export const WorkspaceParametersPageView: FC<
return (
<div className="flex flex-col gap-10">
<header className="flex flex-col items-start gap-2">
<span className="flex flex-row justify-between items-center gap-2">
<span className="flex flex-row justify-between w-full items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
{experimentalFormContext && (
<ShadcnButton
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
Try out the new workspace parameters
</ShadcnButton>
)}
</span>
{experimentalFormContext && (
<ShadcnButton
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
Try out the new workspace parameters
</ShadcnButton>
)}
</header>
{submitError && !isApiValidationError(submitError) ? (

View File

@@ -9,6 +9,7 @@ import type {
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { EmptyState } from "components/EmptyState/EmptyState";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import { Link } from "components/Link/Link";
import { Loader } from "components/Loader/Loader";
import {
@@ -26,6 +27,7 @@ import { useMutation, useQuery } from "react-query";
import { useNavigate } from "react-router-dom";
import { docs } from "utils/docs";
import { pageTitle } from "utils/page";
import type { AutofillBuildParameter } from "utils/richParameters";
import {
type WorkspacePermissions,
workspaceChecks,
@@ -39,11 +41,27 @@ const WorkspaceParametersPageExperimental: FC = () => {
const navigate = useNavigate();
const experimentalFormContext = useContext(ExperimentalFormContext);
// autofill the form with the workspace build parameters from the latest build
const {
data: latestBuildParameters,
isLoading: latestBuildParametersLoading,
} = useQuery({
queryKey: ["workspaceBuilds", workspace.latest_build.id, "parameters"],
queryFn: () => API.getWorkspaceBuildParameters(workspace.latest_build.id),
});
const [latestResponse, setLatestResponse] =
useState<DynamicParametersResponse | null>(null);
const wsResponseId = useRef<number>(-1);
const ws = useRef<WebSocket | null>(null);
const [wsError, setWsError] = useState<Error | null>(null);
const initialParamsSentRef = useRef(false);
const autofillParameters: AutofillBuildParameter[] =
latestBuildParameters?.map((p) => ({
...p,
source: "active_build",
})) ?? [];
const sendMessage = useEffectEvent((formValues: Record<string, string>) => {
const request: DynamicParametersRequest = {
@@ -57,11 +75,34 @@ const WorkspaceParametersPageExperimental: FC = () => {
}
});
// On page load, sends initial workspace build parameters to the websocket.
// This ensures the backend has the form's complete initial state,
// vital for rendering dynamic UI elements dependent on initial parameter values.
const sendInitialParameters = useEffectEvent(() => {
if (initialParamsSentRef.current) return;
if (autofillParameters.length === 0) return;
const initialParamsToSend: Record<string, string> = {};
for (const param of autofillParameters) {
if (param.name && param.value) {
initialParamsToSend[param.name] = param.value;
}
}
if (Object.keys(initialParamsToSend).length === 0) return;
sendMessage(initialParamsToSend);
initialParamsSentRef.current = true;
});
const onMessage = useEffectEvent((response: DynamicParametersResponse) => {
if (latestResponse && latestResponse?.id >= response.id) {
return;
}
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
sendInitialParameters();
}
setLatestResponse(response);
});
@@ -149,6 +190,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
const error = wsError || updateParameters.error;
if (
latestBuildParametersLoading ||
!latestResponse ||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
) {
@@ -162,39 +204,46 @@ const WorkspaceParametersPageExperimental: FC = () => {
</Helmet>
<header className="flex flex-col items-start gap-2">
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp className="size-icon-xs text-content-secondary" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-sm">
Dynamic Parameters enhances Coder's existing parameter system
with real-time validation, conditional parameter behavior, and
richer input types.
<br />
<Link
href={docs(
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
)}
>
View docs
</Link>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="flex flex-row items-center gap-2 justify-between w-full">
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp className="size-icon-xs text-content-secondary" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-sm">
Dynamic Parameters enhances Coder's existing parameter system
with real-time validation, conditional parameter behavior, and
richer input types.
<br />
<Link
href={docs(
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
)}
>
View docs
</Link>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
<Undo2 />
Classic workspace parameters
</Button>
)}
</span>
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
<Undo2 />
Use the classic workspace parameters
</Button>
)}
<FeatureStageBadge
contentType={"early_access"}
size="sm"
labelText="Dynamic parameters"
/>
</header>
{Boolean(error) && <ErrorAlert error={error} />}
@@ -202,6 +251,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
{sortedParams.length > 0 ? (
<WorkspaceParametersPageViewExperimental
workspace={workspace}
autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={latestResponse.diagnostics}

View File

@@ -16,9 +16,11 @@ import {
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import type { FC } from "react";
import { docs } from "utils/docs";
import type { AutofillBuildParameter } from "utils/richParameters";
type WorkspaceParametersPageViewExperimentalProps = {
workspace: Workspace;
autofillParameters: AutofillBuildParameter[];
parameters: PreviewParameter[];
diagnostics: PreviewParameter["diagnostics"];
canChangeVersions: boolean;
@@ -34,6 +36,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
WorkspaceParametersPageViewExperimentalProps
> = ({
workspace,
autofillParameters,
parameters,
diagnostics,
canChangeVersions,
@@ -42,17 +45,32 @@ export const WorkspaceParametersPageViewExperimental: FC<
sendMessage,
onCancel,
}) => {
const autofillByName = Object.fromEntries(
autofillParameters.map((param) => [param.name, param]),
);
const initialTouched = parameters.reduce(
(touched, parameter) => {
if (autofillByName[parameter.name] !== undefined) {
touched[parameter.name] = true;
}
return touched;
},
{} as Record<string, boolean>,
);
const form = useFormik({
onSubmit,
initialValues: {
rich_parameter_values: getInitialParameterValues(parameters),
rich_parameter_values: getInitialParameterValues(
parameters,
autofillParameters,
),
},
initialTouched,
validationSchema: useValidationSchemaForDynamicParameters(parameters),
enableReinitialize: false,
validateOnChange: true,
validateOnBlur: true,
});
// Group parameters by ephemeral status
const ephemeralParameters = parameters.filter((p) => p.ephemeral);
const standardParameters = parameters.filter((p) => !p.ephemeral);

View File

@@ -16,7 +16,7 @@ import {
type Update,
} from "./BatchUpdateConfirmation";
const workspaces = [
const workspaces: Workspace[] = [
{ ...MockRunningOutdatedWorkspace, id: "1" },
{ ...MockDormantOutdatedWorkspace, id: "2" },
{ ...MockOutdatedWorkspace, id: "3" },

View File

@@ -1289,7 +1289,7 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
workspace_owner_id: MockUserOwner.id,
workspace_owner_username: MockUserOwner.username,
workspace_owner_name: MockUserOwner.username,
workspace_owner_avatar_url: MockUserOwner.avatar_url,
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
deadline: "2022-05-17T23:39:00.00Z",
@@ -1317,7 +1317,7 @@ const MockWorkspaceBuildAutostart: TypesGen.WorkspaceBuild = {
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
workspace_owner_id: MockUserOwner.id,
workspace_owner_username: MockUserOwner.username,
workspace_owner_name: MockUserOwner.username,
workspace_owner_avatar_url: MockUserOwner.avatar_url,
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
deadline: "2022-05-17T23:39:00.00Z",
@@ -1341,7 +1341,7 @@ const MockWorkspaceBuildAutostop: TypesGen.WorkspaceBuild = {
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
workspace_owner_id: MockUserOwner.id,
workspace_owner_username: MockUserOwner.username,
workspace_owner_name: MockUserOwner.username,
workspace_owner_avatar_url: MockUserOwner.avatar_url,
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
deadline: "2022-05-17T23:39:00.00Z",
@@ -1367,7 +1367,7 @@ export const MockFailedWorkspaceBuild = (
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
workspace_owner_id: MockUserOwner.id,
workspace_owner_username: MockUserOwner.username,
workspace_owner_name: MockUserOwner.username,
workspace_owner_avatar_url: MockUserOwner.avatar_url,
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
deadline: "2022-05-17T23:39:00.00Z",