Skip to content

Commit 7c692d4

Browse files
committed
finishing touches
1 parent 3a4fb5e commit 7c692d4

File tree

6 files changed

+279
-89
lines changed

6 files changed

+279
-89
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import dayjs from "dayjs";
2+
import type { Workspace } from "api/typesGenerated";
3+
4+
export type WorkspaceActivityStatus =
5+
| "ready"
6+
| "connected"
7+
| "inactive"
8+
| "notConnected"
9+
| "notRunning";
10+
11+
export function getWorkspaceActivityStatus(
12+
workspace: Workspace,
13+
): WorkspaceActivityStatus {
14+
const builtAt = dayjs(workspace.latest_build.created_at);
15+
const usedAt = dayjs(workspace.last_used_at);
16+
const now = dayjs();
17+
18+
if (workspace.latest_build.status !== "running") {
19+
return "notRunning";
20+
}
21+
22+
// This needs to compare to `usedAt` instead of `now`, because the "grace period" for
23+
// marking a workspace as "Connected" is a lot longer. If you compared `builtAt` to `now`,
24+
// you could end up switching from "Ready" to "Connected" without ever actually connecting.
25+
const isBuiltRecently = builtAt.isAfter(usedAt.subtract(1, "second"));
26+
// By default, agents report connection stats every 30 seconds, so 2 minutes should be
27+
// plenty. Disconnection will be reflected relatively-quickly
28+
const isUsedRecently = usedAt.isAfter(now.subtract(2, "minute"));
29+
30+
// If the build is still "fresh", it'll be a while before the `last_used_at` gets bumped in
31+
// a significant way by the agent, so just label it as ready instead of connected.
32+
// Wait until `last_used_at` is after the time that the build finished, _and_ still
33+
// make sure to check that it's recent, so that we don't show "Ready" indefinitely.
34+
if (isUsedRecently && isBuiltRecently && workspace.health.healthy) {
35+
return "ready";
36+
}
37+
38+
if (isUsedRecently) {
39+
return "connected";
40+
}
41+
42+
// TODO: It'd be nice if we could differentiate between "connected but inactive" and
43+
// "not connected", but that will require some relatively substantial backend work.
44+
return "inactive";
45+
}

site/src/pages/WorkspacePage/ActivityStatus.tsx

Lines changed: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,59 @@
11
import { type FC } from "react";
22
import dayjs from "dayjs";
33
import relativeTime from "dayjs/plugin/relativeTime";
4+
import Tooltip from "@mui/material/Tooltip";
45
import type { Workspace } from "api/typesGenerated";
56
import { useTime } from "hooks/useTime";
7+
import type { WorkspaceActivityStatus } from "modules/workspaces/activity";
68
import { Pill } from "components/Pill/Pill";
79

810
dayjs.extend(relativeTime);
911

1012
interface ActivityStatusProps {
1113
workspace: Workspace;
14+
status: WorkspaceActivityStatus;
1215
}
1316

