Skip to content

feat: add activity status and autostop reason to workspace overview #11987

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 17 commits into from
Feb 13, 2024
Prev Previous commit
Next Next commit
finishing touches
  • Loading branch information
aslilac committed Feb 9, 2024
commit 7c692d4f137bc48655376c809c638c71201c25fa
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";
}
66 changes: 39 additions & 27 deletions site/src/pages/WorkspacePage/ActivityStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,47 +1,59 @@
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 }) => {
const builtAt = dayjs(workspace.latest_build.updated_at);
const usedAt = dayjs(workspace.last_used_at);
const now = dayjs();
export const ActivityStatus: FC<ActivityStatusProps> = ({
workspace,
status,
}) => {
const usedAt = dayjs(workspace.last_used_at).tz(dayjs.tz.guess());

// 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"));
const isUsedRecently = usedAt.isAfter(now.subtract(15, "minute"));
// Don't bother updating if `status` will need to change before anything can happen.
useTime(status === "ready" || status === "connected");

useTime(isUsedRecently);

switch (workspace.latest_build.status) {
// 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.
case isBuiltRecently &&
isUsedRecently &&
workspace.health.healthy &&
"running":
switch (status) {
case "ready":
return <Pill type="active">Ready</Pill>;
// Since the agent reports on a 10m interval, we present any connection within that period
// plus a little wiggle room as an active connection.
case isUsedRecently && "running":
case "connected":
return <Pill type="active">Connected</Pill>;
case "running":
case "stopping":
case "stopped":
return <Pill type="inactive">Not 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;
Expand Down
79 changes: 61 additions & 18 deletions site/src/pages/WorkspacePage/WorkspaceScheduleControls.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { type Interpolation, type Theme } from "@emotion/react";
import Link, { type LinkProps } from "@mui/material/Link";
import IconButton from "@mui/material/IconButton";
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
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";
Expand All @@ -25,16 +26,54 @@ import {
updateDeadline,
workspaceByOwnerAndNameKey,
} from "api/queries/workspaces";
import { TopbarData, TopbarIcon } from "components/FullPageLayout/Topbar";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import type { WorkspaceActivityStatus } from "modules/workspaces/activity";

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 {
workspace: Workspace;
status: WorkspaceActivityStatus;
template: Template;
canUpdateSchedule: boolean;
}

export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
workspace,
status,
template,
canUpdateSchedule,
}) => {
Expand Down Expand Up @@ -92,7 +131,11 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
return (
<div css={styles.scheduleValue} data-testid="schedule-controls">
{isWorkspaceOn(workspace) ? (
<AutoStopDisplay workspace={workspace} template={template} />
<AutoStopDisplay
workspace={workspace}
status={status}
template={template}
/>
) : (
<ScheduleSettingsLink>
Starts at {autostartDisplay(workspace.autostart_schedule)}
Expand Down Expand Up @@ -135,18 +178,27 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({

interface AutoStopDisplayProps {
workspace: Workspace;
status: WorkspaceActivityStatus;
template: Template;
}

const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace, template }) => {
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({
workspace,
status,
template,
}) => {
useTime();
const { message, tooltip } = autostopDisplay(workspace, template);
const { message, tooltip, danger } = autostopDisplay(
workspace,
status,
template,
);

const display = (
<ScheduleSettingsLink
data-testid="schedule-controls-autostop"
css={
isShutdownSoon(workspace) &&
danger &&
((theme) => ({
color: `${theme.roles.danger.fill.outline} !important`,
}))
Expand Down Expand Up @@ -196,22 +248,13 @@ export const canEditDeadline = (workspace: Workspace): boolean => {

export const shouldDisplayScheduleControls = (
workspace: Workspace,
status: WorkspaceActivityStatus,
): boolean => {
const willAutoStop = isWorkspaceOn(workspace) && hasDeadline(workspace);
const willAutoStart = !isWorkspaceOn(workspace) && hasAutoStart(workspace);
return willAutoStop || willAutoStart;
};

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 hasActivity =
status === "connected" && !workspace.latest_build.max_deadline;
return (willAutoStop || willAutoStart) && !hasActivity;
};

const styles = {
Expand Down
Loading