Skip to content

Commit c5ad3db

Browse files
committed
notify frontend about new agent activity
1 parent a5b2dbb commit c5ad3db

File tree

12 files changed

+128
-12
lines changed

12 files changed

+128
-12
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/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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,10 @@ func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID)
497497
return nil
498498
}
499499

500+
var (
501+
WorkspaceNotifyDescriptionAgentStatsOnly = []byte("agentStatsOnly")
502+
)
503+
500504
// WorkspaceNotifyChannel is the PostgreSQL NOTIFY
501505
// channel to listen for updates on. The payload is empty,
502506
// 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+
}

site/src/pages/WorkspacePage/ActivityStatus.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { type FC } from "react";
22
import dayjs from "dayjs";
33
import relativeTime from "dayjs/plugin/relativeTime";
44
import type { Workspace } from "api/typesGenerated";
5+
import { useTime } from "hooks/useTime";
56
import { Pill } from "components/Pill/Pill";
67

78
dayjs.extend(relativeTime);
@@ -11,29 +12,31 @@ interface ActivityStatusProps {
1112
}
1213

1314
export const ActivityStatus: FC<ActivityStatusProps> = ({ workspace }) => {
14-
const builtAt = dayjs(workspace.latest_build.created_at);
15+
const builtAt = dayjs(workspace.latest_build.updated_at);
1516
const usedAt = dayjs(workspace.last_used_at);
1617
const now = dayjs();
1718

1819
// This needs to compare to `usedAt` instead of `now`, because the "grace period" for
1920
// marking a workspace as "Connected" is a lot longer. If you compared `builtAt` to `now`,
2021
// you could end up switching from "Ready" to "Connected" without ever actually connecting.
21-
const isBuiltRecently = builtAt.isAfter(usedAt.subtract(2, "minute"));
22+
const isBuiltRecently = builtAt.isAfter(usedAt.subtract(1, "second"));
2223
const isUsedRecently = usedAt.isAfter(now.subtract(15, "minute"));
2324

25+
useTime(isUsedRecently);
26+
2427
switch (workspace.latest_build.status) {
2528
// If the build is still "fresh", it'll be a while before the `last_used_at` gets bumped in
2629
// a significant way by the agent, so just label it as ready instead of connected.
27-
// Wait until `last_used_at` is at least 2 minutes after the build was created, _and_ still
28-
// make sure to check that it's recent.
30+
// Wait until `last_used_at` is after the time that the build finished, _and_ still
31+
// make sure to check that it's recent, so that we don't show "Ready" indefinitely.
2932
case isBuiltRecently &&
3033
isUsedRecently &&
3134
workspace.health.healthy &&
3235
"running":
3336
return <Pill type="active">Ready</Pill>;
3437
// Since the agent reports on a 10m interval, we present any connection within that period
3538
// plus a little wiggle room as an active connection.
36-
case usedAt.isAfter(now.subtract(15, "minute")) && "running":
39+
case isUsedRecently && "running":
3740
return <Pill type="active">Connected</Pill>;
3841
case "running":
3942
case "stopping":

site/src/pages/WorkspacePage/WorkspacePage.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { type FC, useEffect } from "react";
22
import { useQuery, useQueryClient } from "react-query";
33
import { useParams } from "react-router-dom";
4+
import merge from "lodash/merge";
45
import { watchWorkspace } from "api/api";
56
import type { Workspace } from "api/typesGenerated";
67
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
@@ -89,6 +90,19 @@ export const WorkspacePage: FC = () => {
8990
await updateWorkspaceData(newWorkspaceData);
9091
});
9192

93+
eventSource.addEventListener("partial", async (event) => {
94+
const newWorkspaceData = JSON.parse(event.data) as Partial<Workspace>;
95+
// Merge with a fresh object `{}` as the base, because `merge` uses an in-place algorithm,
96+
// and would otherwise mutate the `queryClient`'s internal state.
97+
await updateWorkspaceData(
98+
merge(
99+
{},
100+
queryClient.getQueryData(workspaceQueryOptions.queryKey) as Workspace,
101+
newWorkspaceData,
102+
),
103+
);
104+
});
105+
92106
eventSource.addEventListener("error", (event) => {
93107
console.error("Error on getting workspace changes.", event);
94108
});

site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { type Dayjs } from "dayjs";
99
import { forwardRef, type FC, useRef } from "react";
1010
import { useMutation, useQueryClient } from "react-query";
1111
import { Link as RouterLink } from "react-router-dom";
12+
import { useTime } from "hooks/useTime";
1213
import { isWorkspaceOn } from "utils/workspace";
1314
import type { Template, Workspace } from "api/typesGenerated";
1415
import {
@@ -138,6 +139,7 @@ interface AutoStopDisplayProps {
138139
}
139140

140141
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace, template }) => {
142+
useTime();
141143
const { message, tooltip } = autostopDisplay(workspace, template);
142144

143145
const display = (

site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export const Ready: Story = {
5656
last_used_at: new Date().toISOString(),
5757
latest_build: {
5858
...baseWorkspace.latest_build,
59-
created_at: new Date().toISOString(),
59+
updated_at: new Date().toISOString(),
6060
},
6161
},
6262
},

site/src/pages/WorkspacesPage/LastUsed.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { useTheme } from "@emotion/react";
12
import { type FC } from "react";
23
import dayjs from "dayjs";
34
import relativeTime from "dayjs/plugin/relativeTime";
4-
import { useTheme } from "@emotion/react";
55
import { Stack } from "components/Stack/Stack";
6+
import { useTime } from "hooks/useTime";
67

78
dayjs.extend(relativeTime);
89

@@ -31,6 +32,7 @@ interface LastUsedProps {
3132
}
3233

3334
export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
35+
useTime();
3436
const theme = useTheme();
3537
const t = dayjs(lastUsedAt);
3638
const now = dayjs();

0 commit comments

Comments
 (0)