14-
export const ActivityStatus: FC<ActivityStatusProps> = ({ workspace }) => {
15-
const builtAt = dayjs(workspace.latest_build.updated_at);
16-
const usedAt = dayjs(workspace.last_used_at);
17-
const now = dayjs();
17+
export const ActivityStatus: FC<ActivityStatusProps> = ({
18+
workspace,
19+
status,
20+
}) => {
21+
const usedAt = dayjs(workspace.last_used_at).tz(dayjs.tz.guess());
1822

19-
// This needs to compare to `usedAt` instead of `now`, because the "grace period" for
20-
// marking a workspace as "Connected" is a lot longer. If you compared `builtAt` to `now`,
21-
// you could end up switching from "Ready" to "Connected" without ever actually connecting.
22-
const isBuiltRecently = builtAt.isAfter(usedAt.subtract(1, "second"));
23-
const isUsedRecently = usedAt.isAfter(now.subtract(15, "minute"));
23+
// Don't bother updating if `status` will need to change before anything can happen.
24+
useTime(status === "ready" || status === "connected");
2425

25-
useTime(isUsedRecently);
26-
27-
switch (workspace.latest_build.status) {
28-
// If the build is still "fresh", it'll be a while before the `last_used_at` gets bumped in
29-
// a significant way by the agent, so just label it as ready instead of connected.
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.
32-
case isBuiltRecently &&
33-
isUsedRecently &&
34-
workspace.health.healthy &&
35-
"running":
26+
switch (status) {
27+
case "ready":
3628
return <Pill type="active">Ready</Pill>;
37-
// Since the agent reports on a 10m interval, we present any connection within that period
38-
// plus a little wiggle room as an active connection.
39-
case isUsedRecently && "running":
29+
case "connected":
4030
return <Pill type="active">Connected</Pill>;
41-
case "running":
42-
case "stopping":
43-
case "stopped":
44-
return <Pill type="inactive">Not connected</Pill>;
31+
case "inactive":
32+
return (
33+
<Tooltip
34+
title={
35+
<>
36+
This workspace was last active on{" "}
37+
{usedAt.format("MMMM D [at] h:mm A")}
38+
</>
39+
}
40+
>
41+
<Pill type="inactive">Inactive</Pill>
42+
</Tooltip>
43+
);
44+
case "notConnected":
45+
return (
46+
<Tooltip
47+
title={
48+
<>
49+
This workspace was last active on{" "}
50+
{usedAt.format("MMMM D [at] h:mm A")}
51+
</>
52+
}
53+
>
54+
<Pill type="inactive">Not connected</Pill>
55+
</Tooltip>
56+
);
4557
}
4658

4759
return null;

site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { type Interpolation, type Theme } from "@emotion/react";
22
import Link, { type LinkProps } from "@mui/material/Link";
33
import IconButton from "@mui/material/IconButton";
4-
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
54
import AddIcon from "@mui/icons-material/AddOutlined";
5+
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
6+
import ScheduleOutlined from "@mui/icons-material/ScheduleOutlined";
67
import Tooltip from "@mui/material/Tooltip";
78
import { visuallyHidden } from "@mui/utils";
89
import { type Dayjs } from "dayjs";
@@ -25,16 +26,54 @@ import {
2526
updateDeadline,
2627
workspaceByOwnerAndNameKey,
2728
} from "api/queries/workspaces";
29+
import { TopbarData, TopbarIcon } from "components/FullPageLayout/Topbar";
2830
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
31+
import type { WorkspaceActivityStatus } from "modules/workspaces/activity";
32+
33+
export interface WorkspaceScheduleProps {
34+
status: WorkspaceActivityStatus;
35+
workspace: Workspace;
36+
template: Template;
37+
canUpdateWorkspace: boolean;
38+
}
39+
40+
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
41+
status,
42+
workspace,
43+
template,
44+
canUpdateWorkspace,
45+
}) => {
46+
if (!shouldDisplayScheduleControls(workspace, status)) {
47+
return null;
48+
}
49+
50+
return (
51+
<TopbarData>
52+
<TopbarIcon>
53+
<Tooltip title="Schedule">
54+
<ScheduleOutlined aria-label="Schedule" />
55+
</Tooltip>
56+
</TopbarIcon>
57+
<WorkspaceScheduleControls
58+
workspace={workspace}
59+
status={status}
60+
template={template}
61+
canUpdateSchedule={canUpdateWorkspace}
62+
/>
63+
</TopbarData>
64+
);
65+
};
2966

3067
export interface WorkspaceScheduleControlsProps {
3168
workspace: Workspace;
69+
status: WorkspaceActivityStatus;
3270
template: Template;
3371
canUpdateSchedule: boolean;
3472
}
3573

3674
export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
3775
workspace,
76+
status,
3877
template,
3978
canUpdateSchedule,
4079
}) => {
@@ -92,7 +131,11 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
92131
return (
93132
<div css={styles.scheduleValue} data-testid="schedule-controls">
94133
{isWorkspaceOn(workspace) ? (
95-
<AutoStopDisplay workspace={workspace} template={template} />
134+
<AutoStopDisplay
135+
workspace={workspace}
136+
status={status}
137+
template={template}
138+
/>
96139
) : (
97140
<ScheduleSettingsLink>
98141
Starts at {autostartDisplay(workspace.autostart_schedule)}
@@ -135,18 +178,27 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
135178

136179
interface AutoStopDisplayProps {
137180
workspace: Workspace;
181+
status: WorkspaceActivityStatus;
138182
template: Template;
139183
}
140184

141-
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace, template }) => {
185+
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({
186+
workspace,
187+
status,
188+
template,
189+
}) => {
142190
useTime();
143-
const { message, tooltip } = autostopDisplay(workspace, template);
191+
const { message, tooltip, danger } = autostopDisplay(
192+
workspace,
193+
status,
194+
template,
195+
);
144196

145197
const display = (
146198
<ScheduleSettingsLink
147199
data-testid="schedule-controls-autostop"
148200
css={
149-
isShutdownSoon(workspace) &&
201+
danger &&
150202
((theme) => ({
151203
color: `${theme.roles.danger.fill.outline} !important`,
152204
}))
@@ -196,22 +248,13 @@ export const canEditDeadline = (workspace: Workspace): boolean => {
196248

197249
export const shouldDisplayScheduleControls = (
198250
workspace: Workspace,
251+
status: WorkspaceActivityStatus,
199252
): boolean => {
200253
const willAutoStop = isWorkspaceOn(workspace) && hasDeadline(workspace);
201254
const willAutoStart = !isWorkspaceOn(workspace) && hasAutoStart(workspace);
202-
return willAutoStop || willAutoStart;
203-
};
204-
205-
const isShutdownSoon = (workspace: Workspace): boolean => {
206-
const deadline = workspace.latest_build.deadline;
207-
if (!deadline) {
208-
return false;
209-
}
210-
const deadlineDate = new Date(deadline);
211-
const now = new Date();
212-
const diff = deadlineDate.getTime() - now.getTime();
213-
const oneHour = 1000 * 60 * 60;
214-
return diff < oneHour;
255+
const hasActivity =
256+
status === "connected" && !workspace.latest_build.max_deadline;
257+
return (willAutoStop || willAutoStart) && !hasActivity;
215258
};
216259

217260
const styles = {

0 commit comments

Comments
 (0)