diff --git a/agent/agent.go b/agent/agent.go index 4aaef05661184..865217dd60cfe 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -1200,7 +1200,7 @@ func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(co network := a.network a.closeMutex.Unlock() if network == nil { - keySeed, err := SSHKeySeed(manifest.OwnerName, manifest.WorkspaceName, manifest.AgentName) + keySeed, err := SSHKeySeed(manifest.OwnerUsername, manifest.WorkspaceName, manifest.AgentName) if err != nil { return xerrors.Errorf("generate SSH key seed: %w", err) } diff --git a/cli/exp_scaletest.go b/cli/exp_scaletest.go index a844a7e8c6258..9ab18e168e16a 100644 --- a/cli/exp_scaletest.go +++ b/cli/exp_scaletest.go @@ -1027,8 +1027,8 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command { BytesPerTick: bytesPerTick, Duration: strategy.timeout, TickInterval: tickInterval, - ReadMetrics: metrics.ReadMetrics(ws.OwnerName, ws.Name, agent.Name), - WriteMetrics: metrics.WriteMetrics(ws.OwnerName, ws.Name, agent.Name), + ReadMetrics: metrics.ReadMetrics(ws.OwnerUsername, ws.Name, agent.Name), + WriteMetrics: metrics.WriteMetrics(ws.OwnerUsername, ws.Name, agent.Name), SSH: ssh, Echo: ssh, App: appConfig, @@ -1420,7 +1420,7 @@ func isScaleTestUser(user codersdk.User) bool { } func isScaleTestWorkspace(workspace codersdk.Workspace) bool { - return strings.HasPrefix(workspace.OwnerName, "scaletest-") || + return strings.HasPrefix(workspace.OwnerUsername, "scaletest-") || strings.HasPrefix(workspace.Name, "scaletest-") } @@ -1592,7 +1592,7 @@ func createWorkspaceAppConfig(client *codersdk.Client, appHost, app string, work c.URL = fmt.Sprintf("%s://%s", client.URL.Scheme, strings.Replace(appHost, "*", agent.Apps[i].SubdomainName, 1)) } else { - c.URL = fmt.Sprintf("%s/@%s/%s.%s/apps/%s", client.URL.String(), workspace.OwnerName, workspace.Name, agent.Name, agent.Apps[i].Slug) + c.URL = fmt.Sprintf("%s/@%s/%s.%s/apps/%s", client.URL.String(), workspace.OwnerUsername, workspace.Name, agent.Name, agent.Apps[i].Slug) } return c, nil diff --git a/cli/list.go b/cli/list.go index 083d32c6e8fa1..7eb90b4b6b547 100644 --- a/cli/list.go +++ b/cli/list.go @@ -54,7 +54,7 @@ func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) if workspace.Favorite { favIco = "★" } - workspaceName := favIco + " " + workspace.OwnerName + "/" + workspace.Name + workspaceName := favIco + " " + workspace.OwnerUsername + "/" + workspace.Name return workspaceListRow{ Favorite: workspace.Favorite, Workspace: workspace, diff --git a/cli/open.go b/cli/open.go index ff950b552a853..1d47241564fa9 100644 --- a/cli/open.go +++ b/cli/open.go @@ -403,7 +403,7 @@ func buildVSCodeWorkspaceLink( ) (*url.URL, url.Values) { qp := url.Values{} qp.Add("url", clientURL) - qp.Add("owner", workspace.OwnerName) + qp.Add("owner", workspace.OwnerUsername) qp.Add("workspace", workspace.Name) qp.Add("agent", workspaceAgent.Name) @@ -435,7 +435,7 @@ func buildVSCodeWorkspaceDevContainerLink( qp := url.Values{} qp.Add("url", clientURL) - qp.Add("owner", workspace.OwnerName) + qp.Add("owner", workspace.OwnerUsername) qp.Add("workspace", workspace.Name) qp.Add("agent", workspaceAgent.Name) qp.Add("devContainerName", containerName) @@ -596,7 +596,7 @@ func buildAppLinkURL(baseURL *url.URL, workspace codersdk.Workspace, agent coder u.Path = fmt.Sprintf( "%s/@%s/%s.%s/apps/%s/", preferredPathBase, - workspace.OwnerName, + workspace.OwnerUsername, workspace.Name, agent.Name, url.PathEscape(app.Slug), @@ -606,7 +606,7 @@ func buildAppLinkURL(baseURL *url.URL, workspace codersdk.Workspace, agent coder u.Path = fmt.Sprintf( "%s/@%s/%s.%s/terminal", preferredPathBase, - workspace.OwnerName, + workspace.OwnerUsername, workspace.Name, agent.Name, ) diff --git a/cli/open_internal_test.go b/cli/open_internal_test.go index 7af4359a56bc2..7cee89ee78727 100644 --- a/cli/open_internal_test.go +++ b/cli/open_internal_test.go @@ -91,8 +91,8 @@ func Test_buildAppLinkURL(t *testing.T) { name: "without subdomain", baseURL: "https://coder.tld", workspace: codersdk.Workspace{ - Name: "Test-Workspace", - OwnerName: "username", + Name: "Test-Workspace", + OwnerUsername: "username", }, agent: codersdk.WorkspaceAgent{ Name: "a-workspace-agent", @@ -108,8 +108,8 @@ func Test_buildAppLinkURL(t *testing.T) { name: "with command", baseURL: "https://coder.tld", workspace: codersdk.Workspace{ - Name: "Test-Workspace", - OwnerName: "username", + Name: "Test-Workspace", + OwnerUsername: "username", }, agent: codersdk.WorkspaceAgent{ Name: "a-workspace-agent", @@ -123,8 +123,8 @@ func Test_buildAppLinkURL(t *testing.T) { name: "with subdomain", baseURL: "ftps://coder.tld", workspace: codersdk.Workspace{ - Name: "Test-Workspace", - OwnerName: "username", + Name: "Test-Workspace", + OwnerUsername: "username", }, agent: codersdk.WorkspaceAgent{ Name: "a-workspace-agent", @@ -141,8 +141,8 @@ func Test_buildAppLinkURL(t *testing.T) { name: "with subdomain, but not apps host", baseURL: "https://coder.tld", workspace: codersdk.Workspace{ - Name: "Test-Workspace", - OwnerName: "username", + Name: "Test-Workspace", + OwnerUsername: "username", }, agent: codersdk.WorkspaceAgent{ Name: "a-workspace-agent", diff --git a/cli/restart_test.go b/cli/restart_test.go index d69344435bf28..46d3c7b675949 100644 --- a/cli/restart_test.go +++ b/cli/restart_test.go @@ -342,7 +342,7 @@ func TestRestartWithParameters(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() - workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) require.NoError(t, err) @@ -393,7 +393,7 @@ func TestRestartWithParameters(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() - workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) require.NoError(t, err) diff --git a/cli/schedule.go b/cli/schedule.go index 9ade82b9c4a36..02445576f427f 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -327,7 +327,7 @@ func scheduleListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) s } } return scheduleListRow{ - WorkspaceName: workspace.OwnerName + "/" + workspace.Name, + WorkspaceName: workspace.OwnerUsername + "/" + workspace.Name, StartsAt: autostartDisplay, StartsNext: nextStartDisplay, StopsAfter: autostopDisplay, diff --git a/cli/schedule_test.go b/cli/schedule_test.go index 60fbf19f4db08..e702c0a8d65de 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -71,8 +71,8 @@ func setupTestSchedule(t *testing.T, sched *cron.Schedule) (ownerClient, memberC // Ensure same order as in CLI output ws = resp.Workspaces sort.Slice(ws, func(i, j int) bool { - a := ws[i].OwnerName + "/" + ws[i].Name - b := ws[j].OwnerName + "/" + ws[j].Name + a := ws[i].OwnerUsername + "/" + ws[i].Name + b := ws[j].OwnerUsername + "/" + ws[j].Name return a < b }) @@ -102,13 +102,13 @@ func TestScheduleShow(t *testing.T) { // Then: they should see their own workspaces. // 1st workspace: a-owner-ws1 has both autostart and autostop enabled. - pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) + pty.ExpectMatch(ws[0].OwnerUsername + "/" + ws[0].Name) pty.ExpectMatch(sched.Humanize()) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) pty.ExpectMatch("8h") pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 2nd workspace: b-owner-ws2 has only autostart enabled. - pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) + pty.ExpectMatch(ws[1].OwnerUsername + "/" + ws[1].Name) pty.ExpectMatch(sched.Humanize()) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) }) @@ -123,21 +123,21 @@ func TestScheduleShow(t *testing.T) { // Then: they should see all workspaces // 1st workspace: a-owner-ws1 has both autostart and autostop enabled. - pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) + pty.ExpectMatch(ws[0].OwnerUsername + "/" + ws[0].Name) pty.ExpectMatch(sched.Humanize()) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) pty.ExpectMatch("8h") pty.ExpectMatch(ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 2nd workspace: b-owner-ws2 has only autostart enabled. - pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) + pty.ExpectMatch(ws[1].OwnerUsername + "/" + ws[1].Name) pty.ExpectMatch(sched.Humanize()) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) // 3rd workspace: c-member-ws3 has only autostop enabled. - pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch(ws[2].OwnerUsername + "/" + ws[2].Name) pty.ExpectMatch("8h") pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 4th workspace: d-member-ws4 has neither autostart nor autostop enabled. - pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) + pty.ExpectMatch(ws[3].OwnerUsername + "/" + ws[3].Name) }) t.Run("OwnerSearchByName", func(t *testing.T) { @@ -150,14 +150,14 @@ func TestScheduleShow(t *testing.T) { // Then: they should see workspaces matching that query // 2nd workspace: b-owner-ws2 has only autostart enabled. - pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) + pty.ExpectMatch(ws[1].OwnerUsername + "/" + ws[1].Name) pty.ExpectMatch(sched.Humanize()) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) }) t.Run("OwnerOneArg", func(t *testing.T) { // When: owner asks for a specific workspace by name - inv, root := clitest.New(t, "schedule", "show", ws[2].OwnerName+"/"+ws[2].Name) + inv, root := clitest.New(t, "schedule", "show", ws[2].OwnerUsername+"/"+ws[2].Name) //nolint:gocritic // Testing that owner user sees all clitest.SetupConfig(t, ownerClient, root) pty := ptytest.New(t).Attach(inv) @@ -165,7 +165,7 @@ func TestScheduleShow(t *testing.T) { // Then: they should see that workspace // 3rd workspace: c-member-ws3 has only autostop enabled. - pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch(ws[2].OwnerUsername + "/" + ws[2].Name) pty.ExpectMatch("8h") pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) }) @@ -179,11 +179,11 @@ func TestScheduleShow(t *testing.T) { // Then: they should see their own workspaces // 1st workspace: c-member-ws3 has only autostop enabled. - pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch(ws[2].OwnerUsername + "/" + ws[2].Name) pty.ExpectMatch("8h") pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled. - pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) + pty.ExpectMatch(ws[3].OwnerUsername + "/" + ws[3].Name) }) t.Run("MemberAll", func(t *testing.T) { @@ -200,11 +200,11 @@ func TestScheduleShow(t *testing.T) { // Then: they should only see their own // 1st workspace: c-member-ws3 has only autostop enabled. - pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch(ws[2].OwnerUsername + "/" + ws[2].Name) pty.ExpectMatch("8h") pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) // 2nd workspace: d-member-ws4 has neither autostart nor autostop enabled. - pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) + pty.ExpectMatch(ws[3].OwnerUsername + "/" + ws[3].Name) }) t.Run("JSON", func(t *testing.T) { @@ -231,25 +231,25 @@ func TestScheduleShow(t *testing.T) { return a < b }) // 1st workspace: a-owner-ws1 has both autostart and autostop enabled. - assert.Equal(t, ws[0].OwnerName+"/"+ws[0].Name, parsed[0]["workspace"]) + assert.Equal(t, ws[0].OwnerUsername+"/"+ws[0].Name, parsed[0]["workspace"]) assert.Equal(t, sched.Humanize(), parsed[0]["starts_at"]) assert.Equal(t, sched.Next(now).In(loc).Format(time.RFC3339), parsed[0]["starts_next"]) assert.Equal(t, "8h", parsed[0]["stops_after"]) assert.Equal(t, ws[0].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339), parsed[0]["stops_next"]) // 2nd workspace: b-owner-ws2 has only autostart enabled. - assert.Equal(t, ws[1].OwnerName+"/"+ws[1].Name, parsed[1]["workspace"]) + assert.Equal(t, ws[1].OwnerUsername+"/"+ws[1].Name, parsed[1]["workspace"]) assert.Equal(t, sched.Humanize(), parsed[1]["starts_at"]) assert.Equal(t, sched.Next(now).In(loc).Format(time.RFC3339), parsed[1]["starts_next"]) assert.Empty(t, parsed[1]["stops_after"]) assert.Empty(t, parsed[1]["stops_next"]) // 3rd workspace: c-member-ws3 has only autostop enabled. - assert.Equal(t, ws[2].OwnerName+"/"+ws[2].Name, parsed[2]["workspace"]) + assert.Equal(t, ws[2].OwnerUsername+"/"+ws[2].Name, parsed[2]["workspace"]) assert.Empty(t, parsed[2]["starts_at"]) assert.Empty(t, parsed[2]["starts_next"]) assert.Equal(t, "8h", parsed[2]["stops_after"]) assert.Equal(t, ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339), parsed[2]["stops_next"]) // 4th workspace: d-member-ws4 has neither autostart nor autostop enabled. - assert.Equal(t, ws[3].OwnerName+"/"+ws[3].Name, parsed[3]["workspace"]) + assert.Equal(t, ws[3].OwnerUsername+"/"+ws[3].Name, parsed[3]["workspace"]) assert.Empty(t, parsed[3]["starts_at"]) assert.Empty(t, parsed[3]["starts_next"]) assert.Empty(t, parsed[3]["stops_after"]) @@ -272,7 +272,7 @@ func TestScheduleModify(t *testing.T) { t.Run("SetStart", func(t *testing.T) { // When: we set the start schedule inv, root := clitest.New(t, - "schedule", "start", ws[3].OwnerName+"/"+ws[3].Name, "7:30AM", "Mon-Fri", "Europe/Dublin", + "schedule", "start", ws[3].OwnerUsername+"/"+ws[3].Name, "7:30AM", "Mon-Fri", "Europe/Dublin", ) //nolint:gocritic // this workspace is not owned by the same user clitest.SetupConfig(t, ownerClient, root) @@ -280,7 +280,7 @@ func TestScheduleModify(t *testing.T) { require.NoError(t, inv.Run()) // Then: the updated schedule should be shown - pty.ExpectMatch(ws[3].OwnerName + "/" + ws[3].Name) + pty.ExpectMatch(ws[3].OwnerUsername + "/" + ws[3].Name) pty.ExpectMatch(sched.Humanize()) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) }) @@ -288,7 +288,7 @@ func TestScheduleModify(t *testing.T) { t.Run("SetStop", func(t *testing.T) { // When: we set the stop schedule inv, root := clitest.New(t, - "schedule", "stop", ws[2].OwnerName+"/"+ws[2].Name, "8h30m", + "schedule", "stop", ws[2].OwnerUsername+"/"+ws[2].Name, "8h30m", ) //nolint:gocritic // this workspace is not owned by the same user clitest.SetupConfig(t, ownerClient, root) @@ -296,7 +296,7 @@ func TestScheduleModify(t *testing.T) { require.NoError(t, inv.Run()) // Then: the updated schedule should be shown - pty.ExpectMatch(ws[2].OwnerName + "/" + ws[2].Name) + pty.ExpectMatch(ws[2].OwnerUsername + "/" + ws[2].Name) pty.ExpectMatch("8h30m") pty.ExpectMatch(ws[2].LatestBuild.Deadline.Time.In(loc).Format(time.RFC3339)) }) @@ -304,7 +304,7 @@ func TestScheduleModify(t *testing.T) { t.Run("UnsetStart", func(t *testing.T) { // When: we unset the start schedule inv, root := clitest.New(t, - "schedule", "start", ws[1].OwnerName+"/"+ws[1].Name, "manual", + "schedule", "start", ws[1].OwnerUsername+"/"+ws[1].Name, "manual", ) //nolint:gocritic // this workspace is owned by owner clitest.SetupConfig(t, ownerClient, root) @@ -312,13 +312,13 @@ func TestScheduleModify(t *testing.T) { require.NoError(t, inv.Run()) // Then: the updated schedule should be shown - pty.ExpectMatch(ws[1].OwnerName + "/" + ws[1].Name) + pty.ExpectMatch(ws[1].OwnerUsername + "/" + ws[1].Name) }) t.Run("UnsetStop", func(t *testing.T) { // When: we unset the stop schedule inv, root := clitest.New(t, - "schedule", "stop", ws[0].OwnerName+"/"+ws[0].Name, "manual", + "schedule", "stop", ws[0].OwnerUsername+"/"+ws[0].Name, "manual", ) //nolint:gocritic // this workspace is owned by owner clitest.SetupConfig(t, ownerClient, root) @@ -326,7 +326,7 @@ func TestScheduleModify(t *testing.T) { require.NoError(t, inv.Run()) // Then: the updated schedule should be shown - pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) + pty.ExpectMatch(ws[0].OwnerUsername + "/" + ws[0].Name) }) } @@ -359,7 +359,7 @@ func TestScheduleOverride(t *testing.T) { // When: we override the stop schedule inv, root := clitest.New(t, - "schedule", tt.command, ws[0].OwnerName+"/"+ws[0].Name, "10h", + "schedule", tt.command, ws[0].OwnerUsername+"/"+ws[0].Name, "10h", ) clitest.SetupConfig(t, ownerClient, root) @@ -367,7 +367,7 @@ func TestScheduleOverride(t *testing.T) { require.NoError(t, inv.Run()) // Then: the updated schedule should be shown - pty.ExpectMatch(ws[0].OwnerName + "/" + ws[0].Name) + pty.ExpectMatch(ws[0].OwnerUsername + "/" + ws[0].Name) pty.ExpectMatch(sched.Humanize()) pty.ExpectMatch(sched.Next(now).In(loc).Format(time.RFC3339)) pty.ExpectMatch("8h") diff --git a/cli/ssh.go b/cli/ssh.go index 5cc81284ca317..70f00afa5614a 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -305,7 +305,7 @@ func (r *RootCmd) ssh() *serpent.Command { return xerrors.Errorf("get agent connection info: %w", err) } coderConnectHost := fmt.Sprintf("%s.%s.%s.%s", - workspaceAgent.Name, workspace.Name, workspace.OwnerName, connInfo.HostnameSuffix) + workspaceAgent.Name, workspace.Name, workspace.OwnerUsername, connInfo.HostnameSuffix) exists, _ := workspacesdk.ExistsViaCoderConnect(ctx, coderConnectHost) if exists { defer cancel() @@ -1022,7 +1022,7 @@ func verifyWorkspaceOutdated(client *codersdk.Client, workspace codersdk.Workspa // Build the user workspace link which navigates to the Coder web UI. func buildWorkspaceLink(serverURL *url.URL, workspace codersdk.Workspace) *url.URL { - return serverURL.ResolveReference(&url.URL{Path: fmt.Sprintf("@%s/%s", workspace.OwnerName, workspace.Name)}) + return serverURL.ResolveReference(&url.URL{Path: fmt.Sprintf("@%s/%s", workspace.OwnerUsername, workspace.Name)}) } // runLocal runs a command on the local machine. diff --git a/cli/ssh_internal_test.go b/cli/ssh_internal_test.go index 003bc697a4052..c445d4fadf44b 100644 --- a/cli/ssh_internal_test.go +++ b/cli/ssh_internal_test.go @@ -25,7 +25,7 @@ import ( ) const ( - fakeOwnerName = "fake-owner-name" + fakeOwnerUsername = "fake-owner-name" fakeServerURL = "https://fake-foo-url" fakeWorkspaceName = "fake-workspace-name" ) @@ -41,7 +41,7 @@ func TestVerifyWorkspaceOutdated(t *testing.T) { t.Run("Up-to-date", func(t *testing.T) { t.Parallel() - workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerName: fakeOwnerName} + workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerUsername: fakeOwnerUsername} _, outdated := verifyWorkspaceOutdated(&client, workspace) @@ -50,7 +50,7 @@ func TestVerifyWorkspaceOutdated(t *testing.T) { t.Run("Outdated", func(t *testing.T) { t.Parallel() - workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerName: fakeOwnerName, Outdated: true} + workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerUsername: fakeOwnerUsername, Outdated: true} updateWorkspaceBanner, outdated := verifyWorkspaceOutdated(&client, workspace) @@ -65,10 +65,10 @@ func TestBuildWorkspaceLink(t *testing.T) { serverURL, err := url.Parse(fakeServerURL) require.NoError(t, err) - workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerName: fakeOwnerName} + workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerUsername: fakeOwnerUsername} workspaceLink := buildWorkspaceLink(serverURL, workspace) - assert.Equal(t, workspaceLink.String(), fakeServerURL+"/@"+fakeOwnerName+"/"+fakeWorkspaceName) + assert.Equal(t, workspaceLink.String(), fakeServerURL+"/@"+fakeOwnerUsername+"/"+fakeWorkspaceName) } func TestCloserStack_Mainline(t *testing.T) { diff --git a/cli/start_test.go b/cli/start_test.go index 29fa4cdb46e5f..d15dcbd356711 100644 --- a/cli/start_test.go +++ b/cli/start_test.go @@ -148,7 +148,7 @@ func TestStart(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() - workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) require.NoError(t, err) @@ -191,7 +191,7 @@ func TestStart(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() - workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) require.NoError(t, err) @@ -247,7 +247,7 @@ func TestStartWithParameters(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() - workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) require.NoError(t, err) @@ -302,7 +302,7 @@ func TestStartWithParameters(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() - workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID) require.NoError(t, err) diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index d8e6a306cabcf..80180c6262b21 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -4,7 +4,7 @@ "created_at": "====[timestamp]=====", "updated_at": "====[timestamp]=====", "owner_id": "==========[first user ID]===========", - "owner_name": "testuser", + "owner_username": "testuser", "owner_avatar_url": "", "organization_id": "===========[first org ID]===========", "organization_name": "coder", diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 416e230dfffc9..0cc518d09399f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17004,6 +17004,9 @@ const docTemplate = `{ "owner_name": { "type": "string" }, + "owner_username": { + "type": "string" + }, "template_active_version_id": { "type": "string", "format": "uuid" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4dbbf5a5fbcf9..0fdc08d22e228 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15509,6 +15509,9 @@ "owner_name": { "type": "string" }, + "owner_username": { + "type": "string" + }, "template_active_version_id": { "type": "string", "format": "uuid" diff --git a/coderd/audit_test.go b/coderd/audit_test.go index 18bcd78b38807..c61ded694171d 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -139,7 +139,7 @@ func TestAuditLogs(t *testing.T) { require.NoError(t, err) buildNumberString := strconv.FormatInt(int64(workspace.LatestBuild.BuildNumber), 10) require.Equal(t, auditLogs.AuditLogs[0].ResourceLink, fmt.Sprintf("/@%s/%s/builds/%s", - workspace.OwnerName, workspace.Name, buildNumberString)) + workspace.OwnerUsername, workspace.Name, buildNumberString)) }) t.Run("Organization", func(t *testing.T) { diff --git a/coderd/prebuilds/global_snapshot.go b/coderd/prebuilds/global_snapshot.go index f4c094289b54e..976461780fd07 100644 --- a/coderd/prebuilds/global_snapshot.go +++ b/coderd/prebuilds/global_snapshot.go @@ -12,11 +12,11 @@ import ( // GlobalSnapshot represents a full point-in-time snapshot of state relating to prebuilds across all templates. type GlobalSnapshot struct { - Presets []database.GetTemplatePresetsWithPrebuildsRow - RunningPrebuilds []database.GetRunningPrebuiltWorkspacesRow - PrebuildsInProgress []database.CountInProgressPrebuildsRow - Backoffs []database.GetPresetsBackoffRow - HardLimitedPresets []database.GetPresetsAtFailureLimitRow + Presets []database.GetTemplatePresetsWithPrebuildsRow + RunningPrebuilds []database.GetRunningPrebuiltWorkspacesRow + PrebuildsInProgress []database.CountInProgressPrebuildsRow + Backoffs []database.GetPresetsBackoffRow + HardLimitedPresetsMap map[uuid.UUID]database.GetPresetsAtFailureLimitRow } func NewGlobalSnapshot( @@ -26,12 +26,17 @@ func NewGlobalSnapshot( backoffs []database.GetPresetsBackoffRow, hardLimitedPresets []database.GetPresetsAtFailureLimitRow, ) GlobalSnapshot { + hardLimitedPresetsMap := make(map[uuid.UUID]database.GetPresetsAtFailureLimitRow, len(hardLimitedPresets)) + for _, preset := range hardLimitedPresets { + hardLimitedPresetsMap[preset.PresetID] = preset + } + return GlobalSnapshot{ - Presets: presets, - RunningPrebuilds: runningPrebuilds, - PrebuildsInProgress: prebuildsInProgress, - Backoffs: backoffs, - HardLimitedPresets: hardLimitedPresets, + Presets: presets, + RunningPrebuilds: runningPrebuilds, + PrebuildsInProgress: prebuildsInProgress, + Backoffs: backoffs, + HardLimitedPresetsMap: hardLimitedPresetsMap, } } @@ -66,9 +71,7 @@ func (s GlobalSnapshot) FilterByPreset(presetID uuid.UUID) (*PresetSnapshot, err backoffPtr = &backoff } - _, isHardLimited := slice.Find(s.HardLimitedPresets, func(row database.GetPresetsAtFailureLimitRow) bool { - return row.PresetID == preset.ID - }) + _, isHardLimited := s.HardLimitedPresetsMap[preset.ID] return &PresetSnapshot{ Preset: preset, @@ -80,6 +83,12 @@ func (s GlobalSnapshot) FilterByPreset(presetID uuid.UUID) (*PresetSnapshot, err }, nil } +func (s GlobalSnapshot) IsHardLimited(presetID uuid.UUID) bool { + _, isHardLimited := s.HardLimitedPresetsMap[presetID] + + return isHardLimited +} + // filterExpiredWorkspaces splits running workspaces into expired and non-expired // based on the preset's TTL. // If TTL is missing or zero, all workspaces are considered non-expired. diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 5635296d1a47b..f58c039868861 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1771,7 +1771,7 @@ func TestWorkspaceAgent_Metadata(t *testing.T) { // Verify manifest API response. require.Equal(t, workspace.ID, manifest.WorkspaceID) - require.Equal(t, workspace.OwnerName, manifest.OwnerName) + require.Equal(t, workspace.OwnerUsername, manifest.OwnerUsername) require.Equal(t, "First Meta", manifest.Metadata[0].DisplayName) require.Equal(t, "foo1", manifest.Metadata[0].Key) require.Equal(t, "echo hi", manifest.Metadata[0].Script) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index fe0c2d3f609a2..cb954c8690685 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -2248,7 +2248,8 @@ func convertWorkspace( CreatedAt: workspace.CreatedAt, UpdatedAt: workspace.UpdatedAt, OwnerID: workspace.OwnerID, - OwnerName: workspace.OwnerUsername, + OwnerName: workspace.OwnerName, + OwnerUsername: workspace.OwnerUsername, OwnerAvatarURL: workspace.OwnerAvatarUrl, OrganizationID: workspace.OrganizationID, OrganizationName: workspace.OrganizationName, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index e5a5a1e513633..fedbee56942cb 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1379,12 +1379,12 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { // Then: // When we call without includes_deleted, we don't expect to get the workspace back - _, err = client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + _, err = client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{}) require.ErrorContains(t, err, "404") // Then: // When we call with includes_deleted, we should get the workspace back - workspaceNew, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{IncludeDeleted: true}) + workspaceNew, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{IncludeDeleted: true}) require.NoError(t, err) require.Equal(t, workspace.ID, workspaceNew.ID) @@ -1402,7 +1402,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { // Then: // We can fetch the most recent workspace - workspaceNew, err = client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{}) + workspaceNew, err = client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{}) require.NoError(t, err) require.Equal(t, workspace.ID, workspaceNew.ID) @@ -1416,7 +1416,7 @@ func TestWorkspaceByOwnerAndName(t *testing.T) { // Then: // When we fetch the deleted workspace, we get the most recently deleted one - workspaceNew, err = client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{IncludeDeleted: true}) + workspaceNew, err = client.WorkspaceByOwnerAndName(ctx, workspace.OwnerUsername, workspace.Name, codersdk.WorkspaceOptions{IncludeDeleted: true}) require.NoError(t, err) require.Equal(t, workspace.ID, workspaceNew.ID) }) @@ -1901,7 +1901,7 @@ func TestWorkspaceFilterManual(t *testing.T) { require.NoError(t, err) require.Len(t, res.Workspaces, len(workspaces)) for _, found := range res.Workspaces { - require.Equal(t, found.OwnerName, sdkUser.Username) + require.Equal(t, found.OwnerUsername, sdkUser.Username) } }) t.Run("IDs", func(t *testing.T) { @@ -2033,7 +2033,7 @@ func TestWorkspaceFilterManual(t *testing.T) { // single workspace res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ - FilterQuery: fmt.Sprintf("template:%s %s/%s", template.Name, workspace.OwnerName, workspace.Name), + FilterQuery: fmt.Sprintf("template:%s %s/%s", template.Name, workspace.OwnerUsername, workspace.Name), }) require.NoError(t, err) require.Len(t, res.Workspaces, 1) diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index 9e6df933ce6c3..b17bb5db536e5 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -104,10 +104,10 @@ type PostMetadataRequestDeprecated = codersdk.WorkspaceAgentMetadataResult type Manifest struct { AgentID uuid.UUID `json:"agent_id"` AgentName string `json:"agent_name"` - // OwnerName and WorkspaceID are used by an open-source user to identify the workspace. + // OwnerUsername and WorkspaceID are used by an open-source user to identify the workspace. // We do not provide insurance that this will not be removed in the future, // but if it's easy to persist lets keep it around. - OwnerName string `json:"owner_name"` + OwnerUsername string `json:"owner_username"` WorkspaceID uuid.UUID `json:"workspace_id"` WorkspaceName string `json:"workspace_name"` // GitAuthConfigs stores the number of Git configurations diff --git a/codersdk/agentsdk/convert.go b/codersdk/agentsdk/convert.go index 2b7dff950a3e7..f67ac63f3861b 100644 --- a/codersdk/agentsdk/convert.go +++ b/codersdk/agentsdk/convert.go @@ -38,7 +38,7 @@ func ManifestFromProto(manifest *proto.Manifest) (Manifest, error) { return Manifest{ AgentID: agentID, AgentName: manifest.AgentName, - OwnerName: manifest.OwnerUsername, + OwnerUsername: manifest.OwnerUsername, WorkspaceID: workspaceID, WorkspaceName: manifest.WorkspaceName, Apps: apps, @@ -64,7 +64,7 @@ func ProtoFromManifest(manifest Manifest) (*proto.Manifest, error) { return &proto.Manifest{ AgentId: manifest.AgentID[:], AgentName: manifest.AgentName, - OwnerUsername: manifest.OwnerName, + OwnerUsername: manifest.OwnerUsername, WorkspaceId: manifest.WorkspaceID[:], WorkspaceName: manifest.WorkspaceName, // #nosec G115 - Safe conversion for GitAuthConfigs which is expected to be small and positive diff --git a/codersdk/agentsdk/convert_test.go b/codersdk/agentsdk/convert_test.go index 09482b1694910..dcafe3127a3df 100644 --- a/codersdk/agentsdk/convert_test.go +++ b/codersdk/agentsdk/convert_test.go @@ -21,7 +21,7 @@ func TestManifest(t *testing.T) { manifest := agentsdk.Manifest{ AgentID: uuid.New(), AgentName: "test-agent", - OwnerName: "test-owner", + OwnerUsername: "test-owner", WorkspaceID: uuid.New(), WorkspaceName: "test-workspace", GitAuthConfigs: 3, @@ -144,7 +144,7 @@ func TestManifest(t *testing.T) { require.NoError(t, err) require.Equal(t, manifest.AgentID, back.AgentID) require.Equal(t, manifest.AgentName, back.AgentName) - require.Equal(t, manifest.OwnerName, back.OwnerName) + require.Equal(t, manifest.OwnerUsername, back.OwnerUsername) require.Equal(t, manifest.WorkspaceID, back.WorkspaceID) require.Equal(t, manifest.WorkspaceName, back.WorkspaceName) require.Equal(t, manifest.GitAuthConfigs, back.GitAuthConfigs) diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index e0f1b9b1e2c2a..ee762ddcd3d30 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -30,7 +30,8 @@ type Workspace struct { 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 string `json:"owner_name"` + OwnerName string `json:"owner_name,omitempty"` + OwnerUsername string `json:"owner_username"` OwnerAvatarURL string `json:"owner_avatar_url"` OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` OrganizationName string `json:"organization_name"` @@ -69,7 +70,7 @@ type Workspace struct { } func (w Workspace) FullName() string { - return fmt.Sprintf("%s/%s", w.OwnerName, w.Name) + return fmt.Sprintf("%s/%s", w.OwnerUsername, w.Name) } type WorkspaceHealth struct { diff --git a/docs/admin/templates/extending-templates/parameters.md b/docs/admin/templates/extending-templates/parameters.md index f3640007634e0..0125dd49a5c5e 100644 --- a/docs/admin/templates/extending-templates/parameters.md +++ b/docs/admin/templates/extending-templates/parameters.md @@ -478,13 +478,13 @@ The "Options" column in the table below indicates whether the form type requires | `checkbox` | `bool` | No | A single checkbox for boolean parameters. Default for boolean parameters. | | `dropdown` | `string`, `number` | Yes | Searchable dropdown list for choosing a single option from a list. Default for `string` or `number` parameters with options. | | `input` | `string`, `number` | No | Standard single-line text input field. Default for string/number parameters without options. | -| `key-value` | `string` | No | For entering key-value pairs (as JSON). | | `multi-select` | `list(string)` | Yes | Select multiple items from a list with checkboxes. | | `radio` | `string`, `number`, `bool`, `list(string)` | Yes | Radio buttons for selecting a single option with all choices visible at once. | | `slider` | `number` | No | Slider selection with min/max validation for numeric values. | | `switch` | `bool` | No | Toggle switch alternative for boolean parameters. | | `tag-select` | `list(string)` | No | Default for list(string) parameters without options. | -| `textarea` | `string` | No | Multi-line text input field for longer content. | | +| `textarea` | `string` | No | Multi-line text input field for longer content. | +| `error` | | No | Used to display an error message when a parameter form_type is unknown | ### Form Type Examples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index fbb23964f3d28..84681a9134530 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -8420,6 +8420,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "owner_username": "string", "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -8457,6 +8458,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `owner_avatar_url` | string | false | | | | `owner_id` | string | false | | | | `owner_name` | string | false | | | +| `owner_username` | string | false | | | | `template_active_version_id` | string | false | | | | `template_allow_user_cancel_workspace_jobs` | boolean | false | | | | `template_display_name` | string | false | | | @@ -10123,6 +10125,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "owner_username": "string", "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 1e73787dfb77e..6f3b2ec9ce76d 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -291,6 +291,7 @@ of the template will be used. "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "owner_username": "string", "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -576,6 +577,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "owner_username": "string", "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -887,6 +889,7 @@ of the template will be used. "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "owner_username": "string", "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -1158,6 +1161,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "owner_username": "string", "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -1444,6 +1448,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "owner_username": "string", "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", @@ -1845,6 +1850,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "owner_avatar_url": "string", "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", "owner_name": "string", + "owner_username": "string", "template_active_version_id": "b0da9c29-67d8-4c87-888c-bafe356f7f3c", "template_allow_user_cancel_workspace_jobs": true, "template_display_name": "string", diff --git a/enterprise/coderd/prebuilds/metricscollector.go b/enterprise/coderd/prebuilds/metricscollector.go index 90257c26dd580..4499849ffde0a 100644 --- a/enterprise/coderd/prebuilds/metricscollector.go +++ b/enterprise/coderd/prebuilds/metricscollector.go @@ -280,16 +280,9 @@ func (k hardLimitedPresetKey) String() string { return fmt.Sprintf("%s:%s:%s", k.orgName, k.templateName, k.presetName) } -// nolint:revive // isHardLimited determines if the preset should be reported as hard-limited in Prometheus. -func (mc *MetricsCollector) trackHardLimitedStatus(orgName, templateName, presetName string, isHardLimited bool) { +func (mc *MetricsCollector) registerHardLimitedPresets(isPresetHardLimited map[hardLimitedPresetKey]bool) { mc.isPresetHardLimitedMu.Lock() defer mc.isPresetHardLimitedMu.Unlock() - key := hardLimitedPresetKey{orgName: orgName, templateName: templateName, presetName: presetName} - - if isHardLimited { - mc.isPresetHardLimited[key] = true - } else { - delete(mc.isPresetHardLimited, key) - } + mc.isPresetHardLimited = isPresetHardLimited } diff --git a/enterprise/coderd/prebuilds/reconcile.go b/enterprise/coderd/prebuilds/reconcile.go index 90c97afa26d69..ebfcfaf2b3182 100644 --- a/enterprise/coderd/prebuilds/reconcile.go +++ b/enterprise/coderd/prebuilds/reconcile.go @@ -256,6 +256,9 @@ func (c *StoreReconciler) ReconcileAll(ctx context.Context) error { if err != nil { return xerrors.Errorf("determine current snapshot: %w", err) } + + c.reportHardLimitedPresets(snapshot) + if len(snapshot.Presets) == 0 { logger.Debug(ctx, "no templates found with prebuilds configured") return nil @@ -296,6 +299,49 @@ func (c *StoreReconciler) ReconcileAll(ctx context.Context) error { return err } +func (c *StoreReconciler) reportHardLimitedPresets(snapshot *prebuilds.GlobalSnapshot) { + // presetsMap is a map from key (orgName:templateName:presetName) to list of corresponding presets. + // Multiple versions of a preset can exist with the same orgName, templateName, and presetName, + // because templates can have multiple versions — or deleted templates can share the same name. + presetsMap := make(map[hardLimitedPresetKey][]database.GetTemplatePresetsWithPrebuildsRow) + for _, preset := range snapshot.Presets { + key := hardLimitedPresetKey{ + orgName: preset.OrganizationName, + templateName: preset.TemplateName, + presetName: preset.Name, + } + + presetsMap[key] = append(presetsMap[key], preset) + } + + // Report a preset as hard-limited only if all the following conditions are met: + // - The preset is marked as hard-limited + // - The preset is using the active version of its template, and the template has not been deleted + // + // The second condition is important because a hard-limited preset that has become outdated is no longer relevant. + // Its associated prebuilt workspaces were likely deleted, and it's not meaningful to continue reporting it + // as hard-limited to the admin. + // + // This approach accounts for all relevant scenarios: + // Scenario #1: The admin created a new template version with the same preset names. + // Scenario #2: The admin created a new template version and renamed the presets. + // Scenario #3: The admin deleted a template version that contained hard-limited presets. + // + // In all of these cases, only the latest and non-deleted presets will be reported. + // All other presets will be ignored and eventually removed from Prometheus. + isPresetHardLimited := make(map[hardLimitedPresetKey]bool) + for key, presets := range presetsMap { + for _, preset := range presets { + if preset.UsingActiveVersion && !preset.Deleted && snapshot.IsHardLimited(preset.ID) { + isPresetHardLimited[key] = true + break + } + } + } + + c.metrics.registerHardLimitedPresets(isPresetHardLimited) +} + // SnapshotState captures the current state of all prebuilds across templates. func (c *StoreReconciler) SnapshotState(ctx context.Context, store database.Store) (*prebuilds.GlobalSnapshot, error) { if err := ctx.Err(); err != nil { @@ -361,24 +407,6 @@ func (c *StoreReconciler) ReconcilePreset(ctx context.Context, ps prebuilds.Pres slog.F("preset_name", ps.Preset.Name), ) - // Report a metric only if the preset uses the latest version of the template and the template is not deleted. - // This avoids conflicts between metrics from old and new template versions. - // - // NOTE: Multiple versions of a preset can exist with the same orgName, templateName, and presetName, - // because templates can have multiple versions — or deleted templates can share the same name. - // - // The safest approach is to report the metric only for the latest version of the preset. - // When a new template version is released, the metric for the new preset should overwrite - // the old value in Prometheus. - // - // However, there’s one edge case: if an admin creates a template, it becomes hard-limited, - // then deletes the template and never creates another with the same name, - // the old preset will continue to be reported as hard-limited — - // even though it’s deleted. This will persist until `coderd` is restarted. - if ps.Preset.UsingActiveVersion && !ps.Preset.Deleted { - c.metrics.trackHardLimitedStatus(ps.Preset.OrganizationName, ps.Preset.TemplateName, ps.Preset.Name, ps.IsHardLimited) - } - // If the preset reached the hard failure limit for the first time during this iteration: // - Mark it as hard-limited in the database // - Send notifications to template admins diff --git a/enterprise/coderd/prebuilds/reconcile_test.go b/enterprise/coderd/prebuilds/reconcile_test.go index 7de22db64c8be..a0e1f9726d7d5 100644 --- a/enterprise/coderd/prebuilds/reconcile_test.go +++ b/enterprise/coderd/prebuilds/reconcile_test.go @@ -1034,8 +1034,7 @@ func TestHardLimitedPresetShouldNotBlockDeletion(t *testing.T) { require.Equal(t, database.WorkspaceTransitionDelete, workspaceBuilds[0].Transition) require.Equal(t, database.WorkspaceTransitionStart, workspaceBuilds[1].Transition) - // The metric is still set to 1, even though the preset has become outdated. - // This happens because the old value hasn't been overwritten by a newer preset yet. + // Metric is deleted after preset became outdated. mf, err = registry.Gather() require.NoError(t, err) metric = findMetric(mf, prebuilds.MetricPresetHardLimitedGauge, map[string]string{ @@ -1043,9 +1042,7 @@ func TestHardLimitedPresetShouldNotBlockDeletion(t *testing.T) { "preset_name": preset.Name, "org_name": org.Name, }) - require.NotNil(t, metric) - require.NotNil(t, metric.GetGauge()) - require.EqualValues(t, 1, metric.GetGauge().GetValue()) + require.Nil(t, metric) }) } } diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 61bc7f0e70c22..b1f7aad1d792f 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -267,7 +267,7 @@ export const activate = (workspace: Workspace, queryClient: QueryClient) => { }, onSuccess: (updatedWorkspace: Workspace) => { queryClient.setQueryData( - workspaceByOwnerAndNameKey(workspace.owner_name, workspace.name), + workspaceByOwnerAndNameKey(workspace.owner_username, workspace.name), updatedWorkspace, ); }, @@ -316,12 +316,12 @@ export const toggleFavorite = ( }, onSuccess: async () => { queryClient.setQueryData( - workspaceByOwnerAndNameKey(workspace.owner_name, workspace.name), + workspaceByOwnerAndNameKey(workspace.owner_username, workspace.name), { ...workspace, favorite: !workspace.favorite }, ); await queryClient.invalidateQueries({ queryKey: workspaceByOwnerAndNameKey( - workspace.owner_name, + workspace.owner_username, workspace.name, ), }); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 807b6baa8b8f2..d9008c4d78919 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3277,7 +3277,8 @@ export interface Workspace { readonly created_at: string; readonly updated_at: string; readonly owner_id: string; - readonly owner_name: string; + readonly owner_name?: string; + readonly owner_username: string; readonly owner_avatar_url: string; readonly organization_id: string; readonly organization_name: string; diff --git a/site/src/components/Table/Table.tsx b/site/src/components/Table/Table.tsx index c20fe99428e09..b642655f5539b 100644 --- a/site/src/components/Table/Table.tsx +++ b/site/src/components/Table/Table.tsx @@ -3,6 +3,7 @@ * @see {@link https://ui.shadcn.com/docs/components/table} */ +import { type VariantProps, cva } from "class-variance-authority"; import * as React from "react"; import { cn } from "utils/cn"; @@ -60,15 +61,38 @@ const TableFooter = React.forwardRef< /> )); +const tableRowVariants = cva( + [ + "border-0 border-b border-solid border-border transition-colors", + "data-[state=selected]:bg-muted", + ], + { + variants: { + hover: { + false: null, + true: cn([ + "cursor-pointer hover:outline focus:outline outline-1 -outline-offset-1 outline-border-hover", + "first:rounded-t-md last:rounded-b-md", + ]), + }, + }, + defaultVariants: { + hover: false, + }, + }, +); + export const TableRow = React.forwardRef< HTMLTableRowElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( + React.HTMLAttributes & + VariantProps +>(({ className, hover, ...props }, ref) => ( = ({ + status, + latest, + className: customClassName, +}) => { + const className = cn(["size-4 shrink-0", customClassName]); + + switch (status.state) { + case "complete": + return ( + + ); + case "failure": + return ( + + ); + case "working": + return latest ? ( + + ) : ( + + ); + default: + return ( + + ); + } +}; diff --git a/site/src/modules/apps/apps.test.ts b/site/src/modules/apps/apps.test.ts index e61b214a25385..517bc047ee815 100644 --- a/site/src/modules/apps/apps.test.ts +++ b/site/src/modules/apps/apps.test.ts @@ -81,7 +81,7 @@ describe("getAppHref", () => { path: "/path-base", }); expect(href).toBe( - `/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`, + `/path-base/@${MockWorkspace.owner_username}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`, ); }); @@ -97,7 +97,7 @@ describe("getAppHref", () => { path: "", }); expect(href).toBe( - `/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la`, + `/@${MockWorkspace.owner_username}/Test-Workspace.a-workspace-agent/terminal?command=ls%20-la`, ); }); @@ -129,7 +129,7 @@ describe("getAppHref", () => { path: "/path-base", }); expect(href).toBe( - `/path-base/@${MockWorkspace.owner_name}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`, + `/path-base/@${MockWorkspace.owner_username}/Test-Workspace.a-workspace-agent/apps/${app.slug}/`, ); }); }); diff --git a/site/src/modules/apps/apps.ts b/site/src/modules/apps/apps.ts index d154b632dc1ca..47e4447725481 100644 --- a/site/src/modules/apps/apps.ts +++ b/site/src/modules/apps/apps.ts @@ -105,7 +105,7 @@ export const getAppHref = ( // Terminal links are relative. The terminal page knows how // to select the correct workspace proxy for the websocket // connection. - return `/@${workspace.owner_name}/${workspace.name}.${ + return `/@${workspace.owner_username}/${workspace.name}.${ agent.name }/terminal?command=${encodeURIComponent(app.command)}`; } @@ -119,7 +119,7 @@ export const getAppHref = ( // The backend redirects if the trailing slash isn't included, so we add it // here to avoid extra roundtrips. - return `${path}/@${workspace.owner_name}/${workspace.name}.${ + return `${path}/@${workspace.owner_username}/${workspace.name}.${ agent.name }/apps/${encodeURIComponent(app.slug)}/`; }; diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index 4891c632bbc2a..a3c3e02ac8d8a 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -142,7 +142,7 @@ export const AgentDevcontainerCard: FC = ({
= ({ workspaceName={workspace.name} agentName={agent.name} containerName={container.name} - userName={workspace.owner_name} + userName={workspace.owner_username} /> {wildcardHostname !== "" && container.ports.map((port) => { @@ -170,7 +170,7 @@ export const AgentDevcontainerCard: FC = ({ port.host_port, agent.name, workspace.name, - workspace.owner_name, + workspace.owner_username, location.protocol === "https" ? "https" : "http", ) : ""; diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 15854cbdc23f3..40b6dcf3bea71 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -216,7 +216,7 @@ export const AgentRow: FC = ({ host={proxy.preferredWildcardHostname} workspaceName={workspace.name} agent={agent} - username={workspace.owner_name} + username={workspace.owner_username} workspaceID={workspace.id} template={template} /> @@ -239,7 +239,7 @@ export const AgentRow: FC = ({ <> {showVSCode && ( = ({ )} diff --git a/site/src/modules/resources/VSCodeDesktopButton/VSCodeDesktopButton.stories.tsx b/site/src/modules/resources/VSCodeDesktopButton/VSCodeDesktopButton.stories.tsx index fe3f274b17d24..792903da45e78 100644 --- a/site/src/modules/resources/VSCodeDesktopButton/VSCodeDesktopButton.stories.tsx +++ b/site/src/modules/resources/VSCodeDesktopButton/VSCodeDesktopButton.stories.tsx @@ -12,7 +12,7 @@ type Story = StoryObj; export const Default: Story = { args: { - userName: MockWorkspace.owner_name, + userName: MockWorkspace.owner_username, workspaceName: MockWorkspace.name, agentName: MockWorkspaceAgent.name, displayApps: [ diff --git a/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.stories.tsx b/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.stories.tsx index a16eb58ba72b3..bd4b5b378760a 100644 --- a/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.stories.tsx +++ b/site/src/modules/resources/VSCodeDevContainerButton/VSCodeDevContainerButton.stories.tsx @@ -12,7 +12,7 @@ type Story = StoryObj; export const Default: Story = { args: { - userName: MockWorkspace.owner_name, + userName: MockWorkspace.owner_username, workspaceName: MockWorkspace.name, agentName: MockWorkspaceAgent.name, devContainerName: "musing_ride", @@ -29,7 +29,7 @@ export const Default: Story = { export const VSCodeOnly: Story = { args: { - userName: MockWorkspace.owner_name, + userName: MockWorkspace.owner_username, workspaceName: MockWorkspace.name, agentName: MockWorkspaceAgent.name, devContainerName: "nifty_borg", @@ -45,7 +45,7 @@ export const VSCodeOnly: Story = { export const InsidersOnly: Story = { args: { - userName: MockWorkspace.owner_name, + userName: MockWorkspace.owner_username, workspaceName: MockWorkspace.name, agentName: MockWorkspaceAgent.name, devContainerName: "amazing_swartz", diff --git a/site/src/modules/tasks/tasks.ts b/site/src/modules/tasks/tasks.ts new file mode 100644 index 0000000000000..c48f5ec1c3f22 --- /dev/null +++ b/site/src/modules/tasks/tasks.ts @@ -0,0 +1,8 @@ +import type { Workspace } from "api/typesGenerated"; + +export const AI_PROMPT_PARAMETER_NAME = "AI Prompt"; + +export type Task = { + workspace: Workspace; + prompt: string; +}; diff --git a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx index 76e74f17c351e..f2eab7f2086ac 100644 --- a/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx +++ b/site/src/modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus.tsx @@ -34,7 +34,7 @@ export const WorkspaceAppStatus = ({ } return ( -
+
@@ -48,7 +48,9 @@ export const WorkspaceAppStatus = ({ {status.message} - {status.state} + + {status.state} +
); }; diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx index 22e9638ee7caa..8b02dd4930d62 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx @@ -90,7 +90,7 @@ export const WorkspaceMoreActions: FC = ({ Settings diff --git a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx index 03bf31cb095fb..d9b031aacb3cf 100644 --- a/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx +++ b/site/src/pages/ChatPage/ChatToolInvocation.stories.tsx @@ -38,7 +38,7 @@ export const CreateWorkspace: Story = { name: MockWorkspace.name, rich_parameters: {}, template_version_id: MockWorkspace.template_active_version_id, - user: MockWorkspace.owner_name, + user: MockWorkspace.owner_username, }, MockWorkspace, ), diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index e5a18edbc2224..946cdf13a34b6 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -102,7 +102,7 @@ const CreateWorkspacePage: FC = () => { const onCreateWorkspace = useCallback( (workspace: Workspace) => { - navigate(`/@${workspace.owner_name}/${workspace.name}`); + navigate(`/@${workspace.owner_username}/${workspace.name}`); }, [navigate], ); diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx index cf0e80d592cd6..477420e41fa77 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageExperimental.tsx @@ -195,7 +195,7 @@ const CreateWorkspacePageExperimental: FC = () => { const onCreateWorkspace = useCallback( (workspace: Workspace) => { - navigate(`/@${workspace.owner_name}/${workspace.name}`); + navigate(`/@${workspace.owner_username}/${workspace.name}`); }, [navigate], ); diff --git a/site/src/pages/TaskPage/TaskPage.stories.tsx b/site/src/pages/TaskPage/TaskPage.stories.tsx new file mode 100644 index 0000000000000..1fd9c4b93cfa6 --- /dev/null +++ b/site/src/pages/TaskPage/TaskPage.stories.tsx @@ -0,0 +1,150 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { spyOn } from "@storybook/test"; +import { + MockFailedWorkspace, + MockStartingWorkspace, + MockStoppedWorkspace, + MockWorkspace, + MockWorkspaceAgent, + MockWorkspaceApp, + MockWorkspaceAppStatus, + MockWorkspaceResource, + mockApiError, +} from "testHelpers/entities"; +import { withProxyProvider } from "testHelpers/storybook"; +import TaskPage, { data } from "./TaskPage"; + +const meta: Meta = { + title: "pages/TaskPage", + component: TaskPage, + parameters: { + layout: "fullscreen", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockImplementation( + () => new Promise((res) => 1000 * 60 * 60), + ); + }, +}; + +export const LoadingError: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockRejectedValue( + mockApiError({ + message: "Failed to load task", + detail: "You don't have permission to access this resource.", + }), + ); + }, +}; + +export const WaitingOnBuild: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: MockStartingWorkspace, + }); + }, +}; + +export const WaitingOnStatus: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockWorkspace, + latest_app_status: null, + }, + }); + }, +}; + +export const FailedBuild: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: MockFailedWorkspace, + }); + }, +}; + +export const TerminatedBuild: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: MockStoppedWorkspace, + }); + }, +}; + +export const TerminatedBuildWithStatus: Story = { + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockStoppedWorkspace, + latest_app_status: MockWorkspaceAppStatus, + }, + }); + }, +}; + +export const Active: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + spyOn(data, "fetchTask").mockResolvedValue({ + prompt: "Create competitors page", + workspace: { + ...MockWorkspace, + latest_build: { + ...MockWorkspace.latest_build, + resources: [ + { + ...MockWorkspaceResource, + agents: [ + { + ...MockWorkspaceAgent, + apps: [ + { + ...MockWorkspaceApp, + id: "claude-code", + display_name: "Claude Code", + icon: "/icon/claude.svg", + url: `${window.location.protocol}/iframe.html?viewMode=story&id=pages-terminal--ready&args=&globals=`, + external: true, + statuses: [ + MockWorkspaceAppStatus, + { + ...MockWorkspaceAppStatus, + id: "2", + message: "Planning changes", + state: "working", + }, + ], + }, + { + ...MockWorkspaceApp, + id: "vscode", + display_name: "VSCode", + icon: "/icon/code.svg", + }, + ], + }, + ], + }, + ], + }, + latest_app_status: { + ...MockWorkspaceAppStatus, + app_id: "claude-code", + }, + }, + }); + }, +}; diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx new file mode 100644 index 0000000000000..692c99db2d63f --- /dev/null +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -0,0 +1,407 @@ +import { API } from "api/api"; +import { getErrorDetail, getErrorMessage } from "api/errors"; +import type { WorkspaceApp, WorkspaceStatus } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { ExternalImage } from "components/ExternalImage/ExternalImage"; +import { Loader } from "components/Loader/Loader"; +import { Margins } from "components/Margins/Margins"; +import { ScrollArea } from "components/ScrollArea/ScrollArea"; +import { Spinner } from "components/Spinner/Spinner"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; +import { useProxy } from "contexts/ProxyContext"; +import { ArrowLeftIcon, LayoutGridIcon, RotateCcwIcon } from "lucide-react"; +import { AppStatusIcon } from "modules/apps/AppStatusIcon"; +import { getAppHref } from "modules/apps/apps"; +import { useAppLink } from "modules/apps/useAppLink"; +import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; +import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; +import type React from "react"; +import { type FC, type ReactNode, useState } from "react"; +import { Helmet } from "react-helmet-async"; +import { useQuery } from "react-query"; +import { useParams } from "react-router-dom"; +import { Link as RouterLink } from "react-router-dom"; +import { cn } from "utils/cn"; +import { pageTitle } from "utils/page"; +import { timeFrom } from "utils/time"; + +const TaskPage = () => { + const { workspace: workspaceName, username } = useParams() as { + workspace: string; + username: string; + }; + const { + data: task, + error, + refetch, + } = useQuery({ + queryKey: ["tasks", username, workspaceName], + queryFn: () => data.fetchTask(username, workspaceName), + refetchInterval: 5_000, + }); + + if (error) { + return ( + <> + + {pageTitle("Error loading task")} + + +
+
+

+ {getErrorMessage(error, "Failed to load task")} +

+ + {getErrorDetail(error)} + +
+ + +
+
+
+ + ); + } + + if (!task) { + return ( + <> + + {pageTitle("Loading task")} + + + + ); + } + + let content: ReactNode = null; + const waitingStatuses: WorkspaceStatus[] = ["starting", "pending"]; + const terminatedStatuses: WorkspaceStatus[] = [ + "canceled", + "canceling", + "deleted", + "deleting", + "stopped", + "stopping", + ]; + + if (waitingStatuses.includes(task.workspace.latest_build.status)) { + content = ( +
+
+ +

+ Building your task +

+ + Your task is being built and will be ready soon + +
+
+ ); + } else if (task.workspace.latest_build.status === "failed") { + content = ( +
+
+

+ Task build failed +

+ + Please check the logs for more details. + + +
+
+ ); + } else if (terminatedStatuses.includes(task.workspace.latest_build.status)) { + content = ( + +
+ {task.workspace.latest_app_status && ( +
+ +
+ )} +
+
+

+ Task build terminated +

+ + So apps and previous statuses are not available + +
+
+
+
+ ); + } else if (!task.workspace.latest_app_status) { + content = ( +
+
+ +

+ Running your task +

+ + The status should be available soon + +
+
+ ); + } else { + const statuses = task.workspace.latest_build.resources + .flatMap((r) => r.agents) + .flatMap((a) => a?.apps) + .flatMap((a) => a?.statuses) + .filter((s) => !!s) + .sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + + content = ( +
+ + + +
+ ); + } + + return ( + <> + + {pageTitle(task.prompt)} + + +
+
+
+ + + + + + Back to tasks + + + +
+

{task.prompt}

+ + Created by {task.workspace.owner_name}{" "} + {timeFrom(new Date(task.workspace.created_at))} + +
+
+
+ + {content} +
+ + ); +}; + +export default TaskPage; + +type TaskAppsProps = { + task: Task; +}; + +const TaskApps: FC = ({ task }) => { + const agents = task.workspace.latest_build.resources + .flatMap((r) => r.agents) + .filter((a) => !!a); + + const apps = agents.flatMap((a) => a?.apps).filter((a) => !!a); + + const [activeAppId, setActiveAppId] = useState(() => { + const appId = task.workspace.latest_app_status?.app_id; + if (!appId) { + throw new Error("No active app found in task"); + } + return appId; + }); + + const activeApp = apps.find((app) => app.id === activeAppId); + if (!activeApp) { + throw new Error(`Active app with ID ${activeAppId} not found in task`); + } + + const agent = agents.find((a) => + a.apps.some((app) => app.id === activeAppId), + ); + if (!agent) { + throw new Error(`Agent for app ${activeAppId} not found in task workspace`); + } + + const { proxy } = useProxy(); + const [iframeSrc, setIframeSrc] = useState(() => { + const src = getAppHref(activeApp, { + agent, + workspace: task.workspace, + path: proxy.preferredPathAppURL, + host: proxy.preferredWildcardHostname, + }); + return src; + }); + + return ( +
+
+ {apps.map((app) => ( + { + if (app.external) { + return; + } + + e.preventDefault(); + setActiveAppId(app.id); + setIframeSrc(e.currentTarget.href); + }} + /> + ))} +
+ +
+