Skip to content

chore: reimplement activity status and autostop improvements #12175

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions coderd/agentapi/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ func New(opts Options) *API {
api.StatsAPI = &StatsAPI{
AgentFn: api.agent,
Database: opts.Database,
Pubsub: opts.Pubsub,
Log: opts.Log,
StatsBatcher: opts.StatsBatcher,
TemplateScheduleStore: opts.TemplateScheduleStore,
Expand Down
14 changes: 14 additions & 0 deletions coderd/agentapi/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import (
"github.com/coder/coder/v2/coderd/autobuild"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/prometheusmetrics"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/codersdk"
)

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

// Tell the frontend about the new agent report, now that everything is updated
a.publishWorkspaceAgentStats(ctx, workspace.ID)

return res, nil
}

func (a *StatsAPI) publishWorkspaceAgentStats(ctx context.Context, workspaceID uuid.UUID) {
err := a.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(workspaceID), []byte{})
if err != nil {
a.Log.Warn(ctx, "failed to publish workspace agent stats",
slog.F("workspace_id", workspaceID), slog.Error(err))
}
}
37 changes: 33 additions & 4 deletions coderd/agentapi/stats_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agentapi_test

import (
"bytes"
"context"
"database/sql"
"sync"
Expand All @@ -19,8 +20,11 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbmock"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/prometheusmetrics"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)

