mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
feat: add activity status and autostop reason to workspace overview (#11987)
This commit is contained in:
committed by
GitHub
parent
e53d8bdb50
commit
d37b131426
@ -114,6 +114,7 @@ func New(opts Options) *API {
|
|||||||
api.StatsAPI = &StatsAPI{
|
api.StatsAPI = &StatsAPI{
|
||||||
AgentFn: api.agent,
|
AgentFn: api.agent,
|
||||||
Database: opts.Database,
|
Database: opts.Database,
|
||||||
|
Pubsub: opts.Pubsub,
|
||||||
Log: opts.Log,
|
Log: opts.Log,
|
||||||
StatsBatcher: opts.StatsBatcher,
|
StatsBatcher: opts.StatsBatcher,
|
||||||
TemplateScheduleStore: opts.TemplateScheduleStore,
|
TemplateScheduleStore: opts.TemplateScheduleStore,
|
||||||
|
@ -16,8 +16,10 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/autobuild"
|
"github.com/coder/coder/v2/coderd/autobuild"
|
||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"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/prometheusmetrics"
|
||||||
"github.com/coder/coder/v2/coderd/schedule"
|
"github.com/coder/coder/v2/coderd/schedule"
|
||||||
|
"github.com/coder/coder/v2/codersdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StatsBatcher interface {
|
type StatsBatcher interface {
|
||||||
@ -27,6 +29,7 @@ type StatsBatcher interface {
|
|||||||
type StatsAPI struct {
|
type StatsAPI struct {
|
||||||
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
AgentFn func(context.Context) (database.WorkspaceAgent, error)
|
||||||
Database database.Store
|
Database database.Store
|
||||||
|
Pubsub pubsub.Pubsub
|
||||||
Log slog.Logger
|
Log slog.Logger
|
||||||
StatsBatcher StatsBatcher
|
StatsBatcher StatsBatcher
|
||||||
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore]
|
||||||
@ -130,5 +133,16 @@ func (a *StatsAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateStatsR
|
|||||||
return nil, xerrors.Errorf("update stats in database: %w", err)
|
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
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *StatsAPI) publishWorkspaceAgentStats(ctx context.Context, workspaceID uuid.UUID) {
|
||||||
|
err := a.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(workspaceID), codersdk.WorkspaceNotifyDescriptionAgentStatsOnly)
|
||||||
|
if err != nil {
|
||||||
|
a.Log.Warn(ctx, "failed to publish workspace agent stats",
|
||||||
|
slog.F("workspace_id", workspaceID), slog.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package agentapi_test
|
package agentapi_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"sync"
|
"sync"
|
||||||
@ -19,8 +20,11 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbmock"
|
"github.com/coder/coder/v2/coderd/database/dbmock"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"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/prometheusmetrics"
|
||||||
"github.com/coder/coder/v2/coderd/schedule"
|
"github.com/coder/coder/v2/coderd/schedule"
|
||||||
|
"github.com/coder/coder/v2/codersdk"
|
||||||
|
"github.com/coder/coder/v2/testutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type statsBatcher struct {
|
type statsBatcher struct {
|
||||||
@ -80,6 +84,8 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
var (
|
var (
|
||||||
now = dbtime.Now()
|
now = dbtime.Now()
|
||||||
dbM = dbmock.NewMockStore(gomock.NewController(t))
|
dbM = dbmock.NewMockStore(gomock.NewController(t))
|
||||||
|
ps = pubsub.NewInMemory()
|
||||||
|
|
||||||
templateScheduleStore = schedule.MockTemplateScheduleStore{
|
templateScheduleStore = schedule.MockTemplateScheduleStore{
|
||||||
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
||||||
panic("should not be called")
|
panic("should not be called")
|
||||||
@ -125,6 +131,7 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
return agent, nil
|
return agent, nil
|
||||||
},
|
},
|
||||||
Database: dbM,
|
Database: dbM,
|
||||||
|
Pubsub: ps,
|
||||||
StatsBatcher: batcher,
|
StatsBatcher: batcher,
|
||||||
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
|
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
|
||||||
AgentStatsRefreshInterval: 10 * time.Second,
|
AgentStatsRefreshInterval: 10 * time.Second,
|
||||||
@ -164,6 +171,15 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
// User gets fetched to hit the UpdateAgentMetricsFn.
|
// User gets fetched to hit the UpdateAgentMetricsFn.
|
||||||
dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
|
dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
|
||||||
|
|
||||||
|
// Ensure that pubsub notifications are sent.
|
||||||
|
publishAgentStats := make(chan bool)
|
||||||
|
ps.Subscribe(codersdk.WorkspaceNotifyChannel(workspace.ID), func(_ context.Context, description []byte) {
|
||||||
|
go func() {
|
||||||
|
publishAgentStats <- bytes.Equal(description, codersdk.WorkspaceNotifyDescriptionAgentStatsOnly)
|
||||||
|
close(publishAgentStats)
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
|
||||||
resp, err := api.UpdateStats(context.Background(), req)
|
resp, err := api.UpdateStats(context.Background(), req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, &agentproto.UpdateStatsResponse{
|
require.Equal(t, &agentproto.UpdateStatsResponse{
|
||||||
@ -179,7 +195,13 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
require.Equal(t, user.ID, batcher.lastUserID)
|
require.Equal(t, user.ID, batcher.lastUserID)
|
||||||
require.Equal(t, workspace.ID, batcher.lastWorkspaceID)
|
require.Equal(t, workspace.ID, batcher.lastWorkspaceID)
|
||||||
require.Equal(t, req.Stats, batcher.lastStats)
|
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 wasAgentStatsOnly := <-publishAgentStats:
|
||||||
|
require.Equal(t, wasAgentStatsOnly, true)
|
||||||
|
}
|
||||||
require.True(t, updateAgentMetricsFnCalled)
|
require.True(t, updateAgentMetricsFnCalled)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -189,6 +211,7 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
var (
|
var (
|
||||||
now = dbtime.Now()
|
now = dbtime.Now()
|
||||||
dbM = dbmock.NewMockStore(gomock.NewController(t))
|
dbM = dbmock.NewMockStore(gomock.NewController(t))
|
||||||
|
ps = pubsub.NewInMemory()
|
||||||
templateScheduleStore = schedule.MockTemplateScheduleStore{
|
templateScheduleStore = schedule.MockTemplateScheduleStore{
|
||||||
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
||||||
panic("should not be called")
|
panic("should not be called")
|
||||||
@ -214,6 +237,7 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
return agent, nil
|
return agent, nil
|
||||||
},
|
},
|
||||||
Database: dbM,
|
Database: dbM,
|
||||||
|
Pubsub: ps,
|
||||||
StatsBatcher: batcher,
|
StatsBatcher: batcher,
|
||||||
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
|
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
|
||||||
AgentStatsRefreshInterval: 10 * time.Second,
|
AgentStatsRefreshInterval: 10 * time.Second,
|
||||||
@ -244,7 +268,8 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
dbM = dbmock.NewMockStore(gomock.NewController(t))
|
db = dbmock.NewMockStore(gomock.NewController(t))
|
||||||
|
ps = pubsub.NewInMemory()
|
||||||
req = &agentproto.UpdateStatsRequest{
|
req = &agentproto.UpdateStatsRequest{
|
||||||
Stats: &agentproto.Stats{
|
Stats: &agentproto.Stats{
|
||||||
ConnectionsByProto: map[string]int64{}, // len() == 0
|
ConnectionsByProto: map[string]int64{}, // len() == 0
|
||||||
@ -255,7 +280,8 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||||
return agent, nil
|
return agent, nil
|
||||||
},
|
},
|
||||||
Database: dbM,
|
Database: db,
|
||||||
|
Pubsub: ps,
|
||||||
StatsBatcher: nil, // should not be called
|
StatsBatcher: nil, // should not be called
|
||||||
TemplateScheduleStore: nil, // should not be called
|
TemplateScheduleStore: nil, // should not be called
|
||||||
AgentStatsRefreshInterval: 10 * time.Second,
|
AgentStatsRefreshInterval: 10 * time.Second,
|
||||||
@ -290,7 +316,9 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
nextAutostart := now.Add(30 * time.Minute).UTC() // always sent to DB as UTC
|
nextAutostart := now.Add(30 * time.Minute).UTC() // always sent to DB as UTC
|
||||||
|
|
||||||
var (
|
var (
|
||||||
dbM = dbmock.NewMockStore(gomock.NewController(t))
|
db = dbmock.NewMockStore(gomock.NewController(t))
|
||||||
|
ps = pubsub.NewInMemory()
|
||||||
|
|
||||||
templateScheduleStore = schedule.MockTemplateScheduleStore{
|
templateScheduleStore = schedule.MockTemplateScheduleStore{
|
||||||
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
GetFn: func(context.Context, database.Store, uuid.UUID) (schedule.TemplateScheduleOptions, error) {
|
||||||
return schedule.TemplateScheduleOptions{
|
return schedule.TemplateScheduleOptions{
|
||||||
@ -321,7 +349,8 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
AgentFn: func(context.Context) (database.WorkspaceAgent, error) {
|
||||||
return agent, nil
|
return agent, nil
|
||||||
},
|
},
|
||||||
Database: dbM,
|
Database: db,
|
||||||
|
Pubsub: ps,
|
||||||
StatsBatcher: batcher,
|
StatsBatcher: batcher,
|
||||||
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
|
TemplateScheduleStore: templateScheduleStorePtr(templateScheduleStore),
|
||||||
AgentStatsRefreshInterval: 15 * time.Second,
|
AgentStatsRefreshInterval: 15 * time.Second,
|
||||||
@ -341,26 +370,26 @@ func TestUpdateStates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Workspace gets fetched.
|
// Workspace gets fetched.
|
||||||
dbM.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{
|
db.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agent.ID).Return(database.GetWorkspaceByAgentIDRow{
|
||||||
Workspace: workspace,
|
Workspace: workspace,
|
||||||
TemplateName: template.Name,
|
TemplateName: template.Name,
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|
||||||
// We expect an activity bump because ConnectionCount > 0. However, the
|
// We expect an activity bump because ConnectionCount > 0. However, the
|
||||||
// next autostart time will be set on the bump.
|
// next autostart time will be set on the bump.
|
||||||
dbM.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
|
db.EXPECT().ActivityBumpWorkspace(gomock.Any(), database.ActivityBumpWorkspaceParams{
|
||||||
WorkspaceID: workspace.ID,
|
WorkspaceID: workspace.ID,
|
||||||
NextAutostart: nextAutostart,
|
NextAutostart: nextAutostart,
|
||||||
}).Return(nil)
|
}).Return(nil)
|
||||||
|
|
||||||
// Workspace last used at gets bumped.
|
// Workspace last used at gets bumped.
|
||||||
dbM.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{
|
db.EXPECT().UpdateWorkspaceLastUsedAt(gomock.Any(), database.UpdateWorkspaceLastUsedAtParams{
|
||||||
ID: workspace.ID,
|
ID: workspace.ID,
|
||||||
LastUsedAt: now,
|
LastUsedAt: now,
|
||||||
}).Return(nil)
|
}).Return(nil)
|
||||||
|
|
||||||
// User gets fetched to hit the UpdateAgentMetricsFn.
|
// User gets fetched to hit the UpdateAgentMetricsFn.
|
||||||
dbM.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
|
db.EXPECT().GetUserByID(gomock.Any(), user.ID).Return(user, nil)
|
||||||
|
|
||||||
resp, err := api.UpdateStats(context.Background(), req)
|
resp, err := api.UpdateStats(context.Background(), req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package coderd
|
package coderd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@ -1343,7 +1344,48 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) {
|
|||||||
<-senderClosed
|
<-senderClosed
|
||||||
}()
|
}()
|
||||||
|
|
||||||
sendUpdate := func(_ context.Context, _ []byte) {
|
sendUpdate := func(_ context.Context, description []byte) {
|
||||||
|
// The agent stats get updated frequently, so we treat these as a special case and only
|
||||||
|
// send a partial update. We primarily care about updating the `last_used_at` and
|
||||||
|
// `latest_build.deadline` properties.
|
||||||
|
if bytes.Equal(description, codersdk.WorkspaceNotifyDescriptionAgentStatsOnly) {
|
||||||
|
workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
|
||||||
|
if err != nil {
|
||||||
|
_ = sendEvent(ctx, codersdk.ServerSentEvent{
|
||||||
|
Type: codersdk.ServerSentEventTypeError,
|
||||||
|
Data: codersdk.Response{
|
||||||
|
Message: "Internal error fetching workspace.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||||||
|
if err != nil {
|
||||||
|
_ = sendEvent(ctx, codersdk.ServerSentEvent{
|
||||||
|
Type: codersdk.ServerSentEventTypeError,
|
||||||
|
Data: codersdk.Response{
|
||||||
|
Message: "Internal error fetching workspace build.",
|
||||||
|
Detail: err.Error(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = sendEvent(ctx, codersdk.ServerSentEvent{
|
||||||
|
Type: codersdk.ServerSentEventTypePartial,
|
||||||
|
Data: struct {
|
||||||
|
database.Workspace
|
||||||
|
LatestBuild database.WorkspaceBuild `json:"latest_build"`
|
||||||
|
}{
|
||||||
|
Workspace: workspace,
|
||||||
|
LatestBuild: workspaceBuild,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
|
workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sendEvent(ctx, codersdk.ServerSentEvent{
|
_ = sendEvent(ctx, codersdk.ServerSentEvent{
|
||||||
|
@ -22,6 +22,7 @@ type ServerSentEventType string
|
|||||||
const (
|
const (
|
||||||
ServerSentEventTypePing ServerSentEventType = "ping"
|
ServerSentEventTypePing ServerSentEventType = "ping"
|
||||||
ServerSentEventTypeData ServerSentEventType = "data"
|
ServerSentEventTypeData ServerSentEventType = "data"
|
||||||
|
ServerSentEventTypePartial ServerSentEventType = "partial"
|
||||||
ServerSentEventTypeError ServerSentEventType = "error"
|
ServerSentEventTypeError ServerSentEventType = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -497,6 +497,8 @@ func (c *Client) UnfavoriteWorkspace(ctx context.Context, workspaceID uuid.UUID)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var WorkspaceNotifyDescriptionAgentStatsOnly = []byte("agentStatsOnly")
|
||||||
|
|
||||||
// WorkspaceNotifyChannel is the PostgreSQL NOTIFY
|
// WorkspaceNotifyChannel is the PostgreSQL NOTIFY
|
||||||
// channel to listen for updates on. The payload is empty,
|
// channel to listen for updates on. The payload is empty,
|
||||||
// because the size of a workspace payload can be very large.
|
// because the size of a workspace payload can be very large.
|
||||||
|
3
site/src/api/typesGenerated.ts
generated
3
site/src/api/typesGenerated.ts
generated
@ -2116,10 +2116,11 @@ export const ResourceTypes: ResourceType[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// From codersdk/serversentevents.go
|
// From codersdk/serversentevents.go
|
||||||
export type ServerSentEventType = "data" | "error" | "ping";
|
export type ServerSentEventType = "data" | "error" | "partial" | "ping";
|
||||||
export const ServerSentEventTypes: ServerSentEventType[] = [
|
export const ServerSentEventTypes: ServerSentEventType[] = [
|
||||||
"data",
|
"data",
|
||||||
"error",
|
"error",
|
||||||
|
"partial",
|
||||||
"ping",
|
"ping",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
32
site/src/hooks/useTime.ts
Normal file
32
site/src/hooks/useTime.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* This hook should only be used in components that are very simple, and that will not
|
||||||
|
* create a lot of unnecessary work for the reconciler. Given that this hook will result in
|
||||||
|
* the entire subtree being rerendered on a frequent interval, it's important that the subtree
|
||||||
|
* remains small.
|
||||||
|
*
|
||||||
|
* @param active Can optionally be set to false in circumstances where updating over time is
|
||||||
|
* not necessary.
|
||||||
|
*/
|
||||||
|
export function useTime(active: boolean = true) {
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setTick((i) => i + 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
}
|
45
site/src/modules/workspaces/activity.ts
Normal file
45
site/src/modules/workspaces/activity.ts
Normal file
@ -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";
|
||||||
|
}
|
@ -29,12 +29,12 @@ describe("AccountPage", () => {
|
|||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
id: userId,
|
id: userId,
|
||||||
email: "user@coder.com",
|
email: "user@coder.com",
|
||||||
created_at: new Date().toString(),
|
created_at: new Date().toISOString(),
|
||||||
status: "active",
|
status: "active",
|
||||||
organization_ids: ["123"],
|
organization_ids: ["123"],
|
||||||
roles: [],
|
roles: [],
|
||||||
avatar_url: "",
|
avatar_url: "",
|
||||||
last_seen_at: new Date().toString(),
|
last_seen_at: new Date().toISOString(),
|
||||||
login_type: "password",
|
login_type: "password",
|
||||||
theme_preference: "",
|
theme_preference: "",
|
||||||
...data,
|
...data,
|
||||||
|
60
site/src/pages/WorkspacePage/ActivityStatus.tsx
Normal file
60
site/src/pages/WorkspacePage/ActivityStatus.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { type FC } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import type { Workspace } from "api/typesGenerated";
|
||||||
|
import { useTime } from "hooks/useTime";
|
||||||
|
import type { WorkspaceActivityStatus } from "modules/workspaces/activity";
|
||||||
|
import { Pill } from "components/Pill/Pill";
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
interface ActivityStatusProps {
|
||||||
|
workspace: Workspace;
|
||||||
|
status: WorkspaceActivityStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ActivityStatus: FC<ActivityStatusProps> = ({
|
||||||
|
workspace,
|
||||||
|
status,
|
||||||
|
}) => {
|
||||||
|
const usedAt = dayjs(workspace.last_used_at).tz(dayjs.tz.guess());
|
||||||
|
|
||||||
|
// Don't bother updating if `status` will need to change before anything can happen.
|
||||||
|
useTime(status === "ready" || status === "connected");
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "ready":
|
||||||
|
return <Pill type="active">Ready</Pill>;
|
||||||
|
case "connected":
|
||||||
|
return <Pill type="active">Connected</Pill>;
|
||||||
|
case "inactive":
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
This workspace was last active on{" "}
|
||||||
|
{usedAt.format("MMMM D [at] h:mm A")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pill type="inactive">Inactive</Pill>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
case "notConnected":
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
<>
|
||||||
|
This workspace was last active on{" "}
|
||||||
|
{usedAt.format("MMMM D [at] h:mm A")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pill type="inactive">Not connected</Pill>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
@ -1,6 +1,7 @@
|
|||||||
import { type FC, useEffect } from "react";
|
import { type FC, useEffect } from "react";
|
||||||
import { useQuery, useQueryClient } from "react-query";
|
import { useQuery, useQueryClient } from "react-query";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import merge from "lodash/merge";
|
||||||
import { watchWorkspace } from "api/api";
|
import { watchWorkspace } from "api/api";
|
||||||
import type { Workspace } from "api/typesGenerated";
|
import type { Workspace } from "api/typesGenerated";
|
||||||
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
|
import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
|
||||||
@ -76,6 +77,15 @@ export const WorkspacePage: FC = () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const getWorkspaceData = useEffectEvent(() => {
|
||||||
|
if (!workspace) {
|
||||||
|
throw new Error("Applying an update for a workspace that is undefined.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryClient.getQueryData(
|
||||||
|
workspaceQueryOptions.queryKey,
|
||||||
|
) as Workspace;
|
||||||
|
});
|
||||||
const workspaceId = workspace?.id;
|
const workspaceId = workspace?.id;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
@ -89,6 +99,15 @@ export const WorkspacePage: FC = () => {
|
|||||||
await updateWorkspaceData(newWorkspaceData);
|
await updateWorkspaceData(newWorkspaceData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener("partial", async (event) => {
|
||||||
|
const newWorkspaceData = JSON.parse(event.data) as Partial<Workspace>;
|
||||||
|
// Merge with a fresh object `{}` as the base, because `merge` uses an in-place algorithm,
|
||||||
|
// and would otherwise mutate the `queryClient`'s internal state.
|
||||||
|
await updateWorkspaceData(
|
||||||
|
merge({}, getWorkspaceData(), newWorkspaceData),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
eventSource.addEventListener("error", (event) => {
|
eventSource.addEventListener("error", (event) => {
|
||||||
console.error("Error on getting workspace changes.", event);
|
console.error("Error on getting workspace changes.", event);
|
||||||
});
|
});
|
||||||
@ -96,7 +115,7 @@ export const WorkspacePage: FC = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
}, [updateWorkspaceData, workspaceId]);
|
}, [updateWorkspaceData, getWorkspaceData, workspaceId]);
|
||||||
|
|
||||||
// Page statuses
|
// Page statuses
|
||||||
const pageError =
|
const pageError =
|
||||||
|
@ -1,18 +1,20 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { ThemeProvider } from "contexts/ThemeProvider";
|
|
||||||
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 userEvent from "@testing-library/user-event";
|
||||||
import { server } from "testHelpers/server";
|
import { type FC } from "react";
|
||||||
import { rest } from "msw";
|
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
|
||||||
|
import { RouterProvider, createMemoryRouter } from "react-router-dom";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { rest } from "msw";
|
||||||
import * as API from "api/api";
|
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 { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
|
||||||
|
import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls";
|
||||||
|
import { getWorkspaceActivityStatus } from "modules/workspaces/activity";
|
||||||
|
|
||||||
const Wrapper = () => {
|
const Wrapper: FC = () => {
|
||||||
const { data: workspace } = useQuery(
|
const { data: workspace } = useQuery(
|
||||||
workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name),
|
workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name),
|
||||||
);
|
);
|
||||||
@ -21,7 +23,14 @@ const Wrapper = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <WorkspaceScheduleControls workspace={workspace} canUpdateSchedule />;
|
return (
|
||||||
|
<WorkspaceScheduleControls
|
||||||
|
workspace={workspace}
|
||||||
|
status={getWorkspaceActivityStatus(workspace)}
|
||||||
|
template={MockTemplate}
|
||||||
|
canUpdateSchedule
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_DEADLINE = dayjs().add(3, "hour");
|
const BASE_DEADLINE = dayjs().add(3, "hour");
|
||||||
|
@ -1,9 +1,18 @@
|
|||||||
import { type Interpolation, type Theme } from "@emotion/react";
|
import { type Interpolation, type Theme } from "@emotion/react";
|
||||||
import Link, { LinkProps } from "@mui/material/Link";
|
import Link, { type LinkProps } from "@mui/material/Link";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import AddIcon from "@mui/icons-material/AddOutlined";
|
||||||
|
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
|
||||||
|
import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import { visuallyHidden } from "@mui/utils";
|
||||||
|
import { type Dayjs } from "dayjs";
|
||||||
import { forwardRef, type FC, useRef } from "react";
|
import { forwardRef, type FC, useRef } from "react";
|
||||||
|
import { useMutation, useQueryClient } from "react-query";
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import { useTime } from "hooks/useTime";
|
||||||
import { isWorkspaceOn } from "utils/workspace";
|
import { isWorkspaceOn } from "utils/workspace";
|
||||||
import type { Workspace } from "api/typesGenerated";
|
import type { Template, Workspace } from "api/typesGenerated";
|
||||||
import {
|
import {
|
||||||
autostartDisplay,
|
autostartDisplay,
|
||||||
autostopDisplay,
|
autostopDisplay,
|
||||||
@ -12,28 +21,60 @@ import {
|
|||||||
getMaxDeadlineChange,
|
getMaxDeadlineChange,
|
||||||
getMinDeadline,
|
getMinDeadline,
|
||||||
} from "utils/schedule";
|
} from "utils/schedule";
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
|
|
||||||
import AddIcon from "@mui/icons-material/AddOutlined";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
|
||||||
import _ from "lodash";
|
|
||||||
import { getErrorMessage } from "api/errors";
|
import { getErrorMessage } from "api/errors";
|
||||||
import {
|
import {
|
||||||
updateDeadline,
|
updateDeadline,
|
||||||
workspaceByOwnerAndNameKey,
|
workspaceByOwnerAndNameKey,
|
||||||
} from "api/queries/workspaces";
|
} from "api/queries/workspaces";
|
||||||
|
import { TopbarData, TopbarIcon } from "components/FullPageLayout/Topbar";
|
||||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||||
import { useMutation, useQueryClient } from "react-query";
|
import type { WorkspaceActivityStatus } from "modules/workspaces/activity";
|
||||||
import { Dayjs } from "dayjs";
|
|
||||||
import { visuallyHidden } from "@mui/utils";
|
export interface WorkspaceScheduleProps {
|
||||||
|
status: WorkspaceActivityStatus;
|
||||||
|
workspace: Workspace;
|
||||||
|
template: Template;
|
||||||
|
canUpdateWorkspace: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
|
||||||
|
status,
|
||||||
|
workspace,
|
||||||
|
template,
|
||||||
|
canUpdateWorkspace,
|
||||||
|
}) => {
|
||||||
|
if (!shouldDisplayScheduleControls(workspace, status)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TopbarData>
|
||||||
|
<TopbarIcon>
|
||||||
|
<Tooltip title="Schedule">
|
||||||
|
<ScheduleOutlined aria-label="Schedule" />
|
||||||
|
</Tooltip>
|
||||||
|
</TopbarIcon>
|
||||||
|
<WorkspaceScheduleControls
|
||||||
|
workspace={workspace}
|
||||||
|
status={status}
|
||||||
|
template={template}
|
||||||
|
canUpdateSchedule={canUpdateWorkspace}
|
||||||
|
/>
|
||||||
|
</TopbarData>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export interface WorkspaceScheduleControlsProps {
|
export interface WorkspaceScheduleControlsProps {
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
|
status: WorkspaceActivityStatus;
|
||||||
|
template: Template;
|
||||||
canUpdateSchedule: boolean;
|
canUpdateSchedule: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
|
export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
|
||||||
workspace,
|
workspace,
|
||||||
|
status,
|
||||||
|
template,
|
||||||
canUpdateSchedule,
|
canUpdateSchedule,
|
||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@ -90,7 +131,11 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div css={styles.scheduleValue} data-testid="schedule-controls">
|
<div css={styles.scheduleValue} data-testid="schedule-controls">
|
||||||
{isWorkspaceOn(workspace) ? (
|
{isWorkspaceOn(workspace) ? (
|
||||||
<AutoStopDisplay workspace={workspace} />
|
<AutoStopDisplay
|
||||||
|
workspace={workspace}
|
||||||
|
status={status}
|
||||||
|
template={template}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ScheduleSettingsLink>
|
<ScheduleSettingsLink>
|
||||||
Starts at {autostartDisplay(workspace.autostart_schedule)}
|
Starts at {autostartDisplay(workspace.autostart_schedule)}
|
||||||
@ -133,28 +178,41 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
|
|||||||
|
|
||||||
interface AutoStopDisplayProps {
|
interface AutoStopDisplayProps {
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
|
status: WorkspaceActivityStatus;
|
||||||
|
template: Template;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace }) => {
|
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({
|
||||||
const display = autostopDisplay(workspace);
|
workspace,
|
||||||
|
status,
|
||||||
if (display.tooltip) {
|
template,
|
||||||
return (
|
}) => {
|
||||||
<Tooltip title={display.tooltip}>
|
useTime();
|
||||||
<ScheduleSettingsLink
|
const { message, tooltip, danger } = autostopDisplay(
|
||||||
css={(theme) => ({
|
workspace,
|
||||||
color: isShutdownSoon(workspace)
|
status,
|
||||||
? `${theme.palette.warning.light} !important`
|
template,
|
||||||
: undefined,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Stop {display.message}
|
|
||||||
</ScheduleSettingsLink>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const display = (
|
||||||
|
<ScheduleSettingsLink
|
||||||
|
data-testid="schedule-controls-autostop"
|
||||||
|
css={
|
||||||
|
danger &&
|
||||||
|
((theme) => ({
|
||||||
|
color: `${theme.roles.danger.fill.outline} !important`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</ScheduleSettingsLink>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
return <Tooltip title={tooltip}>{display}</Tooltip>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ScheduleSettingsLink>{display.message}</ScheduleSettingsLink>;
|
return display;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>(
|
const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>(
|
||||||
@ -190,22 +248,13 @@ export const canEditDeadline = (workspace: Workspace): boolean => {
|
|||||||
|
|
||||||
export const shouldDisplayScheduleControls = (
|
export const shouldDisplayScheduleControls = (
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
|
status: WorkspaceActivityStatus,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const willAutoStop = isWorkspaceOn(workspace) && hasDeadline(workspace);
|
const willAutoStop = isWorkspaceOn(workspace) && hasDeadline(workspace);
|
||||||
const willAutoStart = !isWorkspaceOn(workspace) && hasAutoStart(workspace);
|
const willAutoStart = !isWorkspaceOn(workspace) && hasAutoStart(workspace);
|
||||||
return willAutoStop || willAutoStart;
|
const hasActivity =
|
||||||
};
|
status === "connected" && !workspace.latest_build.max_deadline;
|
||||||
|
return (willAutoStop || willAutoStart) && !hasActivity;
|
||||||
const isShutdownSoon = (workspace: Workspace): boolean => {
|
|
||||||
const deadline = workspace.latest_build.deadline;
|
|
||||||
if (!deadline) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const deadlineDate = new Date(deadline);
|
|
||||||
const now = new Date();
|
|
||||||
const diff = deadlineDate.getTime() - now.getTime();
|
|
||||||
const oneHour = 1000 * 60 * 60;
|
|
||||||
return diff < oneHour;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Meta, StoryObj } from "@storybook/react";
|
import { Meta, StoryObj } from "@storybook/react";
|
||||||
|
import { expect, userEvent, waitFor, within, screen } from "@storybook/test";
|
||||||
import {
|
import {
|
||||||
MockTemplate,
|
MockTemplate,
|
||||||
MockTemplateVersion,
|
MockTemplateVersion,
|
||||||
@ -7,7 +8,7 @@ import {
|
|||||||
} from "testHelpers/entities";
|
} from "testHelpers/entities";
|
||||||
import { WorkspaceTopbar } from "./WorkspaceTopbar";
|
import { WorkspaceTopbar } from "./WorkspaceTopbar";
|
||||||
import { withDashboardProvider } from "testHelpers/storybook";
|
import { withDashboardProvider } from "testHelpers/storybook";
|
||||||
import { addDays } from "date-fns";
|
import { addDays, addHours, addMinutes } from "date-fns";
|
||||||
import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota";
|
import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota";
|
||||||
|
|
||||||
// We want a workspace without a deadline to not pollute the screenshot
|
// We want a workspace without a deadline to not pollute the screenshot
|
||||||
@ -42,12 +43,94 @@ export const Example: Story = {};
|
|||||||
export const Outdated: Story = {
|
export const Outdated: Story = {
|
||||||
args: {
|
args: {
|
||||||
workspace: {
|
workspace: {
|
||||||
...MockWorkspace,
|
...baseWorkspace,
|
||||||
outdated: true,
|
outdated: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Ready: Story = {
|
||||||
|
args: {
|
||||||
|
workspace: {
|
||||||
|
...baseWorkspace,
|
||||||
|
get last_used_at() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
},
|
||||||
|
latest_build: {
|
||||||
|
...baseWorkspace.latest_build,
|
||||||
|
get created_at() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const ReadyWithDeadline: Story = {
|
||||||
|
args: {
|
||||||
|
workspace: {
|
||||||
|
...MockWorkspace,
|
||||||
|
get last_used_at() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
},
|
||||||
|
latest_build: {
|
||||||
|
...MockWorkspace.latest_build,
|
||||||
|
get created_at() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
},
|
||||||
|
get deadline() {
|
||||||
|
return addHours(new Date(), 8).toISOString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Connected: Story = {
|
||||||
|
args: {
|
||||||
|
workspace: {
|
||||||
|
...baseWorkspace,
|
||||||
|
get last_used_at() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const ConnectedWithDeadline: Story = {
|
||||||
|
args: {
|
||||||
|
workspace: {
|
||||||
|
...MockWorkspace,
|
||||||
|
get last_used_at() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
},
|
||||||
|
latest_build: {
|
||||||
|
...MockWorkspace.latest_build,
|
||||||
|
get deadline() {
|
||||||
|
return addHours(new Date(), 8).toISOString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const ConnectedWithMaxDeadline: Story = {
|
||||||
|
args: {
|
||||||
|
workspace: {
|
||||||
|
...MockWorkspace,
|
||||||
|
get last_used_at() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
},
|
||||||
|
latest_build: {
|
||||||
|
...MockWorkspace.latest_build,
|
||||||
|
get deadline() {
|
||||||
|
return addHours(new Date(), 1).toISOString();
|
||||||
|
},
|
||||||
|
get max_deadline() {
|
||||||
|
return addHours(new Date(), 1).toISOString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const Dormant: Story = {
|
export const Dormant: Story = {
|
||||||
args: {
|
args: {
|
||||||
workspace: {
|
workspace: {
|
||||||
@ -61,7 +144,7 @@ export const Dormant: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WithDeadline: Story = {
|
export const WithExceededDeadline: Story = {
|
||||||
args: {
|
args: {
|
||||||
workspace: {
|
workspace: {
|
||||||
...MockWorkspace,
|
...MockWorkspace,
|
||||||
@ -73,6 +156,88 @@ export const WithDeadline: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WithApproachingDeadline: Story = {
|
||||||
|
args: {
|
||||||
|
workspace: {
|
||||||
|
...MockWorkspace,
|
||||||
|
latest_build: {
|
||||||
|
...MockWorkspace.latest_build,
|
||||||
|
get deadline() {
|
||||||
|
return addMinutes(new Date(), 30).toISOString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, step }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await step("activate hover trigger", async () => {
|
||||||
|
await userEvent.hover(canvas.getByTestId("schedule-controls-autostop"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||||
|
/this workspace has enabled autostop/,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithFarAwayDeadline: Story = {
|
||||||
|
args: {
|
||||||
|
workspace: {
|
||||||
|
...MockWorkspace,
|
||||||
|
latest_build: {
|
||||||
|
...MockWorkspace.latest_build,
|
||||||
|
get deadline() {
|
||||||
|
return addHours(new Date(), 8).toISOString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, step }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await step("activate hover trigger", async () => {
|
||||||
|
await userEvent.hover(canvas.getByTestId("schedule-controls-autostop"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||||
|
/this workspace has enabled autostop/,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithFarAwayDeadlineRequiredByTemplate: Story = {
|
||||||
|
args: {
|
||||||
|
workspace: {
|
||||||
|
...MockWorkspace,
|
||||||
|
latest_build: {
|
||||||
|
...MockWorkspace.latest_build,
|
||||||
|
get deadline() {
|
||||||
|
return addHours(new Date(), 8).toISOString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
...MockTemplate,
|
||||||
|
allow_user_autostop: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement, step }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
await step("activate hover trigger", async () => {
|
||||||
|
await userEvent.hover(canvas.getByTestId("schedule-controls-autostop"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||||
|
/template has an autostop requirement/,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const WithQuota: Story = {
|
export const WithQuota: Story = {
|
||||||
parameters: {
|
parameters: {
|
||||||
queries: [
|
queries: [
|
||||||
|
@ -3,12 +3,16 @@ import Link from "@mui/material/Link";
|
|||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
import DeleteOutline from "@mui/icons-material/DeleteOutline";
|
import DeleteOutline from "@mui/icons-material/DeleteOutline";
|
||||||
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
|
import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined";
|
||||||
import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined";
|
|
||||||
import { useTheme } from "@emotion/react";
|
import { useTheme } from "@emotion/react";
|
||||||
import { type FC } from "react";
|
import { type FC } from "react";
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import { Link as RouterLink } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
import type * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
|
import { workspaceQuota } from "api/queries/workspaceQuota";
|
||||||
|
import { WorkspaceStatusBadge } from "modules/workspaces/WorkspaceStatusBadge/WorkspaceStatusBadge";
|
||||||
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
|
import { getWorkspaceActivityStatus } from "modules/workspaces/activity";
|
||||||
|
import { displayDormantDeletion } from "utils/dormant";
|
||||||
import {
|
import {
|
||||||
Topbar,
|
Topbar,
|
||||||
TopbarAvatar,
|
TopbarAvatar,
|
||||||
@ -17,10 +21,6 @@ import {
|
|||||||
TopbarIcon,
|
TopbarIcon,
|
||||||
TopbarIconButton,
|
TopbarIconButton,
|
||||||
} from "components/FullPageLayout/Topbar";
|
} from "components/FullPageLayout/Topbar";
|
||||||
import { WorkspaceStatusBadge } from "modules/workspaces/WorkspaceStatusBadge/WorkspaceStatusBadge";
|
|
||||||
import { workspaceQuota } from "api/queries/workspaceQuota";
|
|
||||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
|
||||||
import { displayDormantDeletion } from "utils/dormant";
|
|
||||||
import { Popover, PopoverTrigger } from "components/Popover/Popover";
|
import { Popover, PopoverTrigger } from "components/Popover/Popover";
|
||||||
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
|
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
|
||||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||||
@ -28,11 +28,9 @@ import { ExternalAvatar } from "components/Avatar/Avatar";
|
|||||||
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||||
import { WorkspaceActions } from "./WorkspaceActions/WorkspaceActions";
|
import { WorkspaceActions } from "./WorkspaceActions/WorkspaceActions";
|
||||||
import { WorkspaceNotifications } from "./WorkspaceNotifications/WorkspaceNotifications";
|
import { WorkspaceNotifications } from "./WorkspaceNotifications/WorkspaceNotifications";
|
||||||
import {
|
|
||||||
WorkspaceScheduleControls,
|
|
||||||
shouldDisplayScheduleControls,
|
|
||||||
} from "./WorkspaceScheduleControls";
|
|
||||||
import { WorkspacePermissions } from "./permissions";
|
import { WorkspacePermissions } from "./permissions";
|
||||||
|
import { ActivityStatus } from "./ActivityStatus";
|
||||||
|
import { WorkspaceSchedule } from "./WorkspaceScheduleControls";
|
||||||
|
|
||||||
export type WorkspaceError =
|
export type WorkspaceError =
|
||||||
| "getBuildsError"
|
| "getBuildsError"
|
||||||
@ -110,6 +108,8 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
|
|||||||
allowAdvancedScheduling,
|
allowAdvancedScheduling,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activityStatus = getWorkspaceActivityStatus(workspace);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Topbar css={{ gridArea: "topbar" }}>
|
<Topbar css={{ gridArea: "topbar" }}>
|
||||||
<Tooltip title="Back to workspaces">
|
<Tooltip title="Back to workspaces">
|
||||||
@ -200,6 +200,15 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
|
|||||||
</Popover>
|
</Popover>
|
||||||
</TopbarData>
|
</TopbarData>
|
||||||
|
|
||||||
|
<ActivityStatus workspace={workspace} status={activityStatus} />
|
||||||
|
|
||||||
|
<WorkspaceSchedule
|
||||||
|
status={activityStatus}
|
||||||
|
workspace={workspace}
|
||||||
|
template={template}
|
||||||
|
canUpdateWorkspace={canUpdateWorkspace}
|
||||||
|
/>
|
||||||
|
|
||||||
{shouldDisplayDormantData && (
|
{shouldDisplayDormantData && (
|
||||||
<TopbarData>
|
<TopbarData>
|
||||||
<TopbarIcon>
|
<TopbarIcon>
|
||||||
@ -219,20 +228,6 @@ export const WorkspaceTopbar: FC<WorkspaceProps> = ({
|
|||||||
</TopbarData>
|
</TopbarData>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldDisplayScheduleControls(workspace) && (
|
|
||||||
<TopbarData>
|
|
||||||
<TopbarIcon>
|
|
||||||
<Tooltip title="Schedule">
|
|
||||||
<ScheduleOutlined aria-label="Schedule" />
|
|
||||||
</Tooltip>
|
|
||||||
</TopbarIcon>
|
|
||||||
<WorkspaceScheduleControls
|
|
||||||
workspace={workspace}
|
|
||||||
canUpdateSchedule={canUpdateWorkspace}
|
|
||||||
/>
|
|
||||||
</TopbarData>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{quota && quota.budget > 0 && (
|
{quota && quota.budget > 0 && (
|
||||||
<TopbarData>
|
<TopbarData>
|
||||||
<TopbarIcon>
|
<TopbarIcon>
|
||||||
|
@ -80,11 +80,7 @@ export const WorkspaceSchedulePage: FC = () => {
|
|||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{pageTitle([workspaceName, "Schedule"])}</title>
|
<title>{pageTitle([workspaceName, "Schedule"])}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<PageHeader
|
<PageHeader css={{ paddingTop: 0 }}>
|
||||||
css={{
|
|
||||||
paddingTop: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PageHeaderTitle>Workspace Schedule</PageHeaderTitle>
|
<PageHeaderTitle>Workspace Schedule</PageHeaderTitle>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
|
import { useTheme } from "@emotion/react";
|
||||||
import { type FC } from "react";
|
import { type FC } from "react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import { useTheme } from "@emotion/react";
|
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { Stack } from "components/Stack/Stack";
|
||||||
|
import { useTime } from "hooks/useTime";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@ -31,6 +32,7 @@ interface LastUsedProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
|
export const LastUsed: FC<LastUsedProps> = ({ lastUsedAt }) => {
|
||||||
|
useTime();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const t = dayjs(lastUsedAt);
|
const t = dayjs(lastUsedAt);
|
||||||
const now = dayjs();
|
const now = dayjs();
|
||||||
|
@ -7,8 +7,8 @@ export default {
|
|||||||
outline: colors.orange[500],
|
outline: colors.orange[500],
|
||||||
text: colors.orange[50],
|
text: colors.orange[50],
|
||||||
fill: {
|
fill: {
|
||||||
solid: colors.orange[700],
|
solid: colors.orange[500],
|
||||||
outline: colors.orange[700],
|
outline: colors.orange[400],
|
||||||
text: colors.white,
|
text: colors.white,
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
|
@ -7,8 +7,8 @@ export default {
|
|||||||
outline: colors.orange[600],
|
outline: colors.orange[600],
|
||||||
text: colors.orange[50],
|
text: colors.orange[50],
|
||||||
fill: {
|
fill: {
|
||||||
solid: colors.orange[600],
|
solid: colors.orange[500],
|
||||||
outline: colors.orange[600],
|
outline: colors.orange[400],
|
||||||
text: colors.white,
|
text: colors.white,
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
|
@ -55,10 +55,10 @@ export interface Role {
|
|||||||
|
|
||||||
/** A set of more saturated colors to make things stand out */
|
/** A set of more saturated colors to make things stand out */
|
||||||
fill: {
|
fill: {
|
||||||
/** A saturated color for use as a background, or for text or icons on a neutral background */
|
/** A saturated color for use as a background, or icons on a neutral background */
|
||||||
solid: string;
|
solid: string;
|
||||||
|
|
||||||
/** A color for outlining an area using the solid background color, or for an outlined icon */
|
/** A color for outlining an area using the solid background color, or for text or for an outlined icon */
|
||||||
outline: string;
|
outline: string;
|
||||||
|
|
||||||
/** A color for text when using the `solid` background color */
|
/** A color for text when using the `solid` background color */
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
|
import Link from "@mui/material/Link";
|
||||||
import cronstrue from "cronstrue";
|
import cronstrue from "cronstrue";
|
||||||
import dayjs, { Dayjs } from "dayjs";
|
import cronParser from "cron-parser";
|
||||||
|
import dayjs, { type Dayjs } from "dayjs";
|
||||||
import duration from "dayjs/plugin/duration";
|
import duration from "dayjs/plugin/duration";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import timezone from "dayjs/plugin/timezone";
|
import timezone from "dayjs/plugin/timezone";
|
||||||
import utc from "dayjs/plugin/utc";
|
import utc from "dayjs/plugin/utc";
|
||||||
import { Template, Workspace } from "api/typesGenerated";
|
import { type ReactNode } from "react";
|
||||||
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import type { Template, Workspace } from "api/typesGenerated";
|
||||||
|
import { HelpTooltipTitle } from "components/HelpTooltip/HelpTooltip";
|
||||||
|
import type { WorkspaceActivityStatus } from "modules/workspaces/activity";
|
||||||
import { isWorkspaceOn } from "./workspace";
|
import { isWorkspaceOn } from "./workspace";
|
||||||
import cronParser from "cron-parser";
|
|
||||||
|
|
||||||
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
|
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
|
||||||
// sorted alphabetically.
|
// sorted alphabetically.
|
||||||
@ -90,9 +95,12 @@ export const isShuttingDown = (
|
|||||||
|
|
||||||
export const autostopDisplay = (
|
export const autostopDisplay = (
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
|
activityStatus: WorkspaceActivityStatus,
|
||||||
|
template: Template,
|
||||||
): {
|
): {
|
||||||
message: string;
|
message: ReactNode;
|
||||||
tooltip?: string;
|
tooltip?: ReactNode;
|
||||||
|
danger?: boolean;
|
||||||
} => {
|
} => {
|
||||||
const ttl = workspace.ttl_ms;
|
const ttl = workspace.ttl_ms;
|
||||||
|
|
||||||
@ -103,16 +111,62 @@ export const autostopDisplay = (
|
|||||||
// represent the previously defined ttl. Thus, we always derive from the
|
// represent the previously defined ttl. Thus, we always derive from the
|
||||||
// deadline as the source of truth.
|
// deadline as the source of truth.
|
||||||
|
|
||||||
const deadline = dayjs(workspace.latest_build.deadline).utc();
|
const deadline = dayjs(workspace.latest_build.deadline).tz(
|
||||||
|
dayjs.tz.guess(),
|
||||||
|
);
|
||||||
|
const now = dayjs(workspace.latest_build.deadline);
|
||||||
if (isShuttingDown(workspace, deadline)) {
|
if (isShuttingDown(workspace, deadline)) {
|
||||||
return {
|
return {
|
||||||
message: Language.workspaceShuttingDownLabel,
|
message: Language.workspaceShuttingDownLabel,
|
||||||
};
|
};
|
||||||
} else {
|
} else if (
|
||||||
const deadlineTz = deadline.tz(dayjs.tz.guess());
|
activityStatus === "connected" &&
|
||||||
|
deadline.isBefore(now.add(2, "hour"))
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
message: deadlineTz.fromNow(),
|
message: `Required to stop soon`,
|
||||||
tooltip: deadlineTz.format("MMMM D, YYYY h:mm A"),
|
tooltip: (
|
||||||
|
<>
|
||||||
|
<HelpTooltipTitle>Upcoming stop required</HelpTooltipTitle>
|
||||||
|
This workspace will be required to stop by{" "}
|
||||||
|
{dayjs(workspace.latest_build.max_deadline).format(
|
||||||
|
"MMMM D [at] h:mm A",
|
||||||
|
)}
|
||||||
|
. You can restart your workspace before then to avoid interruption.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
danger: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
let title = (
|
||||||
|
<HelpTooltipTitle>Template Autostop requirement</HelpTooltipTitle>
|
||||||
|
);
|
||||||
|
let reason: ReactNode = ` because the ${template.display_name} template has an autostop requirement.`;
|
||||||
|
if (template.autostop_requirement && template.allow_user_autostop) {
|
||||||
|
title = <HelpTooltipTitle>Autostop schedule</HelpTooltipTitle>;
|
||||||
|
reason = (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
because this workspace has enabled autostop. You can disable
|
||||||
|
autostop from this workspace's{" "}
|
||||||
|
<Link component={RouterLink} to="settings/schedule">
|
||||||
|
schedule settings
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: `Stop ${deadline.fromNow()}`,
|
||||||
|
tooltip: (
|
||||||
|
<>
|
||||||
|
{title}
|
||||||
|
This workspace will be stopped on{" "}
|
||||||
|
{deadline.format("MMMM D [at] h:mm A")}
|
||||||
|
{reason}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
danger: isShutdownSoon(workspace),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (!ttl || ttl < 1) {
|
} else if (!ttl || ttl < 1) {
|
||||||
@ -126,11 +180,23 @@ export const autostopDisplay = (
|
|||||||
// not running. Therefore, we derive from workspace.ttl.
|
// not running. Therefore, we derive from workspace.ttl.
|
||||||
const duration = dayjs.duration(ttl, "milliseconds");
|
const duration = dayjs.duration(ttl, "milliseconds");
|
||||||
return {
|
return {
|
||||||
message: `${duration.humanize()} ${Language.afterStart}`,
|
message: `Stop ${duration.humanize()} ${Language.afterStart}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isShutdownSoon = (workspace: Workspace): boolean => {
|
||||||
|
const deadline = workspace.latest_build.deadline;
|
||||||
|
if (!deadline) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const deadlineDate = new Date(deadline);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = deadlineDate.getTime() - now.getTime();
|
||||||
|
const oneHour = 1000 * 60 * 60;
|
||||||
|
return diff < oneHour;
|
||||||
|
};
|
||||||
|
|
||||||
export const deadlineExtensionMin = dayjs.duration(30, "minutes");
|
export const deadlineExtensionMin = dayjs.duration(30, "minutes");
|
||||||
export const deadlineExtensionMax = dayjs.duration(24, "hours");
|
export const deadlineExtensionMax = dayjs.duration(24, "hours");
|
||||||
|
|
Reference in New Issue
Block a user