Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commitd37b131

Browse files
authored
feat: add activity status and autostop reason to workspace overview (#11987)
1 parente53d8bd commitd37b131

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
typeStatsBatcherinterface {
@@ -27,6 +29,7 @@ type StatsBatcher interface {
2729
typeStatsAPIstruct {
2830
AgentFnfunc(context.Context) (database.WorkspaceAgent,error)
2931
Database database.Store
32+
Pubsub pubsub.Pubsub
3033
Log slog.Logger
3134
StatsBatcherStatsBatcher
3235
TemplateScheduleStore*atomic.Pointer[schedule.TemplateScheduleStore]
@@ -130,5 +133,16 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR
130133
returnnil,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
returnres,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+
iferr!=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
typestatsBatcherstruct {
@@ -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
returnagent,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(chanbool)
176+
ps.Subscribe(codersdk.WorkspaceNotifyChannel(workspace.ID),func(_ context.Context,description []byte) {
177+
gofunc() {
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+
casewasAgentStatsOnly:=<-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
returnagent,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
returnagent,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
returnagent,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+
ifbytes.Equal(description,codersdk.WorkspaceNotifyDescriptionAgentStatsOnly) {
1352+
workspace,err:=api.Database.GetWorkspaceByID(ctx,workspace.ID)
1353+
iferr!=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+
iferr!=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
iferr!=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
typeServerSentEventTypestring
2121

2222
const (
23-
ServerSentEventTypePingServerSentEventType="ping"
24-
ServerSentEventTypeDataServerSentEventType="data"
25-
ServerSentEventTypeErrorServerSentEventType="error"
23+
ServerSentEventTypePingServerSentEventType="ping"
24+
ServerSentEventTypeDataServerSentEventType="data"
25+
ServerSentEventTypePartialServerSentEventType="partial"
26+
ServerSentEventTypeErrorServerSentEventType="error"
2627
)
2728

2829
funcServerSentEventReader(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
returnnil
498498
}
499499

500+
varWorkspaceNotifyDescriptionAgentStatsOnly= []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 aboutcustomizing 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+
exportfunctionuseTime(active:boolean=true){
17+
const[,setTick]=useState(0);
18+
19+
useEffect(()=>{
20+
if(!active){
21+
return;
22+
}
23+
24+
constinterval=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+
importdayjsfrom"dayjs";
2+
importtype{Workspace}from"api/typesGenerated";
3+
4+
exporttypeWorkspaceActivityStatus=
5+
|"ready"
6+
|"connected"
7+
|"inactive"
8+
|"notConnected"
9+
|"notRunning";
10+
11+
exportfunctiongetWorkspaceActivityStatus(
12+
workspace:Workspace,
13+
):WorkspaceActivityStatus{
14+
constbuiltAt=dayjs(workspace.latest_build.created_at);
15+
constusedAt=dayjs(workspace.last_used_at);
16+
constnow=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+
constisBuiltRecently=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+
constisUsedRecently=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:newDate().toString(),
32+
created_at:newDate().toISOString(),
3333
status:"active",
3434
organization_ids:["123"],
3535
roles:[],
3636
avatar_url:"",
37-
last_seen_at:newDate().toString(),
37+
last_seen_at:newDate().toISOString(),
3838
login_type:"password",
3939
theme_preference:"",
4040
...data,

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp