Skip to content

Commit d37b131

Browse files
authored
feat: add activity status and autostop reason to workspace overview (coder#11987)
1 parent e53d8bd commit d37b131

File tree

22 files changed

+645
-117
lines changed

22 files changed

+645
-117
lines changed

coderd/agentapi/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ func New(opts Options) *API {
114114
api.StatsAPI = &StatsAPI{
115115
AgentFn: api.agent,
116116
Database: opts.Database,
117+
Pubsub: opts.Pubsub,
117118
Log: opts.Log,
118119
StatsBatcher: opts.StatsBatcher,
119120
TemplateScheduleStore: opts.TemplateScheduleStore,

coderd/agentapi/stats.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import (
1616
"github.com/coder/coder/v2/coderd/autobuild"
1717
"github.com/coder/coder/v2/coderd/database"
1818
"github.com/coder/coder/v2/coderd/database/dbtime"
19+
"github.com/coder/coder/v2/coderd/database/pubsub"
1920
"github.com/coder/coder/v2/coderd/prometheusmetrics"
2021
"github.com/coder/coder/v2/coderd/schedule"
22+
"github.com/coder/coder/v2/codersdk"
2123
)
2224

2325
type StatsBatcher interface {
@@ -27,6 +29,7 @@ type StatsBatcher interface {
2729
type StatsAPI struct {
2830
AgentFn func(context.Context) (database.WorkspaceAgent, error)
2931
Database database.Store
32+
Pubsub pubsub.Pubsub
3033
Log slog.Logger
3134
StatsBatcher StatsBatcher
3235
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
@@ -130,5 +133,16 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR
130133
return nil, xerrors.Errorf("update stats in database: %w", err)
131134
}
132135

136+
// Tell the frontend about the new agent report, now that everything is updated
137+
a.publishWorkspaceAgentStats(ctx, workspace.ID)
138+
133139
return res, nil
134140
}
141+
142+
func (a *StatsAPI) publishWorkspaceAgentStats(ctx context.Context, workspaceID uuid.UUID) {
143+
err := a.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(workspaceID), codersdk.WorkspaceNotifyDescriptionAgentStatsOnly)
144+
if err != nil {
145+
a.Log.Warn(ctx, "failed to publish workspace agent stats",
146+
slog.F("workspace_id", workspaceID), slog.Error(err))
147+
}
148+
}

coderd/agentapi/stats_test.go

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package agentapi_test
22

33
import (
4+
"bytes"
45
"context"
56
"database/sql"
67
"sync"
@@ -19,8 +20,11 @@ import (
1920
"github.com/coder/coder/v2/coderd/database"
2021
"github.com/coder/coder/v2/coderd/database/dbmock"
2122
"github.com/coder/coder/v2/coderd/database/dbtime"
23+
"github.com/coder/coder/v2/coderd/database/pubsub"
2224
"github.com/coder/coder/v2/coderd/prometheusmetrics"
2325
"github.com/coder/coder/v2/coderd/schedule"
26+
"github.com/coder/coder/v2/codersdk"
27+
"github.com/coder/coder/v2/testutil"
2428
)
2529

2630
type statsBatcher struct {
@@ -78,8 +82,10 @@ func TestUpdateStates(t *testing.T) {
7882
t.Parallel()
7983

8084
var (
81-
now = dbtime.Now()
82-
dbM = dbmock.NewMockStore(gomock.NewController(t))
85+
now = dbtime.Now()
86+
dbM = dbmock.NewMockStore(gomock.NewController(t))
87+
ps = pubsub.NewInMemory()
88+
8389
templateScheduleStore = schedule.MockTemplateScheduleStore{
8490
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
8591
panic("should not be called")
@@ -125,6 +131,7 @@ func TestUpdateStates(t *testing.T) {
125131
return agent, nil
126132
},
127133
Database: dbM,
134+
Pubsub: ps,
128135
StatsBatcher: batcher,
129136
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
130137
AgentStatsRefreshInterval: 10 * time.Second,
@@ -164,6 +171,15 @@ func TestUpdateStates(t *testing.T) {
164171
// User gets fetched to hit the UpdateAgentMetricsFn.
165172
dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
166173

174+
// Ensure that pubsub notifications are sent.
175+
publishAgentStats := make(chan bool)
176+
ps.Subscribe(codersdk.WorkspaceNotifyChannel(workspace.ID), func(_ context.Context, description []byte) {
177+
go func() {
178+
publishAgentStats <- bytes.Equal(description, codersdk.WorkspaceNotifyDescriptionAgentStatsOnly)
179+
close(publishAgentStats)
180+
}()
181+
})
182+
167183
resp, err := api.UpdateStats(context.Background(), req)
168184
require.NoError(t, err)
169185
require.Equal(t, &agentproto.UpdateStatsResponse{
@@ -179,7 +195,13 @@ func TestUpdateStates(t *testing.T) {
179195
require.Equal(t, user.ID, batcher.lastUserID)
180196
require.Equal(t, workspace.ID, batcher.lastWorkspaceID)
181197
require.Equal(t, req.Stats, batcher.lastStats)
182-
198+
ctx := testutil.Context(t, testutil.WaitShort)
199+
select {
200+
case <-ctx.Done():
201+
t.Error("timed out while waiting for pubsub notification")
202+
case wasAgentStatsOnly := <-publishAgentStats:
203+
require.Equal(t, wasAgentStatsOnly, true)
204+
}
183205
require.True(t, updateAgentMetricsFnCalled)
184206
})
185207

@@ -189,6 +211,7 @@ func TestUpdateStates(t *testing.T) {
189211
var (
190212
now = dbtime.Now()
191213
dbM = dbmock.NewMockStore(gomock.NewController(t))
214+
ps = pubsub.NewInMemory()
192215
templateScheduleStore = schedule.MockTemplateScheduleStore{
193216
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
194217
panic("should not be called")
@@ -214,6 +237,7 @@ func TestUpdateStates(t *testing.T) {
214237
return agent, nil
215238
},
216239
Database: dbM,
240+
Pubsub: ps,
217241
StatsBatcher: batcher,
218242
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
219243
AgentStatsRefreshInterval: 10 * time.Second,
@@ -244,7 +268,8 @@ func TestUpdateStates(t *testing.T) {
244268
t.Parallel()
245269

246270
var (
247-
dbM = dbmock.NewMockStore(gomock.NewController(t))
271+
db = dbmock.NewMockStore(gomock.NewController(t))
272+
ps = pubsub.NewInMemory()
248273
req = &agentproto.UpdateStatsRequest{
249274
Stats: &agentproto.Stats{
250275
ConnectionsByProto: map[string]int64{}, // len() == 0
@@ -255,7 +280,8 @@ func TestUpdateStates(t *testing.T) {
255280
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
256281
return agent, nil
257282
},
258-
Database: dbM,
283+
Database: db,
284+
Pubsub: ps,
259285
StatsBatcher: nil, // should not be called
260286
TemplateScheduleStore: nil, // should not be called
261287
AgentStatsRefreshInterval: 10 * time.Second,
@@ -290,7 +316,9 @@ func TestUpdateStates(t *testing.T) {
290316
nextAutostart := now.Add(30 * time.Minute).UTC() // always sent to DB as UTC
291317

292318
var (
293-
dbM = dbmock.NewMockStore(gomock.NewController(t))
319+
db = dbmock.NewMockStore(gomock.NewController(t))
320+
ps = pubsub.NewInMemory()
321+
294322
templateScheduleStore = schedule.MockTemplateScheduleStore{
295323
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
296324
return schedule.TemplateScheduleOptions{
@@ -321,7 +349,8 @@ func TestUpdateStates(t *testing.T) {
321349
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
322350
return agent, nil
323351
},
324-
Database: dbM,
352+
Database: db,
353+
Pubsub: ps,
325354
StatsBatcher: batcher,
326355
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
327356
AgentStatsRefreshInterval: 15 * time.Second,
@@ -341,26 +370,26 @@ func TestUpdateStates(t *testing.T) {
341370
}
342371

343372
// Workspace gets fetched.
344-
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{
373+
db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{
345374
Workspace: workspace,
346375
TemplateName: template.Name,
347376
}, nil)
348377

349378
// We expect an activity bump because ConnectionCount > 0. However, the
350379
// next autostart time will be set on the bump.
351-
dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
380+
db.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
352381
WorkspaceID: workspace.ID,
353382
NextAutostart: nextAutostart,
354383
}).Return(nil)
355384

356385
// Workspace last used at gets bumped.
357-
dbM.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{
386+
db.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{
358387
ID: workspace.ID,
359388
LastUsedAt: now,
360389
}).Return(nil)
361390

362391
// User gets fetched to hit the UpdateAgentMetricsFn.
363-
dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
392+
db.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
364393

365394
resp, err := api.UpdateStats(context.Background(), req)
366395
require.NoError(t, err)

coderd/workspaces.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package coderd
22

33
import (
4+
"bytes"
45
"context"
56
"database/sql"
67
"encoding/json"
@@ -1343,7 +1344,48 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) {
13431344
<-senderClosed
13441345
}()
13451346

1346-
sendUpdate := func(_ context.Context, _ []byte) {
1347+
sendUpdate := func(_ context.Context, description []byte) {
1348+
// The agent stats get updated frequently, so we treat these as a special case and only
1349+
// send a partial update. We primarily care about updating the `last_used_at` and
1350+
// `latest_build.deadline` properties.
1351+
if bytes.Equal(description, codersdk.WorkspaceNotifyDescriptionAgentStatsOnly) {
1352+
workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
1353+
if err != nil {
1354+
_ = sendEvent(ctx, codersdk.ServerSentEvent{
1355+
Type: codersdk.ServerSentEventTypeError,
1356+
Data: codersdk.Response{
1357+
Message: "Internal error fetching workspace.",
1358+
Detail: err.Error(),
1359+
},
1360+
})
1361+
return
1362+
}
1363+
1364+
workspaceBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
1365+
if err != nil {
1366+
_ = sendEvent(ctx, codersdk.ServerSentEvent{
1367+
Type: codersdk.ServerSentEventTypeError,
1368+
Data: codersdk.Response{
1369+
Message: "Internal error fetching workspace build.",
1370+
Detail: err.Error(),
1371+
},
1372+
})
1373+
return
1374+
}
1375+
1376+
_ = sendEvent(ctx, codersdk.ServerSentEvent{
1377+
Type: codersdk.ServerSentEventTypePartial,
1378+
Data: struct {
1379+
database.Workspace
1380+
LatestBuild database.WorkspaceBuild `json:"latest_build"`
1381+
}{
1382+
Workspace: workspace,
1383+
LatestBuild: workspaceBuild,
1384+
},
1385+
})
1386+
return
1387+
}
1388+
13471389
workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
13481390
if err != nil {
13491391
_ = sendEvent(ctx, codersdk.ServerSentEvent{

codersdk/serversentevents.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ type ServerSentEvent struct {
2020
type ServerSentEventType string
2121

2222
const (
23-
ServerSentEventTypePing ServerSentEventType = "ping"
24-
ServerSentEventTypeData ServerSentEventType = "data"
25-
ServerSentEventTypeError ServerSentEventType = "error"
23+
ServerSentEventTypePing ServerSentEventType = "ping"
24+
ServerSentEventTypeData ServerSentEventType = "data"
25+
ServerSentEventTypePartial ServerSentEventType = "partial"
26+
ServerSentEventTypeError ServerSentEventType = "error"
2627
)
2728

2829
func ServerSentEventReader(ctx context.Context, rc io.ReadCloser) func() (*ServerSentEvent, error) {

codersdk/workspaces.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,8 @@ func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID)
497497
return nil
498498
}
499499

500+
var WorkspaceNotifyDescriptionAgentStatsOnly = []byte("agentStatsOnly")
501+
500502
// WorkspaceNotifyChannel is the PostgreSQL NOTIFY
501503
// channel to listen for updates on. The payload is empty,
502504
// because the size of a workspace payload can be very large.

site/src/api/typesGenerated.ts

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/hooks/useTime.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useEffect, useState } from "react";
2+
3+
/**
4+
* useTime allows a component to rerender over time without a corresponding state change.
5+
* An example could be a relative timestamp (eg. "in 5 minutes") that should count down as it
6+
* approaches.
7+
*
8+
* This hook should only be used in components that are very simple, and that will not
9+
* create a lot of unnecessary work for the reconciler. Given that this hook will result in
10+
* the entire subtree being rerendered on a frequent interval, it's important that the subtree
11+
* remains small.
12+
*
13+
* @param active Can optionally be set to false in circumstances where updating over time is
14+
* not necessary.
15+
*/
16+
export function useTime(active: boolean = true) {
17+
const [, setTick] = useState(0);
18+
19+
useEffect(() => {
20+
if (!active) {
21+
return;
22+
}
23+
24+
const interval = setInterval(() => {
25+
setTick((i) => i + 1);
26+
}, 1000);
27+
28+
return () => {
29+
clearInterval(interval);
30+
};
31+
}, [active]);
32+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import dayjs from "dayjs";
2+
import type { Workspace } from "api/typesGenerated";
3+
4+
export type WorkspaceActivityStatus =
5+
| "ready"
6+
| "connected"
7+
| "inactive"
8+
| "notConnected"
9+
| "notRunning";
10+
11+
export function getWorkspaceActivityStatus(
12+
workspace: Workspace,
13+
): WorkspaceActivityStatus {
14+
const builtAt = dayjs(workspace.latest_build.created_at);
15+
const usedAt = dayjs(workspace.last_used_at);
16+
const now = dayjs();
17+
18+
if (workspace.latest_build.status !== "running") {
19+
return "notRunning";
20+
}
21+
22+
// This needs to compare to `usedAt` instead of `now`, because the "grace period" for
23+
// marking a workspace as "Connected" is a lot longer. If you compared `builtAt` to `now`,
24+
// you could end up switching from "Ready" to "Connected" without ever actually connecting.
25+
const isBuiltRecently = builtAt.isAfter(usedAt.subtract(1, "second"));
26+
// By default, agents report connection stats every 30 seconds, so 2 minutes should be
27+
// plenty. Disconnection will be reflected relatively-quickly
28+
const isUsedRecently = usedAt.isAfter(now.subtract(2, "minute"));
29+
30+
// If the build is still "fresh", it'll be a while before the `last_used_at` gets bumped in
31+
// a significant way by the agent, so just label it as ready instead of connected.
32+
// Wait until `last_used_at` is after the time that the build finished, _and_ still
33+
// make sure to check that it's recent, so that we don't show "Ready" indefinitely.
34+
if (isUsedRecently && isBuiltRecently && workspace.health.healthy) {
35+
return "ready";
36+
}
37+
38+
if (isUsedRecently) {
39+
return "connected";
40+
}
41+
42+
// TODO: It'd be nice if we could differentiate between "connected but inactive" and
43+
// "not connected", but that will require some relatively substantial backend work.
44+
return "inactive";
45+
}

site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ describe("AccountPage", () => {
2929
Promise.resolve({
3030
id: userId,
3131
email: "user@coder.com",
32-
created_at: new Date().toString(),
32+
created_at: new Date().toISOString(),
3333
status: "active",
3434
organization_ids: ["123"],
3535
roles: [],
3636
avatar_url: "",
37-
last_seen_at: new Date().toString(),
37+
last_seen_at: new Date().toISOString(),
3838
login_type: "password",
3939
theme_preference: "",
4040
...data,

0 commit comments

Comments
 (0)