diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 4b308a9468b6f..ac9bcc2153668 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -69,6 +69,7 @@ "most_recently_seen": null } }, + "latest_app_status": null, "outdated": false, "name": "test-workspace", "autostart_schedule": "CRON_TZ=US/Central 30 9 * * 1-5", diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a543e5b716e8f..79c597be5afe9 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8057,6 +8057,45 @@ const docTemplate = `{ } } }, + "/workspaceagents/me/app-status": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Patch workspace agent app status", + "operationId": "patch-workspace-agent-app-status", + "parameters": [ + { + "description": "app status", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PatchAppStatus" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/workspaceagents/me/external-auth": { "get": { "security": [ @@ -10170,6 +10209,29 @@ const docTemplate = `{ } } }, + "agentsdk.PatchAppStatus": { + "type": "object", + "properties": { + "app_slug": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "message": { + "type": "string" + }, + "needs_user_attention": { + "type": "boolean" + }, + "state": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatusState" + }, + "uri": { + "type": "string" + } + } + }, "agentsdk.PatchLogs": { "type": "object", "properties": { @@ -16176,6 +16238,9 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "latest_app_status": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatus" + }, "latest_build": { "$ref": "#/definitions/codersdk.WorkspaceBuild" }, @@ -16775,6 +16840,13 @@ const docTemplate = `{ "description": "Slug is a unique identifier within the agent.", "type": "string" }, + "statuses": { + "description": "Statuses is a list of statuses for the app.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatus" + } + }, "subdomain": { "description": "Subdomain denotes whether the app should be accessed via a path on the\n` + "`" + `coder server` + "`" + ` or via a hostname-based dev URL. If this is set to true\nand there is no app wildcard configured on the server, the app will not\nbe accessible in the UI.", "type": "boolean" @@ -16828,6 +16900,61 @@ const docTemplate = `{ "WorkspaceAppSharingLevelPublic" ] }, + "codersdk.WorkspaceAppStatus": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "format": "uuid" + }, + "app_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "icon": { + "description": "Icon is an external URL to an icon that will be rendered in the UI.", + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "message": { + "type": "string" + }, + "needs_user_attention": { + "type": "boolean" + }, + "state": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatusState" + }, + "uri": { + "description": "URI is the URI of the resource that the status is for.\ne.g. https://github.com/org/repo/pull/123\ne.g. file:///path/to/file", + "type": "string" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "codersdk.WorkspaceAppStatusState": { + "type": "string", + "enum": [ + "working", + "complete", + "failure" + ], + "x-enum-varnames": [ + "WorkspaceAppStatusStateWorking", + "WorkspaceAppStatusStateComplete", + "WorkspaceAppStatusStateFailure" + ] + }, "codersdk.WorkspaceBuild": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 586f63e5c6d6f..b4e182cc5e181 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7122,6 +7122,39 @@ } } }, + "/workspaceagents/me/app-status": { + "patch": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Patch workspace agent app status", + "operationId": "patch-workspace-agent-app-status", + "parameters": [ + { + "description": "app status", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/agentsdk.PatchAppStatus" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.Response" + } + } + } + } + }, "/workspaceagents/me/external-auth": { "get": { "security": [ @@ -9013,6 +9046,29 @@ } } }, + "agentsdk.PatchAppStatus": { + "type": "object", + "properties": { + "app_slug": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "message": { + "type": "string" + }, + "needs_user_attention": { + "type": "boolean" + }, + "state": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatusState" + }, + "uri": { + "type": "string" + } + } + }, "agentsdk.PatchLogs": { "type": "object", "properties": { @@ -14734,6 +14790,9 @@ "type": "string", "format": "date-time" }, + "latest_app_status": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatus" + }, "latest_build": { "$ref": "#/definitions/codersdk.WorkspaceBuild" }, @@ -15307,6 +15366,13 @@ "description": "Slug is a unique identifier within the agent.", "type": "string" }, + "statuses": { + "description": "Statuses is a list of statuses for the app.", + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatus" + } + }, "subdomain": { "description": "Subdomain denotes whether the app should be accessed via a path on the\n`coder server` or via a hostname-based dev URL. If this is set to true\nand there is no app wildcard configured on the server, the app will not\nbe accessible in the UI.", "type": "boolean" @@ -15348,6 +15414,57 @@ "WorkspaceAppSharingLevelPublic" ] }, + "codersdk.WorkspaceAppStatus": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "format": "uuid" + }, + "app_id": { + "type": "string", + "format": "uuid" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "icon": { + "description": "Icon is an external URL to an icon that will be rendered in the UI.", + "type": "string" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "message": { + "type": "string" + }, + "needs_user_attention": { + "type": "boolean" + }, + "state": { + "$ref": "#/definitions/codersdk.WorkspaceAppStatusState" + }, + "uri": { + "description": "URI is the URI of the resource that the status is for.\ne.g. https://github.com/org/repo/pull/123\ne.g. file:///path/to/file", + "type": "string" + }, + "workspace_id": { + "type": "string", + "format": "uuid" + } + } + }, + "codersdk.WorkspaceAppStatusState": { + "type": "string", + "enum": ["working", "complete", "failure"], + "x-enum-varnames": [ + "WorkspaceAppStatusStateWorking", + "WorkspaceAppStatusStateComplete", + "WorkspaceAppStatusStateFailure" + ] + }, "codersdk.WorkspaceBuild": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index f68ddeadb6e6b..c9a0f741afd1f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1228,6 +1228,7 @@ func New(options *Options) *API { })) r.Get("/rpc", api.workspaceAgentRPC) r.Patch("/logs", api.patchWorkspaceAgentLogs) + r.Patch("/app-status", api.patchWorkspaceAgentAppStatus) // Deprecated: Required to support legacy agents r.Get("/gitauth", api.workspaceAgentsGitAuth) r.Get("/external-auth", api.workspaceAgentsExternalAuth) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 41691c5a1d3f1..89676b1d94d46 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -487,7 +487,7 @@ func AppSubdomain(dbApp database.WorkspaceApp, agentName, workspaceName, ownerNa }.String() } -func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace) []codersdk.WorkspaceApp { +func Apps(dbApps []database.WorkspaceApp, statuses []database.WorkspaceAppStatus, agent database.WorkspaceAgent, ownerName string, workspace database.Workspace) []codersdk.WorkspaceApp { sort.Slice(dbApps, func(i, j int) bool { if dbApps[i].DisplayOrder != dbApps[j].DisplayOrder { return dbApps[i].DisplayOrder < dbApps[j].DisplayOrder @@ -498,8 +498,14 @@ func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerNa return dbApps[i].Slug < dbApps[j].Slug }) + statusesByAppID := map[uuid.UUID][]database.WorkspaceAppStatus{} + for _, status := range statuses { + statusesByAppID[status.AppID] = append(statusesByAppID[status.AppID], status) + } + apps := make([]codersdk.WorkspaceApp, 0) for _, dbApp := range dbApps { + statuses := statusesByAppID[dbApp.ID] apps = append(apps, codersdk.WorkspaceApp{ ID: dbApp.ID, URL: dbApp.Url.String, @@ -516,14 +522,33 @@ func Apps(dbApps []database.WorkspaceApp, agent database.WorkspaceAgent, ownerNa Interval: dbApp.HealthcheckInterval, Threshold: dbApp.HealthcheckThreshold, }, - Health: codersdk.WorkspaceAppHealth(dbApp.Health), - Hidden: dbApp.Hidden, - OpenIn: codersdk.WorkspaceAppOpenIn(dbApp.OpenIn), + Health: codersdk.WorkspaceAppHealth(dbApp.Health), + Hidden: dbApp.Hidden, + OpenIn: codersdk.WorkspaceAppOpenIn(dbApp.OpenIn), + Statuses: WorkspaceAppStatuses(statuses), }) } return apps } +func WorkspaceAppStatuses(statuses []database.WorkspaceAppStatus) []codersdk.WorkspaceAppStatus { + return List(statuses, WorkspaceAppStatus) +} + +func WorkspaceAppStatus(status database.WorkspaceAppStatus) codersdk.WorkspaceAppStatus { + return codersdk.WorkspaceAppStatus{ + ID: status.ID, + CreatedAt: status.CreatedAt, + AgentID: status.AgentID, + AppID: status.AppID, + NeedsUserAttention: status.NeedsUserAttention, + URI: status.Uri.String, + Icon: status.Icon.String, + Message: status.Message, + State: codersdk.WorkspaceAppStatusState(status.State), + } +} + func ProvisionerDaemon(dbDaemon database.ProvisionerDaemon) codersdk.ProvisionerDaemon { result := codersdk.ProvisionerDaemon{ ID: dbDaemon.ID, diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 87778c66d3dab..7ab078d32ad4f 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1840,6 +1840,13 @@ func (q *querier) GetLatestCryptoKeyByFeature(ctx context.Context, feature datab return q.db.GetLatestCryptoKeyByFeature(ctx, feature) } +func (q *querier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids) +} + func (q *querier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { if _, err := q.GetWorkspaceByID(ctx, workspaceID); err != nil { return database.WorkspaceBuild{}, err @@ -2854,6 +2861,13 @@ func (q *querier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg datab return q.db.GetWorkspaceAppByAgentIDAndSlug(ctx, arg) } +func (q *querier) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetWorkspaceAppStatusesByAppIDs(ctx, ids) +} + func (q *querier) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.WorkspaceApp, error) { if _, err := q.GetWorkspaceByAgentID(ctx, agentID); err != nil { return nil, err @@ -3547,6 +3561,13 @@ func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.Inse return q.db.InsertWorkspaceAppStats(ctx, arg) } +func (q *querier) InsertWorkspaceAppStatus(ctx context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { + return database.WorkspaceAppStatus{}, err + } + return q.db.InsertWorkspaceAppStatus(ctx, arg) +} + func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) if err != nil { diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 70c2a33443a16..cdc1c8e9ca197 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -3706,6 +3706,12 @@ func (s *MethodTestSuite) TestSystemFunctions() { LoginType: database.LoginTypeGithub, }).Asserts(rbac.ResourceSystem, policy.ActionUpdate).Returns(l) })) + s.Run("GetLatestWorkspaceAppStatusesByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) { + check.Args([]uuid.UUID{}).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceAppStatusesByAppIDs", s.Subtest(func(db database.Store, check *expects) { + check.Args([]uuid.UUID{}).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) s.Run("GetLatestWorkspaceBuildsByWorkspaceIDs", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) ws := dbgen.Workspace(s.T(), db, database.WorkspaceTable{}) @@ -4135,6 +4141,13 @@ func (s *MethodTestSuite) TestSystemFunctions() { Options: json.RawMessage("{}"), }).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) + s.Run("InsertWorkspaceAppStatus", s.Subtest(func(db database.Store, check *expects) { + dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) + check.Args(database.InsertWorkspaceAppStatusParams{ + ID: uuid.New(), + State: "working", + }).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) s.Run("InsertWorkspaceResource", s.Subtest(func(db database.Store, check *expects) { dbtestutil.DisableForeignKeysAndTriggers(s.T(), db) check.Args(database.InsertWorkspaceResourceParams{ diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 46f2de5a5820e..87275b1051efe 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -259,6 +259,7 @@ type data struct { workspaceAgentVolumeResourceMonitors []database.WorkspaceAgentVolumeResourceMonitor workspaceAgentDevcontainers []database.WorkspaceAgentDevcontainer workspaceApps []database.WorkspaceApp + workspaceAppStatuses []database.WorkspaceAppStatus workspaceAppAuditSessions []database.WorkspaceAppAuditSession workspaceAppStatsLastInsertID int64 workspaceAppStats []database.WorkspaceAppStat @@ -3697,6 +3698,34 @@ func (q *FakeQuerier) GetLatestCryptoKeyByFeature(_ context.Context, feature dat return latestKey, nil } +func (q *FakeQuerier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + // Map to track latest status per workspace ID + latestByWorkspace := make(map[uuid.UUID]database.WorkspaceAppStatus) + + // Find latest status for each workspace ID + for _, appStatus := range q.workspaceAppStatuses { + if !slices.Contains(ids, appStatus.WorkspaceID) { + continue + } + + current, exists := latestByWorkspace[appStatus.WorkspaceID] + if !exists || appStatus.CreatedAt.After(current.CreatedAt) { + latestByWorkspace[appStatus.WorkspaceID] = appStatus + } + } + + // Convert map to slice + appStatuses := make([]database.WorkspaceAppStatus, 0, len(latestByWorkspace)) + for _, status := range latestByWorkspace { + appStatuses = append(appStatuses, status) + } + + return appStatuses, nil +} + func (q *FakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -7488,6 +7517,21 @@ func (q *FakeQuerier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg d return q.getWorkspaceAppByAgentIDAndSlugNoLock(ctx, arg) } +func (q *FakeQuerier) GetWorkspaceAppStatusesByAppIDs(_ context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + statuses := make([]database.WorkspaceAppStatus, 0) + for _, status := range q.workspaceAppStatuses { + for _, id := range ids { + if status.AppID == id { + statuses = append(statuses, status) + } + } + } + return statuses, nil +} + func (q *FakeQuerier) GetWorkspaceAppsByAgentID(_ context.Context, id uuid.UUID) ([]database.WorkspaceApp, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -9584,6 +9628,31 @@ InsertWorkspaceAppStatsLoop: return nil } +func (q *FakeQuerier) InsertWorkspaceAppStatus(_ context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) { + err := validateDatabaseType(arg) + if err != nil { + return database.WorkspaceAppStatus{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + status := database.WorkspaceAppStatus{ + ID: arg.ID, + CreatedAt: arg.CreatedAt, + WorkspaceID: arg.WorkspaceID, + AgentID: arg.AgentID, + AppID: arg.AppID, + NeedsUserAttention: arg.NeedsUserAttention, + State: arg.State, + Message: arg.Message, + Uri: arg.Uri, + Icon: arg.Icon, + } + q.workspaceAppStatuses = append(q.workspaceAppStatuses, status) + return status, nil +} + func (q *FakeQuerier) InsertWorkspaceBuild(_ context.Context, arg database.InsertWorkspaceBuildParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index 05c0418c77acd..91cdf641c3446 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -858,6 +858,13 @@ func (m queryMetricsStore) GetLatestCryptoKeyByFeature(ctx context.Context, feat return r0, r1 } +func (m queryMetricsStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + start := time.Now() + r0, r1 := m.s.GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids) + m.queryLatencies.WithLabelValues("GetLatestWorkspaceAppStatusesByWorkspaceIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { start := time.Now() build, err := m.s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspaceID) @@ -1670,6 +1677,13 @@ func (m queryMetricsStore) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, return app, err } +func (m queryMetricsStore) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceAppStatusesByAppIDs(ctx, ids) + m.queryLatencies.WithLabelValues("GetWorkspaceAppStatusesByAppIDs").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.WorkspaceApp, error) { start := time.Now() apps, err := m.s.GetWorkspaceAppsByAgentID(ctx, agentID) @@ -2265,6 +2279,13 @@ func (m queryMetricsStore) InsertWorkspaceAppStats(ctx context.Context, arg data return r0 } +func (m queryMetricsStore) InsertWorkspaceAppStatus(ctx context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) { + start := time.Now() + r0, r1 := m.s.InsertWorkspaceAppStatus(ctx, arg) + m.queryLatencies.WithLabelValues("InsertWorkspaceAppStatus").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m queryMetricsStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { start := time.Now() err := m.s.InsertWorkspaceBuild(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index c5a5db20a9e90..109462e5f1996 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1729,6 +1729,21 @@ func (mr *MockStoreMockRecorder) GetLatestCryptoKeyByFeature(ctx, feature any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestCryptoKeyByFeature", reflect.TypeOf((*MockStore)(nil).GetLatestCryptoKeyByFeature), ctx, feature) } +// GetLatestWorkspaceAppStatusesByWorkspaceIDs mocks base method. +func (m *MockStore) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLatestWorkspaceAppStatusesByWorkspaceIDs", ctx, ids) + ret0, _ := ret[0].([]database.WorkspaceAppStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLatestWorkspaceAppStatusesByWorkspaceIDs indicates an expected call of GetLatestWorkspaceAppStatusesByWorkspaceIDs. +func (mr *MockStoreMockRecorder) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx, ids any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLatestWorkspaceAppStatusesByWorkspaceIDs", reflect.TypeOf((*MockStore)(nil).GetLatestWorkspaceAppStatusesByWorkspaceIDs), ctx, ids) +} + // GetLatestWorkspaceBuildByWorkspaceID mocks base method. func (m *MockStore) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { m.ctrl.T.Helper() @@ -3499,6 +3514,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceAppByAgentIDAndSlug(ctx, arg any) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAppByAgentIDAndSlug", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAppByAgentIDAndSlug), ctx, arg) } +// GetWorkspaceAppStatusesByAppIDs mocks base method. +func (m *MockStore) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]database.WorkspaceAppStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceAppStatusesByAppIDs", ctx, ids) + ret0, _ := ret[0].([]database.WorkspaceAppStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceAppStatusesByAppIDs indicates an expected call of GetWorkspaceAppStatusesByAppIDs. +func (mr *MockStoreMockRecorder) GetWorkspaceAppStatusesByAppIDs(ctx, ids any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceAppStatusesByAppIDs", reflect.TypeOf((*MockStore)(nil).GetWorkspaceAppStatusesByAppIDs), ctx, ids) +} + // GetWorkspaceAppsByAgentID mocks base method. func (m *MockStore) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]database.WorkspaceApp, error) { m.ctrl.T.Helper() @@ -4776,6 +4806,21 @@ func (mr *MockStoreMockRecorder) InsertWorkspaceAppStats(ctx, arg any) *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAppStats", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAppStats), ctx, arg) } +// InsertWorkspaceAppStatus mocks base method. +func (m *MockStore) InsertWorkspaceAppStatus(ctx context.Context, arg database.InsertWorkspaceAppStatusParams) (database.WorkspaceAppStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertWorkspaceAppStatus", ctx, arg) + ret0, _ := ret[0].(database.WorkspaceAppStatus) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InsertWorkspaceAppStatus indicates an expected call of InsertWorkspaceAppStatus. +func (mr *MockStoreMockRecorder) InsertWorkspaceAppStatus(ctx, arg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertWorkspaceAppStatus", reflect.TypeOf((*MockStore)(nil).InsertWorkspaceAppStatus), ctx, arg) +} + // InsertWorkspaceBuild mocks base method. func (m *MockStore) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index b7908a8880107..b4207c41deff2 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -293,6 +293,12 @@ CREATE TYPE workspace_app_open_in AS ENUM ( 'slim-window' ); +CREATE TYPE workspace_app_status_state AS ENUM ( + 'working', + 'complete', + 'failure' +); + CREATE TYPE workspace_transition AS ENUM ( 'start', 'stop', @@ -1896,6 +1902,19 @@ CREATE SEQUENCE workspace_app_stats_id_seq ALTER SEQUENCE workspace_app_stats_id_seq OWNED BY workspace_app_stats.id; +CREATE TABLE workspace_app_statuses ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + agent_id uuid NOT NULL, + app_id uuid NOT NULL, + workspace_id uuid NOT NULL, + state workspace_app_status_state NOT NULL, + needs_user_attention boolean NOT NULL, + message text NOT NULL, + uri text, + icon text +); + CREATE TABLE workspace_apps ( id uuid NOT NULL, created_at timestamp with time zone NOT NULL, @@ -2359,6 +2378,9 @@ ALTER TABLE ONLY workspace_app_stats ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); +ALTER TABLE ONLY workspace_app_statuses + ADD CONSTRAINT workspace_app_statuses_pkey PRIMARY KEY (id); + ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); @@ -2451,6 +2473,8 @@ CREATE UNIQUE INDEX idx_users_email ON users USING btree (email) WHERE (deleted CREATE UNIQUE INDEX idx_users_username ON users USING btree (username) WHERE (deleted = false); +CREATE INDEX idx_workspace_app_statuses_workspace_id_created_at ON workspace_app_statuses USING btree (workspace_id, created_at DESC); + CREATE UNIQUE INDEX notification_messages_dedupe_hash_idx ON notification_messages USING btree (dedupe_hash); CREATE UNIQUE INDEX organizations_single_default_org ON organizations USING btree (is_default) WHERE (is_default = true); @@ -2802,6 +2826,15 @@ ALTER TABLE ONLY workspace_app_stats ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); +ALTER TABLE ONLY workspace_app_statuses + ADD CONSTRAINT workspace_app_statuses_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); + +ALTER TABLE ONLY workspace_app_statuses + ADD CONSTRAINT workspace_app_statuses_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id); + +ALTER TABLE ONLY workspace_app_statuses + ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); + ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; diff --git a/coderd/database/foreign_key_constraint.go b/coderd/database/foreign_key_constraint.go index 7dab8519a897c..3f5ce963e6fdb 100644 --- a/coderd/database/foreign_key_constraint.go +++ b/coderd/database/foreign_key_constraint.go @@ -73,6 +73,9 @@ const ( ForeignKeyWorkspaceAppStatsAgentID ForeignKeyConstraint = "workspace_app_stats_agent_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); ForeignKeyWorkspaceAppStatsUserID ForeignKeyConstraint = "workspace_app_stats_user_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); ForeignKeyWorkspaceAppStatsWorkspaceID ForeignKeyConstraint = "workspace_app_stats_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); + ForeignKeyWorkspaceAppStatusesAgentID ForeignKeyConstraint = "workspace_app_statuses_agent_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id); + ForeignKeyWorkspaceAppStatusesAppID ForeignKeyConstraint = "workspace_app_statuses_app_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_app_id_fkey FOREIGN KEY (app_id) REFERENCES workspace_apps(id); + ForeignKeyWorkspaceAppStatusesWorkspaceID ForeignKeyConstraint = "workspace_app_statuses_workspace_id_fkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_workspace_id_fkey FOREIGN KEY (workspace_id) REFERENCES workspaces(id); ForeignKeyWorkspaceAppsAgentID ForeignKeyConstraint = "workspace_apps_agent_id_fkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildParametersWorkspaceBuildID ForeignKeyConstraint = "workspace_build_parameters_workspace_build_id_fkey" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_fkey FOREIGN KEY (workspace_build_id) REFERENCES workspace_builds(id) ON DELETE CASCADE; ForeignKeyWorkspaceBuildsJobID ForeignKeyConstraint = "workspace_builds_job_id_fkey" // ALTER TABLE ONLY workspace_builds ADD CONSTRAINT workspace_builds_job_id_fkey FOREIGN KEY (job_id) REFERENCES provisioner_jobs(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000313_workspace_app_statuses.down.sql b/coderd/database/migrations/000313_workspace_app_statuses.down.sql new file mode 100644 index 0000000000000..59d38cc8bc21c --- /dev/null +++ b/coderd/database/migrations/000313_workspace_app_statuses.down.sql @@ -0,0 +1,3 @@ +DROP TABLE workspace_app_statuses; + +DROP TYPE workspace_app_status_state; diff --git a/coderd/database/migrations/000313_workspace_app_statuses.up.sql b/coderd/database/migrations/000313_workspace_app_statuses.up.sql new file mode 100644 index 0000000000000..4bbeb64efc231 --- /dev/null +++ b/coderd/database/migrations/000313_workspace_app_statuses.up.sql @@ -0,0 +1,28 @@ +CREATE TYPE workspace_app_status_state AS ENUM ('working', 'complete', 'failure'); + +-- Workspace app statuses allow agents to report statuses per-app in the UI. +CREATE TABLE workspace_app_statuses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- The agent that the status is for. + agent_id UUID NOT NULL REFERENCES workspace_agents(id), + -- The slug of the app that the status is for. This will be used + -- to reference the app in the UI - with an icon. + app_id UUID NOT NULL REFERENCES workspace_apps(id), + -- workspace_id is the workspace that the status is for. + workspace_id UUID NOT NULL REFERENCES workspaces(id), + -- The status determines how the status is displayed in the UI. + state workspace_app_status_state NOT NULL, + -- Whether the status needs user attention. + needs_user_attention BOOLEAN NOT NULL, + -- The message is the main text that will be displayed in the UI. + message TEXT NOT NULL, + -- The URI of the resource that the status is for. + -- e.g. https://github.com/org/repo/pull/123 + -- e.g. file:///path/to/file + uri TEXT, + -- Icon is an external URL to an icon that will be rendered in the UI. + icon TEXT +); + +CREATE INDEX idx_workspace_app_statuses_workspace_id_created_at ON workspace_app_statuses(workspace_id, created_at DESC); diff --git a/coderd/database/migrations/testdata/fixtures/000313_workspace_app_statuses.up.sql b/coderd/database/migrations/testdata/fixtures/000313_workspace_app_statuses.up.sql new file mode 100644 index 0000000000000..c36f5c66c3dd0 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000313_workspace_app_statuses.up.sql @@ -0,0 +1,19 @@ +INSERT INTO workspace_app_statuses ( + id, + created_at, + agent_id, + app_id, + workspace_id, + state, + needs_user_attention, + message +) VALUES ( + gen_random_uuid(), + NOW(), + '7a1ce5f8-8d00-431c-ad1b-97a846512804', + '36b65d0c-042b-4653-863a-655ee739861c', + '3a9a1feb-e89d-457c-9d53-ac751b198ebe', + 'working', + false, + 'Creating SQL queries for test data!' +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 634cb6b59a41a..4339191f7afa2 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2414,6 +2414,67 @@ func AllWorkspaceAppOpenInValues() []WorkspaceAppOpenIn { } } +type WorkspaceAppStatusState string + +const ( + WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working" + WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete" + WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure" +) + +func (e *WorkspaceAppStatusState) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = WorkspaceAppStatusState(s) + case string: + *e = WorkspaceAppStatusState(s) + default: + return fmt.Errorf("unsupported scan type for WorkspaceAppStatusState: %T", src) + } + return nil +} + +type NullWorkspaceAppStatusState struct { + WorkspaceAppStatusState WorkspaceAppStatusState `json:"workspace_app_status_state"` + Valid bool `json:"valid"` // Valid is true if WorkspaceAppStatusState is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullWorkspaceAppStatusState) Scan(value interface{}) error { + if value == nil { + ns.WorkspaceAppStatusState, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.WorkspaceAppStatusState.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullWorkspaceAppStatusState) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.WorkspaceAppStatusState), nil +} + +func (e WorkspaceAppStatusState) Valid() bool { + switch e { + case WorkspaceAppStatusStateWorking, + WorkspaceAppStatusStateComplete, + WorkspaceAppStatusStateFailure: + return true + } + return false +} + +func AllWorkspaceAppStatusStateValues() []WorkspaceAppStatusState { + return []WorkspaceAppStatusState{ + WorkspaceAppStatusStateWorking, + WorkspaceAppStatusStateComplete, + WorkspaceAppStatusStateFailure, + } +} + type WorkspaceTransition string const ( @@ -3515,6 +3576,19 @@ type WorkspaceAppStat struct { Requests int32 `db:"requests" json:"requests"` } +type WorkspaceAppStatus struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + State WorkspaceAppStatusState `db:"state" json:"state"` + NeedsUserAttention bool `db:"needs_user_attention" json:"needs_user_attention"` + Message string `db:"message" json:"message"` + Uri sql.NullString `db:"uri" json:"uri"` + Icon sql.NullString `db:"icon" json:"icon"` +} + // Joins in the username + avatar url of the initiated by user. type WorkspaceBuild struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 892582c1201e5..59b53ac5950d8 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -198,6 +198,7 @@ type sqlcQuerier interface { GetJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg GetJFrogXrayScanByWorkspaceAndAgentIDParams) (JfrogXrayScan, error) GetLastUpdateCheck(ctx context.Context) (string, error) GetLatestCryptoKeyByFeature(ctx context.Context, feature CryptoKeyFeature) (CryptoKey, error) + GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) GetLatestWorkspaceBuildsByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceBuild, error) @@ -369,6 +370,7 @@ type sqlcQuerier interface { GetWorkspaceAgentsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceAgent, error) GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceAgent, error) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg GetWorkspaceAppByAgentIDAndSlugParams) (WorkspaceApp, error) + GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) GetWorkspaceAppsByAgentID(ctx context.Context, agentID uuid.UUID) ([]WorkspaceApp, error) GetWorkspaceAppsByAgentIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceApp, error) GetWorkspaceAppsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceApp, error) @@ -474,6 +476,7 @@ type sqlcQuerier interface { InsertWorkspaceAgentStats(ctx context.Context, arg InsertWorkspaceAgentStatsParams) error InsertWorkspaceApp(ctx context.Context, arg InsertWorkspaceAppParams) (WorkspaceApp, error) InsertWorkspaceAppStats(ctx context.Context, arg InsertWorkspaceAppStatsParams) error + InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) InsertWorkspaceBuild(ctx context.Context, arg InsertWorkspaceBuildParams) error InsertWorkspaceBuildParameters(ctx context.Context, arg InsertWorkspaceBuildParametersParams) error InsertWorkspaceModule(ctx context.Context, arg InsertWorkspaceModuleParams) (WorkspaceModule, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 221c9f2c51df6..59d717531324a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15108,6 +15108,48 @@ func (q *sqlQuerier) UpsertWorkspaceAppAuditSession(ctx context.Context, arg Ups return new_or_stale, err } +const getLatestWorkspaceAppStatusesByWorkspaceIDs = `-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many +SELECT DISTINCT ON (workspace_id) + id, created_at, agent_id, app_id, workspace_id, state, needs_user_attention, message, uri, icon +FROM workspace_app_statuses +WHERE workspace_id = ANY($1 :: uuid[]) +ORDER BY workspace_id, created_at DESC +` + +func (q *sqlQuerier) GetLatestWorkspaceAppStatusesByWorkspaceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) { + rows, err := q.db.QueryContext(ctx, getLatestWorkspaceAppStatusesByWorkspaceIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAppStatus + for rows.Next() { + var i WorkspaceAppStatus + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.NeedsUserAttention, + &i.Message, + &i.Uri, + &i.Icon, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceAppByAgentIDAndSlug = `-- name: GetWorkspaceAppByAgentIDAndSlug :one SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in FROM workspace_apps WHERE agent_id = $1 AND slug = $2 ` @@ -15143,6 +15185,44 @@ func (q *sqlQuerier) GetWorkspaceAppByAgentIDAndSlug(ctx context.Context, arg Ge return i, err } +const getWorkspaceAppStatusesByAppIDs = `-- name: GetWorkspaceAppStatusesByAppIDs :many +SELECT id, created_at, agent_id, app_id, workspace_id, state, needs_user_attention, message, uri, icon FROM workspace_app_statuses WHERE app_id = ANY($1 :: uuid [ ]) +` + +func (q *sqlQuerier) GetWorkspaceAppStatusesByAppIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceAppStatus, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceAppStatusesByAppIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceAppStatus + for rows.Next() { + var i WorkspaceAppStatus + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.NeedsUserAttention, + &i.Message, + &i.Uri, + &i.Icon, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceAppsByAgentID = `-- name: GetWorkspaceAppsByAgentID :many SELECT id, created_at, agent_id, display_name, icon, command, url, healthcheck_url, healthcheck_interval, healthcheck_threshold, health, subdomain, sharing_level, slug, external, display_order, hidden, open_in FROM workspace_apps WHERE agent_id = $1 ORDER BY slug ASC ` @@ -15373,6 +15453,54 @@ func (q *sqlQuerier) InsertWorkspaceApp(ctx context.Context, arg InsertWorkspace return i, err } +const insertWorkspaceAppStatus = `-- name: InsertWorkspaceAppStatus :one +INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, needs_user_attention, uri, icon) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, created_at, agent_id, app_id, workspace_id, state, needs_user_attention, message, uri, icon +` + +type InsertWorkspaceAppStatusParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + AppID uuid.UUID `db:"app_id" json:"app_id"` + State WorkspaceAppStatusState `db:"state" json:"state"` + Message string `db:"message" json:"message"` + NeedsUserAttention bool `db:"needs_user_attention" json:"needs_user_attention"` + Uri sql.NullString `db:"uri" json:"uri"` + Icon sql.NullString `db:"icon" json:"icon"` +} + +func (q *sqlQuerier) InsertWorkspaceAppStatus(ctx context.Context, arg InsertWorkspaceAppStatusParams) (WorkspaceAppStatus, error) { + row := q.db.QueryRowContext(ctx, insertWorkspaceAppStatus, + arg.ID, + arg.CreatedAt, + arg.WorkspaceID, + arg.AgentID, + arg.AppID, + arg.State, + arg.Message, + arg.NeedsUserAttention, + arg.Uri, + arg.Icon, + ) + var i WorkspaceAppStatus + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.AgentID, + &i.AppID, + &i.WorkspaceID, + &i.State, + &i.NeedsUserAttention, + &i.Message, + &i.Uri, + &i.Icon, + ) + return i, err +} + const updateWorkspaceAppHealthByID = `-- name: UpdateWorkspaceAppHealthByID :exec UPDATE workspace_apps diff --git a/coderd/database/queries/workspaceapps.sql b/coderd/database/queries/workspaceapps.sql index 2f431268a4c41..e402ee1402922 100644 --- a/coderd/database/queries/workspaceapps.sql +++ b/coderd/database/queries/workspaceapps.sql @@ -42,3 +42,18 @@ SET health = $2 WHERE id = $1; + +-- name: InsertWorkspaceAppStatus :one +INSERT INTO workspace_app_statuses (id, created_at, workspace_id, agent_id, app_id, state, message, needs_user_attention, uri, icon) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING *; + +-- name: GetWorkspaceAppStatusesByAppIDs :many +SELECT * FROM workspace_app_statuses WHERE app_id = ANY(@ids :: uuid [ ]); + +-- name: GetLatestWorkspaceAppStatusesByWorkspaceIDs :many +SELECT DISTINCT ON (workspace_id) + * +FROM workspace_app_statuses +WHERE workspace_id = ANY(@ids :: uuid[]) +ORDER BY workspace_id, created_at DESC; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 9318e1af1678b..d9f8ce275bfdf 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -86,6 +86,7 @@ const ( UniqueWorkspaceAppAuditSessionsPkey UniqueConstraint = "workspace_app_audit_sessions_pkey" // ALTER TABLE ONLY workspace_app_audit_sessions ADD CONSTRAINT workspace_app_audit_sessions_pkey PRIMARY KEY (id); UniqueWorkspaceAppStatsPkey UniqueConstraint = "workspace_app_stats_pkey" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_pkey PRIMARY KEY (id); UniqueWorkspaceAppStatsUserIDAgentIDSessionIDKey UniqueConstraint = "workspace_app_stats_user_id_agent_id_session_id_key" // ALTER TABLE ONLY workspace_app_stats ADD CONSTRAINT workspace_app_stats_user_id_agent_id_session_id_key UNIQUE (user_id, agent_id, session_id); + UniqueWorkspaceAppStatusesPkey UniqueConstraint = "workspace_app_statuses_pkey" // ALTER TABLE ONLY workspace_app_statuses ADD CONSTRAINT workspace_app_statuses_pkey PRIMARY KEY (id); UniqueWorkspaceAppsAgentIDSlugIndex UniqueConstraint = "workspace_apps_agent_id_slug_idx" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_agent_id_slug_idx UNIQUE (agent_id, slug); UniqueWorkspaceAppsPkey UniqueConstraint = "workspace_apps_pkey" // ALTER TABLE ONLY workspace_apps ADD CONSTRAINT workspace_apps_pkey PRIMARY KEY (id); UniqueWorkspaceBuildParametersWorkspaceBuildIDNameKey UniqueConstraint = "workspace_build_parameters_workspace_build_id_name_key" // ALTER TABLE ONLY workspace_build_parameters ADD CONSTRAINT workspace_build_parameters_workspace_build_id_name_key UNIQUE (workspace_build_id, name); diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 975803cb5e1d1..3ed880d40970f 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -93,6 +93,20 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { return } + appIDs := []uuid.UUID{} + for _, app := range dbApps { + appIDs = append(appIDs, app.ID) + } + // nolint:gocritic // This is a system restricted operation. + statuses, err := api.Database.GetWorkspaceAppStatusesByAppIDs(dbauthz.AsSystemRestricted(ctx), appIDs) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace app statuses.", + Detail: err.Error(), + }) + return + } + resource, err := api.Database.GetWorkspaceResourceByID(ctx, workspaceAgent.ResourceID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -127,7 +141,7 @@ func (api *API) workspaceAgent(rw http.ResponseWriter, r *http.Request) { } apiAgent, err := db2sdk.WorkspaceAgent( - api.DERPMap(), *api.TailnetCoordinator.Load(), workspaceAgent, db2sdk.Apps(dbApps, workspaceAgent, owner.Username, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, + api.DERPMap(), *api.TailnetCoordinator.Load(), workspaceAgent, db2sdk.Apps(dbApps, statuses, workspaceAgent, owner.Username, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), ) if err != nil { @@ -300,6 +314,81 @@ func (api *API) patchWorkspaceAgentLogs(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, nil) } +// @Summary Patch workspace agent app status +// @ID patch-workspace-agent-app-status +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Agents +// @Param request body agentsdk.PatchAppStatus true "app status" +// @Success 200 {object} codersdk.Response +// @Router /workspaceagents/me/app-status [patch] +func (api *API) patchWorkspaceAgentAppStatus(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspaceAgent := httpmw.WorkspaceAgent(r) + + var req agentsdk.PatchAppStatus + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + app, err := api.Database.GetWorkspaceAppByAgentIDAndSlug(ctx, database.GetWorkspaceAppByAgentIDAndSlugParams{ + AgentID: workspaceAgent.ID, + Slug: req.AppSlug, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get workspace app.", + Detail: err.Error(), + }) + return + } + + workspace, err := api.Database.GetWorkspaceByAgentID(ctx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get workspace.", + Detail: err.Error(), + }) + return + } + + // nolint:gocritic // This is a system restricted operation. + _, err = api.Database.InsertWorkspaceAppStatus(dbauthz.AsSystemRestricted(ctx), database.InsertWorkspaceAppStatusParams{ + ID: uuid.New(), + CreatedAt: dbtime.Now(), + WorkspaceID: workspace.ID, + AgentID: workspaceAgent.ID, + AppID: app.ID, + State: database.WorkspaceAppStatusState(req.State), + Message: req.Message, + Uri: sql.NullString{ + String: req.URI, + Valid: req.URI != "", + }, + Icon: sql.NullString{ + String: req.Icon, + Valid: req.Icon != "", + }, + NeedsUserAttention: req.NeedsUserAttention, + }) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to insert workspace app status.", + Detail: err.Error(), + }) + return + } + + api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{ + Kind: wspubsub.WorkspaceEventKindAgentAppStatusUpdate, + WorkspaceID: workspace.ID, + AgentID: &workspaceAgent.ID, + }) + + httpapi.Write(ctx, rw, http.StatusOK, nil) +} + // workspaceAgentLogs returns the logs associated with a workspace agent // // @Summary Get logs by workspace agent @@ -1054,7 +1143,7 @@ func (api *API) workspaceAgentPostLogSource(rw http.ResponseWriter, r *http.Requ // convertProvisionedApps converts applications that are in the middle of provisioning process. // It means that they may not have an agent or workspace assigned (dry-run job). func convertProvisionedApps(dbApps []database.WorkspaceApp) []codersdk.WorkspaceApp { - return db2sdk.Apps(dbApps, database.WorkspaceAgent{}, "", database.Workspace{}) + return db2sdk.Apps(dbApps, []database.WorkspaceAppStatus{}, database.WorkspaceAgent{}, "", database.Workspace{}) } func convertLogSources(dbLogSources []database.WorkspaceAgentLogSource) []codersdk.WorkspaceAgentLogSource { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index c45cc8c2a6c2f..186c66bfd6f8e 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -339,6 +339,46 @@ func TestWorkspaceAgentLogs(t *testing.T) { }) } +func TestWorkspaceAgentAppStatus(t *testing.T) { + t.Parallel() + t.Run("Success", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + client, user2 := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user2.ID, + }).WithAgent(func(a []*proto.Agent) []*proto.Agent { + a[0].Apps = []*proto.App{ + { + Slug: "vscode", + }, + } + return a + }).Do() + + agentClient := agentsdk.New(client.URL) + agentClient.SetSessionToken(r.AgentToken) + err := agentClient.PatchAppStatus(ctx, agentsdk.PatchAppStatus{ + AppSlug: "vscode", + Message: "testing", + URI: "https://example.com", + Icon: "https://example.com/icon.png", + State: codersdk.WorkspaceAppStatusStateComplete, + }) + require.NoError(t, err) + + workspace, err := client.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err) + agent, err := client.WorkspaceAgent(ctx, workspace.LatestBuild.Resources[0].Agents[0].ID) + require.NoError(t, err) + require.Len(t, agent.Apps[0].Statuses, 1) + }) +} + func TestWorkspaceAgentConnectRPC(t *testing.T) { t.Parallel() diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index f159d4a4e8bf1..7bd32e00cd830 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -84,6 +84,7 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { data.metadata, data.agents, data.apps, + data.appStatuses, data.scripts, data.logSources, data.templateVersions[0], @@ -202,6 +203,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { data.metadata, data.agents, data.apps, + data.appStatuses, data.scripts, data.logSources, data.templateVersions, @@ -292,6 +294,7 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ data.metadata, data.agents, data.apps, + data.appStatuses, data.scripts, data.logSources, data.templateVersions[0], @@ -432,6 +435,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { []database.WorkspaceResourceMetadatum{}, []database.WorkspaceAgent{}, []database.WorkspaceApp{}, + []database.WorkspaceAppStatus{}, []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, @@ -764,6 +768,7 @@ type workspaceBuildsData struct { metadata []database.WorkspaceResourceMetadatum agents []database.WorkspaceAgent apps []database.WorkspaceApp + appStatuses []database.WorkspaceAppStatus scripts []database.WorkspaceAgentScript logSources []database.WorkspaceAgentLogSource provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow @@ -874,6 +879,17 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab return workspaceBuildsData{}, err } + appIDs := make([]uuid.UUID, 0) + for _, app := range apps { + appIDs = append(appIDs, app.ID) + } + + // nolint:gocritic // Getting workspace app statuses by app IDs is a system function. + statuses, err := api.Database.GetWorkspaceAppStatusesByAppIDs(dbauthz.AsSystemRestricted(ctx), appIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceBuildsData{}, xerrors.Errorf("get workspace app statuses: %w", err) + } + return workspaceBuildsData{ jobs: jobs, templateVersions: templateVersions, @@ -881,6 +897,7 @@ func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []datab metadata: metadata, agents: agents, apps: apps, + appStatuses: statuses, scripts: scripts, logSources: logSources, provisionerDaemons: pendingJobProvisioners, @@ -895,6 +912,7 @@ func (api *API) convertWorkspaceBuilds( resourceMetadata []database.WorkspaceResourceMetadatum, resourceAgents []database.WorkspaceAgent, agentApps []database.WorkspaceApp, + agentAppStatuses []database.WorkspaceAppStatus, agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersions []database.TemplateVersion, @@ -937,6 +955,7 @@ func (api *API) convertWorkspaceBuilds( resourceMetadata, resourceAgents, agentApps, + agentAppStatuses, agentScripts, agentLogSources, templateVersion, @@ -960,6 +979,7 @@ func (api *API) convertWorkspaceBuild( resourceMetadata []database.WorkspaceResourceMetadatum, resourceAgents []database.WorkspaceAgent, agentApps []database.WorkspaceApp, + agentAppStatuses []database.WorkspaceAppStatus, agentScripts []database.WorkspaceAgentScript, agentLogSources []database.WorkspaceAgentLogSource, templateVersion database.TemplateVersion, @@ -997,6 +1017,10 @@ func (api *API) convertWorkspaceBuild( provisionerDaemonsForThisWorkspaceBuild = append(provisionerDaemonsForThisWorkspaceBuild, provisionerDaemon.ProvisionerDaemon) } matchedProvisioners := db2sdk.MatchedProvisioners(provisionerDaemonsForThisWorkspaceBuild, job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval) + statusesByAgentID := map[uuid.UUID][]database.WorkspaceAppStatus{} + for _, status := range agentAppStatuses { + statusesByAgentID[status.AgentID] = append(statusesByAgentID[status.AgentID], status) + } resources := resourcesByJobID[job.ProvisionerJob.ID] apiResources := make([]codersdk.WorkspaceResource, 0) @@ -1018,9 +1042,10 @@ func (api *API) convertWorkspaceBuild( apps := appsByAgentID[agent.ID] scripts := scriptsByAgentID[agent.ID] + statuses := statusesByAgentID[agent.ID] logSources := logSourcesByAgentID[agent.ID] apiAgent, err := db2sdk.WorkspaceAgent( - api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, agent, workspace.OwnerUsername, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, + api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, statuses, agent, workspace.OwnerUsername, workspace), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout, api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), ) if err != nil { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 7022938062c64..84862d9c400c9 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -14,6 +14,7 @@ import ( "github.com/dustin/go-humanize" "github.com/go-chi/chi/v5" "github.com/google/uuid" + "golang.org/x/sync/errgroup" "golang.org/x/xerrors" "cdr.dev/slog" @@ -102,12 +103,18 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { return } + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } + w, err := convertWorkspace( apiKey.UserID, workspace, data.builds[0], data.templates[0], api.Options.AllowWorkspaceRenames, + appStatus, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -300,12 +307,18 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) return } + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } + w, err := convertWorkspace( apiKey.UserID, workspace, data.builds[0], data.templates[0], api.Options.AllowWorkspaceRenames, + appStatus, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -731,6 +744,7 @@ func createWorkspace( []database.WorkspaceResourceMetadatum{}, []database.WorkspaceAgent{}, []database.WorkspaceApp{}, + []database.WorkspaceAppStatus{}, []database.WorkspaceAgentScript{}, []database.WorkspaceAgentLogSource{}, database.TemplateVersion{}, @@ -750,6 +764,7 @@ func createWorkspace( apiBuild, template, api.Options.AllowWorkspaceRenames, + codersdk.WorkspaceAppStatus{}, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1234,12 +1249,18 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { aReq.New = newWorkspace + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } + w, err := convertWorkspace( apiKey.UserID, workspace, data.builds[0], data.templates[0], api.Options.AllowWorkspaceRenames, + appStatus, ) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -1771,12 +1792,17 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) { return } + appStatus := codersdk.WorkspaceAppStatus{} + if len(data.appStatuses) > 0 { + appStatus = data.appStatuses[0] + } w, err := convertWorkspace( apiKey.UserID, workspace, data.builds[0], data.templates[0], api.Options.AllowWorkspaceRenames, + appStatus, ) if err != nil { _ = sendEvent(ctx, codersdk.ServerSentEvent{ @@ -1887,6 +1913,7 @@ func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) { type workspaceData struct { templates []database.Template builds []codersdk.WorkspaceBuild + appStatuses []codersdk.WorkspaceAppStatus allowRenames bool } @@ -1902,18 +1929,42 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa templateIDs = append(templateIDs, workspace.TemplateID) } - templates, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ - IDs: templateIDs, + var ( + templates []database.Template + builds []database.WorkspaceBuild + appStatuses []database.WorkspaceAppStatus + eg errgroup.Group + ) + eg.Go(func() (err error) { + templates, err = api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ + IDs: templateIDs, + }) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get templates: %w", err) + } + return nil }) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return workspaceData{}, xerrors.Errorf("get templates: %w", err) - } - - // This query must be run as system restricted to be efficient. - // nolint:gocritic - builds, err := api.Database.GetLatestWorkspaceBuildsByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return workspaceData{}, xerrors.Errorf("get workspace builds: %w", err) + eg.Go(func() (err error) { + // This query must be run as system restricted to be efficient. + // nolint:gocritic + builds, err = api.Database.GetLatestWorkspaceBuildsByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get workspace builds: %w", err) + } + return nil + }) + eg.Go(func() (err error) { + // This query must be run as system restricted to be efficient. + // nolint:gocritic + appStatuses, err = api.Database.GetLatestWorkspaceAppStatusesByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("get workspace app statuses: %w", err) + } + return nil + }) + err := eg.Wait() + if err != nil { + return workspaceData{}, err } data, err := api.workspaceBuildsData(ctx, builds) @@ -1929,6 +1980,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa data.metadata, data.agents, data.apps, + data.appStatuses, data.scripts, data.logSources, data.templateVersions, @@ -1940,6 +1992,7 @@ func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspa return workspaceData{ templates: templates, + appStatuses: db2sdk.WorkspaceAppStatuses(appStatuses), builds: apiBuilds, allowRenames: api.Options.AllowWorkspaceRenames, }, nil @@ -1954,6 +2007,10 @@ func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, d for _, template := range data.templates { templateByID[template.ID] = template } + appStatusesByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceAppStatus{} + for _, appStatus := range data.appStatuses { + appStatusesByWorkspaceID[appStatus.WorkspaceID] = appStatus + } apiWorkspaces := make([]codersdk.Workspace, 0, len(workspaces)) for _, workspace := range workspaces { @@ -1970,6 +2027,7 @@ func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, d if !exists { continue } + appStatus := appStatusesByWorkspaceID[workspace.ID] w, err := convertWorkspace( requesterID, @@ -1977,6 +2035,7 @@ func convertWorkspaces(requesterID uuid.UUID, workspaces []database.Workspace, d build, template, data.allowRenames, + appStatus, ) if err != nil { return nil, xerrors.Errorf("convert workspace: %w", err) @@ -1993,6 +2052,7 @@ func convertWorkspace( workspaceBuild codersdk.WorkspaceBuild, template database.Template, allowRenames bool, + latestAppStatus codersdk.WorkspaceAppStatus, ) (codersdk.Workspace, error) { if requesterID == uuid.Nil { return codersdk.Workspace{}, xerrors.Errorf("developer error: requesterID cannot be uuid.Nil!") @@ -2036,6 +2096,10 @@ func convertWorkspace( // Only show favorite status if you own the workspace. requesterFavorite := workspace.OwnerID == requesterID && workspace.Favorite + appStatus := &latestAppStatus + if latestAppStatus.ID == uuid.Nil { + appStatus = nil + } return codersdk.Workspace{ ID: workspace.ID, CreatedAt: workspace.CreatedAt, @@ -2047,6 +2111,7 @@ func convertWorkspace( OrganizationName: workspace.OrganizationName, TemplateID: workspace.TemplateID, LatestBuild: workspaceBuild, + LatestAppStatus: appStatus, TemplateName: workspace.TemplateName, TemplateIcon: workspace.TemplateIcon, TemplateDisplayName: workspace.TemplateDisplayName, diff --git a/coderd/wspubsub/wspubsub.go b/coderd/wspubsub/wspubsub.go index 0326efa695304..1175ce5830292 100644 --- a/coderd/wspubsub/wspubsub.go +++ b/coderd/wspubsub/wspubsub.go @@ -55,6 +55,7 @@ const ( WorkspaceEventKindAgentFirstLogs WorkspaceEventKind = "agt_first_logs" WorkspaceEventKindAgentLogsOverflow WorkspaceEventKind = "agt_logs_overflow" WorkspaceEventKindAgentTimeout WorkspaceEventKind = "agt_timeout" + WorkspaceEventKindAgentAppStatusUpdate WorkspaceEventKind = "agt_app_status_update" ) func (w *WorkspaceEvent) Validate() error { diff --git a/codersdk/agentsdk/agentsdk.go b/codersdk/agentsdk/agentsdk.go index a6207f238fcac..4f7d0a8baef31 100644 --- a/codersdk/agentsdk/agentsdk.go +++ b/codersdk/agentsdk/agentsdk.go @@ -581,6 +581,28 @@ func (c *Client) PatchLogs(ctx context.Context, req PatchLogs) error { return nil } +// PatchAppStatus updates the status of a workspace app. +type PatchAppStatus struct { + AppSlug string `json:"app_slug"` + NeedsUserAttention bool `json:"needs_user_attention"` + State codersdk.WorkspaceAppStatusState `json:"state"` + Message string `json:"message"` + URI string `json:"uri"` + Icon string `json:"icon"` +} + +func (c *Client) PatchAppStatus(ctx context.Context, req PatchAppStatus) error { + res, err := c.SDK.Request(ctx, http.MethodPatch, "/api/v2/workspaceagents/me/app-status", req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return codersdk.ReadBodyAsError(res) + } + return nil +} + type PostLogSourceRequest struct { // ID is a unique identifier for the log source. // It is scoped to a workspace agent, and can be statically diff --git a/codersdk/workspaceapps.go b/codersdk/workspaceapps.go index 25e45ac5eb305..ec5a7c4414f76 100644 --- a/codersdk/workspaceapps.go +++ b/codersdk/workspaceapps.go @@ -1,6 +1,8 @@ package codersdk import ( + "time" + "github.com/google/uuid" ) @@ -13,6 +15,14 @@ const ( WorkspaceAppHealthUnhealthy WorkspaceAppHealth = "unhealthy" ) +type WorkspaceAppStatusState string + +const ( + WorkspaceAppStatusStateWorking WorkspaceAppStatusState = "working" + WorkspaceAppStatusStateComplete WorkspaceAppStatusState = "complete" + WorkspaceAppStatusStateFailure WorkspaceAppStatusState = "failure" +) + var MapWorkspaceAppHealths = map[WorkspaceAppHealth]struct{}{ WorkspaceAppHealthDisabled: {}, WorkspaceAppHealthInitializing: {}, @@ -75,6 +85,9 @@ type WorkspaceApp struct { Health WorkspaceAppHealth `json:"health"` Hidden bool `json:"hidden"` OpenIn WorkspaceAppOpenIn `json:"open_in"` + + // Statuses is a list of statuses for the app. + Statuses []WorkspaceAppStatus `json:"statuses"` } type Healthcheck struct { @@ -85,3 +98,20 @@ type Healthcheck struct { // Threshold specifies the number of consecutive failed health checks before returning "unhealthy". Threshold int32 `json:"threshold"` } + +type WorkspaceAppStatus struct { + ID uuid.UUID `json:"id" format:"uuid"` + CreatedAt time.Time `json:"created_at" format:"date-time"` + WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"` + AgentID uuid.UUID `json:"agent_id" format:"uuid"` + AppID uuid.UUID `json:"app_id" format:"uuid"` + State WorkspaceAppStatusState `json:"state"` + NeedsUserAttention bool `json:"needs_user_attention"` + Message string `json:"message"` + // URI is the URI of the resource that the status is for. + // e.g. https://github.com/org/repo/pull/123 + // e.g. file:///path/to/file + URI string `json:"uri"` + // Icon is an external URL to an icon that will be rendered in the UI. + Icon string `json:"icon"` +} diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index da3df12eb9364..f9377c1767451 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -26,27 +26,28 @@ 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"` - OwnerName string `json:"owner_name"` - OwnerAvatarURL string `json:"owner_avatar_url"` - OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` - OrganizationName string `json:"organization_name"` - TemplateID uuid.UUID `json:"template_id" format:"uuid"` - TemplateName string `json:"template_name"` - TemplateDisplayName string `json:"template_display_name"` - TemplateIcon string `json:"template_icon"` - TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"` - TemplateActiveVersionID uuid.UUID `json:"template_active_version_id" format:"uuid"` - TemplateRequireActiveVersion bool `json:"template_require_active_version"` - LatestBuild WorkspaceBuild `json:"latest_build"` - Outdated bool `json:"outdated"` - Name string `json:"name"` - AutostartSchedule *string `json:"autostart_schedule,omitempty"` - TTLMillis *int64 `json:"ttl_ms,omitempty"` - LastUsedAt time.Time `json:"last_used_at" format:"date-time"` + 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 string `json:"owner_name"` + OwnerAvatarURL string `json:"owner_avatar_url"` + OrganizationID uuid.UUID `json:"organization_id" format:"uuid"` + OrganizationName string `json:"organization_name"` + TemplateID uuid.UUID `json:"template_id" format:"uuid"` + TemplateName string `json:"template_name"` + TemplateDisplayName string `json:"template_display_name"` + TemplateIcon string `json:"template_icon"` + TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"` + TemplateActiveVersionID uuid.UUID `json:"template_active_version_id" format:"uuid"` + TemplateRequireActiveVersion bool `json:"template_require_active_version"` + LatestBuild WorkspaceBuild `json:"latest_build"` + LatestAppStatus *WorkspaceAppStatus `json:"latest_app_status"` + Outdated bool `json:"outdated"` + Name string `json:"name"` + 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) diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index ec996e9f57d7d..8faba29cf7ba5 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -180,6 +180,64 @@ curl -X POST http://coder-server:8080/api/v2/workspaceagents/google-instance-ide To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Patch workspace agent app status + +### Code samples + +```shell +# Example request using curl +curl -X PATCH http://coder-server:8080/api/v2/workspaceagents/me/app-status \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PATCH /workspaceagents/me/app-status` + +> Body parameter + +```json +{ + "app_slug": "string", + "icon": "string", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +|--------|------|--------------------------------------------------------------|----------|-------------| +| `body` | body | [agentsdk.PatchAppStatus](schemas.md#agentsdkpatchappstatus) | true | app status | + +### Example responses + +> 200 Response + +```json +{ + "detail": "string", + "message": "string", + "validations": [ + { + "detail": "string", + "field": "string" + } + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|--------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get workspace agent external auth ### Code samples @@ -455,6 +513,20 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent} \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" diff --git a/docs/reference/api/builds.md b/docs/reference/api/builds.md index 26f6df4a55b73..0bb4b2e5b0ef3 100644 --- a/docs/reference/api/builds.md +++ b/docs/reference/api/builds.md @@ -100,6 +100,20 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -314,6 +328,20 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -643,6 +671,20 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/res "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -770,6 +812,17 @@ Status Code **200** | `»»» open_in` | [codersdk.WorkspaceAppOpenIn](schemas.md#codersdkworkspaceappopenin) | false | | | | `»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»» slug` | string | false | | Slug is a unique identifier within the agent. | +| `»»» statuses` | array | false | | Statuses is a list of statuses for the app. | +| `»»»» agent_id` | string(uuid) | false | | | +| `»»»» app_id` | string(uuid) | false | | | +| `»»»» created_at` | string(date-time) | false | | | +| `»»»» icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `»»»» id` | string(uuid) | false | | | +| `»»»» message` | string | false | | | +| `»»»» needs_user_attention` | boolean | false | | | +| `»»»» state` | [codersdk.WorkspaceAppStatusState](schemas.md#codersdkworkspaceappstatusstate) | false | | | +| `»»»» uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `»»»» workspace_id` | string(uuid) | false | | | | `»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -851,6 +904,9 @@ Status Code **200** | `sharing_level` | `owner` | | `sharing_level` | `authenticated` | | `sharing_level` | `public` | +| `state` | `working` | +| `state` | `complete` | +| `state` | `failure` | | `lifecycle_state` | `created` | | `lifecycle_state` | `starting` | | `lifecycle_state` | `start_timeout` | @@ -970,6 +1026,20 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -1257,6 +1327,20 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -1440,6 +1524,17 @@ Status Code **200** | `»»»» open_in` | [codersdk.WorkspaceAppOpenIn](schemas.md#codersdkworkspaceappopenin) | false | | | | `»»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»»» slug` | string | false | | Slug is a unique identifier within the agent. | +| `»»»» statuses` | array | false | | Statuses is a list of statuses for the app. | +| `»»»»» agent_id` | string(uuid) | false | | | +| `»»»»» app_id` | string(uuid) | false | | | +| `»»»»» created_at` | string(date-time) | false | | | +| `»»»»» icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `»»»»» id` | string(uuid) | false | | | +| `»»»»» message` | string | false | | | +| `»»»»» needs_user_attention` | boolean | false | | | +| `»»»»» state` | [codersdk.WorkspaceAppStatusState](schemas.md#codersdkworkspaceappstatusstate) | false | | | +| `»»»»» uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `»»»»» workspace_id` | string(uuid) | false | | | | `»»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `»»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -1544,6 +1639,9 @@ Status Code **200** | `sharing_level` | `owner` | | `sharing_level` | `authenticated` | | `sharing_level` | `public` | +| `state` | `working` | +| `state` | `complete` | +| `state` | `failure` | | `lifecycle_state` | `created` | | `lifecycle_state` | `starting` | | `lifecycle_state` | `start_timeout` | @@ -1699,6 +1797,20 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 4fee5c57d5100..3e86146647030 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -118,6 +118,30 @@ | `level` | [codersdk.LogLevel](#codersdkloglevel) | false | | | | `output` | string | false | | | +## agentsdk.PatchAppStatus + +```json +{ + "app_slug": "string", + "icon": "string", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------------|----------------------------------------------------------------------|----------|--------------|-------------| +| `app_slug` | string | false | | | +| `icon` | string | false | | | +| `message` | string | false | | | +| `needs_user_attention` | boolean | false | | | +| `state` | [codersdk.WorkspaceAppStatusState](#codersdkworkspaceappstatusstate) | false | | | +| `uri` | string | false | | | + ## agentsdk.PatchLogs ```json @@ -7525,6 +7549,18 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -7599,6 +7635,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -7727,36 +7777,37 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|---------------------------------------------|--------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `allow_renames` | boolean | false | | | -| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | -| `autostart_schedule` | string | false | | | -| `created_at` | string | false | | | -| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | -| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time_til_ field on its template. | -| `favorite` | boolean | false | | | -| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | -| `id` | string | false | | | -| `last_used_at` | string | false | | | -| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | -| `name` | string | false | | | -| `next_start_at` | string | false | | | -| `organization_id` | string | false | | | -| `organization_name` | string | false | | | -| `outdated` | boolean | false | | | -| `owner_avatar_url` | string | false | | | -| `owner_id` | string | false | | | -| `owner_name` | string | false | | | -| `template_active_version_id` | string | false | | | -| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | -| `template_display_name` | string | false | | | -| `template_icon` | string | false | | | -| `template_id` | string | false | | | -| `template_name` | string | false | | | -| `template_require_active_version` | boolean | false | | | -| `ttl_ms` | integer | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|---------------------------------------------|------------------------------------------------------------|----------|--------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `allow_renames` | boolean | false | | | +| `automatic_updates` | [codersdk.AutomaticUpdates](#codersdkautomaticupdates) | false | | | +| `autostart_schedule` | string | false | | | +| `created_at` | string | false | | | +| `deleting_at` | string | false | | Deleting at indicates the time at which the workspace will be permanently deleted. A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value) and a value has been specified for time_til_dormant_autodelete on its template. | +| `dormant_at` | string | false | | Dormant at being non-nil indicates a workspace that is dormant. A dormant workspace is no longer accessible must be activated. It is subject to deletion if it breaches the duration of the time_til_ field on its template. | +| `favorite` | boolean | false | | | +| `health` | [codersdk.WorkspaceHealth](#codersdkworkspacehealth) | false | | Health shows the health of the workspace and information about what is causing an unhealthy status. | +| `id` | string | false | | | +| `last_used_at` | string | false | | | +| `latest_app_status` | [codersdk.WorkspaceAppStatus](#codersdkworkspaceappstatus) | false | | | +| `latest_build` | [codersdk.WorkspaceBuild](#codersdkworkspacebuild) | false | | | +| `name` | string | false | | | +| `next_start_at` | string | false | | | +| `organization_id` | string | false | | | +| `organization_name` | string | false | | | +| `outdated` | boolean | false | | | +| `owner_avatar_url` | string | false | | | +| `owner_id` | string | false | | | +| `owner_name` | string | false | | | +| `template_active_version_id` | string | false | | | +| `template_allow_user_cancel_workspace_jobs` | boolean | false | | | +| `template_display_name` | string | false | | | +| `template_icon` | string | false | | | +| `template_id` | string | false | | | +| `template_name` | string | false | | | +| `template_require_active_version` | boolean | false | | | +| `ttl_ms` | integer | false | | | +| `updated_at` | string | false | | | #### Enumerated Values @@ -7787,6 +7838,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -8300,6 +8365,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -8321,6 +8400,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `open_in` | [codersdk.WorkspaceAppOpenIn](#codersdkworkspaceappopenin) | false | | | | `sharing_level` | [codersdk.WorkspaceAppSharingLevel](#codersdkworkspaceappsharinglevel) | false | | | | `slug` | string | false | | Slug is a unique identifier within the agent. | +| `statuses` | array of [codersdk.WorkspaceAppStatus](#codersdkworkspaceappstatus) | false | | Statuses is a list of statuses for the app. | | `subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -8381,6 +8461,54 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `authenticated` | | `public` | +## codersdk.WorkspaceAppStatus + +```json +{ + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +|------------------------|----------------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------| +| `agent_id` | string | false | | | +| `app_id` | string | false | | | +| `created_at` | string | false | | | +| `icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `id` | string | false | | | +| `message` | string | false | | | +| `needs_user_attention` | boolean | false | | | +| `state` | [codersdk.WorkspaceAppStatusState](#codersdkworkspaceappstatusstate) | false | | | +| `uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `workspace_id` | string | false | | | + +## codersdk.WorkspaceAppStatusState + +```json +"working" +``` + +### Properties + +#### Enumerated Values + +| Value | +|------------| +| `working` | +| `complete` | +| `failure` | + ## codersdk.WorkspaceBuild ```json @@ -8458,6 +8586,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -8858,6 +9000,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -9057,6 +9213,18 @@ If the schedule is empty, the user will be updated to use the default schedule.| }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -9127,6 +9295,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [], "subdomain": true, "subdomain_name": "string", "url": "string" diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index ab8b4f1b7c131..b644affbbfc88 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -2284,6 +2284,20 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/d "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -2411,6 +2425,17 @@ Status Code **200** | `»»» open_in` | [codersdk.WorkspaceAppOpenIn](schemas.md#codersdkworkspaceappopenin) | false | | | | `»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»» slug` | string | false | | Slug is a unique identifier within the agent. | +| `»»» statuses` | array | false | | Statuses is a list of statuses for the app. | +| `»»»» agent_id` | string(uuid) | false | | | +| `»»»» app_id` | string(uuid) | false | | | +| `»»»» created_at` | string(date-time) | false | | | +| `»»»» icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `»»»» id` | string(uuid) | false | | | +| `»»»» message` | string | false | | | +| `»»»» needs_user_attention` | boolean | false | | | +| `»»»» state` | [codersdk.WorkspaceAppStatusState](schemas.md#codersdkworkspaceappstatusstate) | false | | | +| `»»»» uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `»»»» workspace_id` | string(uuid) | false | | | | `»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -2492,6 +2517,9 @@ Status Code **200** | `sharing_level` | `owner` | | `sharing_level` | `authenticated` | | `sharing_level` | `public` | +| `state` | `working` | +| `state` | `complete` | +| `state` | `failure` | | `lifecycle_state` | `created` | | `lifecycle_state` | `starting` | | `lifecycle_state` | `start_timeout` | @@ -2777,6 +2805,20 @@ curl -X GET http://coder-server:8080/api/v2/templateversions/{templateversion}/r "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -2904,6 +2946,17 @@ Status Code **200** | `»»» open_in` | [codersdk.WorkspaceAppOpenIn](schemas.md#codersdkworkspaceappopenin) | false | | | | `»»» sharing_level` | [codersdk.WorkspaceAppSharingLevel](schemas.md#codersdkworkspaceappsharinglevel) | false | | | | `»»» slug` | string | false | | Slug is a unique identifier within the agent. | +| `»»» statuses` | array | false | | Statuses is a list of statuses for the app. | +| `»»»» agent_id` | string(uuid) | false | | | +| `»»»» app_id` | string(uuid) | false | | | +| `»»»» created_at` | string(date-time) | false | | | +| `»»»» icon` | string | false | | Icon is an external URL to an icon that will be rendered in the UI. | +| `»»»» id` | string(uuid) | false | | | +| `»»»» message` | string | false | | | +| `»»»» needs_user_attention` | boolean | false | | | +| `»»»» state` | [codersdk.WorkspaceAppStatusState](schemas.md#codersdkworkspaceappstatusstate) | false | | | +| `»»»» uri` | string | false | | Uri is the URI of the resource that the status is for. e.g. https://github.com/org/repo/pull/123 e.g. file:///path/to/file | +| `»»»» workspace_id` | string(uuid) | false | | | | `»»» subdomain` | boolean | false | | Subdomain denotes whether the app should be accessed via a path on the `coder server` or via a hostname-based dev URL. If this is set to true and there is no app wildcard configured on the server, the app will not be accessible in the UI. | | `»»» subdomain_name` | string | false | | Subdomain name is the application domain exposed on the `coder server`. | | `»»» url` | string | false | | URL is the address being proxied to inside the workspace. If external is specified, this will be opened on the client. | @@ -2985,6 +3038,9 @@ Status Code **200** | `sharing_level` | `owner` | | `sharing_level` | `authenticated` | | `sharing_level` | `public` | +| `state` | `working` | +| `state` | `complete` | +| `state` | `failure` | | `lifecycle_state` | `created` | | `lifecycle_state` | `starting` | | `lifecycle_state` | `start_timeout` | diff --git a/docs/reference/api/workspaces.md b/docs/reference/api/workspaces.md index 7264b6dbb3939..df5c7856de8c2 100644 --- a/docs/reference/api/workspaces.md +++ b/docs/reference/api/workspaces.md @@ -67,6 +67,18 @@ of the template will be used. }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -141,6 +153,20 @@ of the template will be used. "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -317,6 +343,18 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -391,6 +429,20 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -591,6 +643,18 @@ of the template will be used. }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -665,6 +729,20 @@ of the template will be used. "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -844,6 +922,18 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -914,6 +1004,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -1091,6 +1182,18 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -1165,6 +1268,20 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" @@ -1457,6 +1574,18 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ }, "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "last_used_at": "2019-08-24T14:15:22Z", + "latest_app_status": { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + }, "latest_build": { "build_number": 0, "created_at": "2019-08-24T14:15:22Z", @@ -1531,6 +1660,20 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "open_in": "slim-window", "sharing_level": "owner", "slug": "string", + "statuses": [ + { + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_id": "affd1d10-9538-4fc8-9e0b-4594a28c1335", + "created_at": "2019-08-24T14:15:22Z", + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "message": "string", + "needs_user_attention": true, + "state": "working", + "uri": "string", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9" + } + ], "subdomain": true, "subdomain_name": "string", "url": "string" diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 602fb582f07c9..fb534dafd3973 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3060,6 +3060,7 @@ export interface Workspace { readonly template_active_version_id: string; readonly template_require_active_version: boolean; readonly latest_build: WorkspaceBuild; + readonly latest_app_status: WorkspaceAppStatus | null; readonly outdated: boolean; readonly name: string; readonly autostart_schedule?: string; @@ -3307,6 +3308,7 @@ export interface WorkspaceApp { readonly health: WorkspaceAppHealth; readonly hidden: boolean; readonly open_in: WorkspaceAppOpenIn; + readonly statuses: readonly WorkspaceAppStatus[]; } // From codersdk/workspaceapps.go @@ -3337,6 +3339,29 @@ export const WorkspaceAppSharingLevels: WorkspaceAppSharingLevel[] = [ "public", ]; +// From codersdk/workspaceapps.go +export interface WorkspaceAppStatus { + readonly id: string; + readonly created_at: string; + readonly workspace_id: string; + readonly agent_id: string; + readonly app_id: string; + readonly state: WorkspaceAppStatusState; + readonly needs_user_attention: boolean; + readonly message: string; + readonly uri: string; + readonly icon: string; +} + +// From codersdk/workspaceapps.go +export type WorkspaceAppStatusState = "complete" | "failure" | "working"; + +export const WorkspaceAppStatusStates: WorkspaceAppStatusState[] = [ + "complete", + "failure", + "working", +]; + // From codersdk/workspacebuilds.go export interface WorkspaceBuild { readonly id: string; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index f80171122826c..1a233b74a084d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -913,6 +913,7 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = { }, hidden: false, open_in: "slim-window", + statuses: [], }; export const MockWorkspaceAgentLogSource: TypesGen.WorkspaceAgentLogSource = { @@ -1371,6 +1372,7 @@ export const MockWorkspace: TypesGen.Workspace = { healthy: true, failing_agents: [], }, + latest_app_status: null, automatic_updates: "never", allow_renames: true, favorite: false,