type statsBatcher struct {
Expand Down Expand Up @@ -78,8 +82,10 @@ func TestUpdateStates(t *testing.T) {
t.Parallel()

var (
now = dbtime.Now()
dbM = dbmock.NewMockStore(gomock.NewController(t))
now = dbtime.Now()
dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()

templateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
panic("should not be called")
Expand Down Expand Up @@ -125,6 +131,7 @@ func TestUpdateStates(t *testing.T) {
return agent, nil
},
Database: dbM,
Pubsub: ps,
StatsBatcher: batcher,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
AgentStatsRefreshInterval: 10 * time.Second,
Expand Down Expand Up @@ -164,6 +171,15 @@ func TestUpdateStates(t *testing.T) {
// User gets fetched to hit the UpdateAgentMetricsFn.
dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)

// Ensure that pubsub notifications are sent.
publishStats := make(chan bool)
ps.Subscribe(codersdk.WorkspaceNotifyChannel(workspace.ID), func(_ context.Context, description []byte) {
go func() {
publishStats <- bytes.Equal(description, []byte{})
close(publishStats)
}()
})

resp, err := api.UpdateStats(context.Background(), req)
require.NoError(t, err)
require.Equal(t, &agentproto.UpdateStatsResponse{
Expand All @@ -179,7 +195,13 @@ func TestUpdateStates(t *testing.T) {
require.Equal(t, user.ID, batcher.lastUserID)
require.Equal(t, workspace.ID, batcher.lastWorkspaceID)
require.Equal(t, req.Stats, batcher.lastStats)

ctx := testutil.Context(t, testutil.WaitShort)
select {
case <-ctx.Done():
t.Error("timed out while waiting for pubsub notification")
case publishStatsReceived := <-publishStats:
require.Equal(t, publishStatsReceived, true)
}
require.True(t, updateAgentMetricsFnCalled)
})

Expand All @@ -189,6 +211,7 @@ func TestUpdateStates(t *testing.T) {
var (
now = dbtime.Now()
dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
templateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
panic("should not be called")
Expand All @@ -214,6 +237,7 @@ func TestUpdateStates(t *testing.T) {
return agent, nil
},
Database: dbM,
Pubsub: ps,
StatsBatcher: batcher,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
AgentStatsRefreshInterval: 10 * time.Second,
Expand Down Expand Up @@ -245,6 +269,7 @@ func TestUpdateStates(t *testing.T) {

var (
dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()
req = &agentproto.UpdateStatsRequest{
Stats: &agentproto.Stats{
ConnectionsByProto: map[string]int64{}, // len() == 0
Expand All @@ -256,6 +281,7 @@ func TestUpdateStates(t *testing.T) {
return agent, nil
},
Database: dbM,
Pubsub: ps,
StatsBatcher: nil, // should not be called
TemplateScheduleStore: nil, // should not be called
AgentStatsRefreshInterval: 10 * time.Second,
Expand Down Expand Up @@ -290,7 +316,9 @@ func TestUpdateStates(t *testing.T) {
nextAutostart := now.Add(30 * time.Minute).UTC() // always sent to DB as UTC

var (
dbM = dbmock.NewMockStore(gomock.NewController(t))
dbM = dbmock.NewMockStore(gomock.NewController(t))
ps = pubsub.NewInMemory()

templateScheduleStore = schedule.MockTemplateScheduleStore{
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
return schedule.TemplateScheduleOptions{
Expand Down Expand Up @@ -322,6 +350,7 @@ func TestUpdateStates(t *testing.T) {
return agent, nil
},
Database: dbM,
Pubsub: ps,
StatsBatcher: batcher,
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
AgentStatsRefreshInterval: 15 * time.Second,
Expand Down
43 changes: 43 additions & 0 deletions site/src/hooks/useTime.ts
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we test this?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm gonna talk with Michael about this!

Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { useEffectEvent } from "./hookPolyfills";

interface UseTimeOptions {
/**
* Can be set to `true` to disable checking for updates in circumstances where it is known
* that there is no work to do.
*/
disabled?: boolean;

/**
* The amount of time in milliseconds that should pass between checking for updates.
*/
interval?: number;
}

/**
* useTime allows a component to rerender over time without a corresponding state change.
* An example could be a relative timestamp (eg. "in 5 minutes") that should count down as it
* approaches.
*/
export function useTime<T>(func: () => T, options: UseTimeOptions = {}): T {
const [computedValue, setComputedValue] = useState(() => func());
const { disabled = false, interval = 1000 } = options;

const thunk = useEffectEvent(func);

useEffect(() => {
if (disabled) {
return;
}

const handle = setInterval(() => {
setComputedValue(() => thunk());
}, interval);

return () => {
clearInterval(handle);
};
}, [thunk, disabled, interval]);

return computedValue;
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,7 @@ export const WorkspaceOutdatedTooltipContent: FC<TooltipProps> = ({
<div css={styles.bold}>Message</div>
<div>
{activeVersion ? (
activeVersion.message === "" ? (
"No message"
) : (
activeVersion.message
)
activeVersion.message || "No message"
) : (
<Skeleton variant="text" height={20} width={150} />
)}
Expand Down
45 changes: 45 additions & 0 deletions site/src/modules/workspaces/activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import dayjs from "dayjs";
import type { Workspace } from "api/typesGenerated";

export type WorkspaceActivityStatus =
| "ready"
| "connected"
| "inactive"
| "notConnected"
| "notRunning";

export function getWorkspaceActivityStatus(
workspace: Workspace,
): WorkspaceActivityStatus {
const builtAt = dayjs(workspace.latest_build.created_at);
const usedAt = dayjs(workspace.last_used_at);
const now = dayjs();

if (workspace.latest_build.status !== "running") {
return "notRunning";
}

// This needs to compare to `usedAt` instead of `now`, because the "grace period" for
// marking a workspace as "Connected" is a lot longer. If you compared `builtAt` to `now`,
// you could end up switching from "Ready" to "Connected" without ever actually connecting.
const isBuiltRecently = builtAt.isAfter(usedAt.subtract(1, "second"));
// By default, agents report connection stats every 30 seconds, so 2 minutes should be
// plenty. Disconnection will be reflected relatively-quickly
const isUsedRecently = usedAt.isAfter(now.subtract(2, "minute"));

// If the build is still "fresh", it'll be a while before the `last_used_at` gets bumped in
// a significant way by the agent, so just label it as ready instead of connected.
// Wait until `last_used_at` is after the time that the build finished, _and_ still
// make sure to check that it's recent, so that we don't show "Ready" indefinitely.
if (isUsedRecently && isBuiltRecently && workspace.health.healthy) {
return "ready";
}

if (isUsedRecently) {
return "connected";
}

// TODO: It'd be nice if we could differentiate between "connected but inactive" and
// "not connected", but that will require some relatively substantial backend work.
return "inactive";
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ describe("AccountPage", () => {
Promise.resolve({
id: userId,
email: "user@coder.com",
created_at: new Date().toString(),
created_at: new Date().toISOString(),
status: "active",
organization_ids: ["123"],
roles: [],
avatar_url: "",
last_seen_at: new Date().toString(),
last_seen_at: new Date().toISOString(),
login_type: "password",
theme_preference: "",
...data,
Expand Down
31 changes: 19 additions & 12 deletions site/src/pages/WorkspacePage/WorkspaceScheduleControls.test.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { render, screen } from "@testing-library/react";
import { ThemeProvider } from "contexts/ThemeProvider";
import userEvent from "@testing-library/user-event";
import { type FC } from "react";
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
import { MockWorkspace } from "testHelpers/entities";
import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { server } from "testHelpers/server";
import { rest } from "msw";
import dayjs from "dayjs";
import { rest } from "msw";
import * as API from "api/api";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import { ThemeProvider } from "contexts/ThemeProvider";
import { MockTemplate, MockWorkspace } from "testHelpers/entities";
import { server } from "testHelpers/server";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls";

const Wrapper = () => {
const Wrapper: FC = () => {
const { data: workspace } = useQuery(
workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name),
);
Expand All @@ -21,7 +22,13 @@ const Wrapper = () => {
return null;
}

return <WorkspaceScheduleControls workspace={workspace} canUpdateSchedule />;
return (
<WorkspaceScheduleControls
workspace={workspace}
template={MockTemplate}
canUpdateSchedule
/>
);
};

const BASE_DEADLINE = dayjs().add(3, "hour");
Expand Down Expand Up @@ -75,7 +82,7 @@ test("add 3 hours to deadline", async () => {
await screen.findByText(
"Workspace shutdown time has been successfully updated.",
);
expect(screen.getByText("Stop in 6 hours")).toBeInTheDocument();
expect(await screen.findByText("Stop in 6 hours")).toBeInTheDocument();

// Mocks are used here because the 'usedDeadline' is a dayjs object, which
// can't be directly compared.
Expand All @@ -87,7 +94,7 @@ test("add 3 hours to deadline", async () => {
);
});

test("remove 3 hours to deadline", async () => {
test("remove 2 hours to deadline", async () => {
const user = userEvent.setup();
const updateDeadlineSpy = jest
.spyOn(API, "putWorkspaceExtension")
Expand All @@ -103,7 +110,7 @@ test("remove 3 hours to deadline", async () => {
await screen.findByText(
"Workspace shutdown time has been successfully updated.",
);
expect(screen.getByText("Stop in an hour")).toBeInTheDocument();
expect(await screen.findByText("Stop in an hour")).toBeInTheDocument();

// Mocks are used here because the 'usedDeadline' is a dayjs object, which
// can't be directly compared.
Expand Down
Loading