diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c14f91019b..10d0dc9281 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/tabbed/pqtype" + "golang.org/x/exp/slices" "golang.org/x/xerrors" "cdr.dev/slog" @@ -1171,7 +1172,7 @@ func convertWorkspace( var ( ttlMillis = convertWorkspaceTTLMillis(workspace.Ttl) - deletingAt = calculateDeletingAt(workspace, template) + deletingAt = calculateDeletingAt(workspace, template, workspaceBuild) ) return codersdk.Workspace{ ID: workspace.ID, @@ -1206,19 +1207,11 @@ func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 { // Calculate the time of the upcoming workspace deletion, if applicable; otherwise, return nil. // Workspaces may have impending deletions if InactivityTTL feature is turned on and the workspace is inactive. -func calculateDeletingAt(workspace database.Workspace, template database.Template) *time.Time { - // Workspace is recently inactive but hasn't been inactive for longer than - // the specified template.InactivityTTL threshold - - fmt.Println("last used at before now (aka inactive)", workspace.LastUsedAt.Before(time.Now())) - fmt.Println("last used at is more recent than now minus the TTL ", workspace.LastUsedAt.After(time.Now().Add(-time.Duration(template.InactivityTTL)*time.Nanosecond))) - - workspaceRecentlyInactive := workspace.LastUsedAt.Before(time.Now()) && - workspace.LastUsedAt.After(time.Now().Add(-time.Duration(template.InactivityTTL)*time.Nanosecond)) - - // If InactivityTTL is turned off (set to 0), if the workspace has already been deleted, - // or if the workspace is only recently inactive, there is no impending deletion - if template.InactivityTTL == 0 || workspace.Deleted || workspaceRecentlyInactive { +func calculateDeletingAt(workspace database.Workspace, template database.Template, build codersdk.WorkspaceBuild) *time.Time { + inactiveStatuses := []codersdk.WorkspaceStatus{codersdk.WorkspaceStatusStopped, codersdk.WorkspaceStatusCanceled, codersdk.WorkspaceStatusFailed, codersdk.WorkspaceStatusDeleted} + isInactive := slices.Contains(inactiveStatuses, build.Status) + // If InactivityTTL is turned off (set to 0) or if the workspace is active, there is no impending deletion + if template.InactivityTTL == 0 || !isInactive { return nil } diff --git a/coderd/workspaces_internal_test.go b/coderd/workspaces_internal_test.go index b62835b690..44c1699309 100644 --- a/coderd/workspaces_internal_test.go +++ b/coderd/workspaces_internal_test.go @@ -8,6 +8,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/codersdk" ) func Test_calculateDeletingAt(t *testing.T) { @@ -17,10 +18,11 @@ func Test_calculateDeletingAt(t *testing.T) { name string workspace database.Workspace template database.Template + build codersdk.WorkspaceBuild expected *time.Time }{ { - name: "DeletingAt", + name: "InactiveWorkspace", workspace: database.Workspace{ Deleted: false, LastUsedAt: time.Now().Add(time.Duration(-10) * time.Hour * 24), // 10 days ago @@ -28,6 +30,9 @@ func Test_calculateDeletingAt(t *testing.T) { template: database.Template{ InactivityTTL: int64(9 * 24 * time.Hour), // 9 days }, + build: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusStopped, + }, expected: ptr.Ref(time.Now().Add(time.Duration(-1) * time.Hour * 24)), // yesterday }, { @@ -39,27 +44,22 @@ func Test_calculateDeletingAt(t *testing.T) { template: database.Template{ InactivityTTL: 0, }, - expected: nil, - }, - { - name: "DeletedWorkspace", - workspace: database.Workspace{ - Deleted: true, - LastUsedAt: time.Now().Add(time.Duration(-10) * time.Hour * 24), - }, - template: database.Template{ - InactivityTTL: int64(9 * 24 * time.Hour), + build: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusStopped, }, expected: nil, }, { name: "ActiveWorkspace", workspace: database.Workspace{ - Deleted: true, - LastUsedAt: time.Now().Add(time.Duration(-5) * time.Hour), // 5 hours ago + Deleted: false, + LastUsedAt: time.Now(), }, template: database.Template{ - InactivityTTL: int64(1 * 24 * time.Hour), // 1 day + InactivityTTL: int64(1 * 24 * time.Hour), + }, + build: codersdk.WorkspaceBuild{ + Status: codersdk.WorkspaceStatusRunning, }, expected: nil, }, @@ -70,7 +70,7 @@ func Test_calculateDeletingAt(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - found := calculateDeletingAt(tc.workspace, tc.template) + found := calculateDeletingAt(tc.workspace, tc.template, tc.build) if tc.expected == nil { require.Nil(t, found, "impending deletion should be nil") } else { diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index c7bdf022d2..e91c2c5ca1 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -29,7 +29,7 @@ const ( WorkspaceStatusStopped WorkspaceStatus = "stopped" WorkspaceStatusFailed WorkspaceStatus = "failed" WorkspaceStatusCanceling WorkspaceStatus = "canceling" - WorkspaceStatusCanceled WorkspaceStatus = "canceled" + WorkspaceStatusCanceled WorkspaceStatus = "canceled" // WorkspaceStatusDeleting WorkspaceStatus = "deleting" WorkspaceStatusDeleted WorkspaceStatus = "deleted" ) diff --git a/site/src/components/WorkspaceStats/WorkspaceStats.tsx b/site/src/components/WorkspaceStats/WorkspaceStats.tsx index 59479efa05..26eb80d7ba 100644 --- a/site/src/components/WorkspaceStats/WorkspaceStats.tsx +++ b/site/src/components/WorkspaceStats/WorkspaceStats.tsx @@ -72,7 +72,7 @@ export const WorkspaceStats: FC = ({ } + value={} /> = (args) => ( export const Running = Template.bind({}) Running.args = { - build: MockWorkspace.latest_build, + workspace: MockWorkspace, } export const Starting = Template.bind({}) Starting.args = { - build: MockStartingWorkspace.latest_build, + workspace: MockStartingWorkspace, } export const Stopped = Template.bind({}) Stopped.args = { - build: MockStoppedWorkspace.latest_build, + workspace: MockStoppedWorkspace, } export const Stopping = Template.bind({}) Stopping.args = { - build: MockStoppingWorkspace.latest_build, + workspace: MockStoppingWorkspace, } export const Deleting = Template.bind({}) Deleting.args = { - build: MockDeletingWorkspace.latest_build, + workspace: MockDeletingWorkspace, } export const Deleted = Template.bind({}) Deleted.args = { - build: MockDeletedWorkspace.latest_build, + workspace: MockDeletedWorkspace, } export const Canceling = Template.bind({}) Canceling.args = { - build: MockCancelingWorkspace.latest_build, + workspace: MockCancelingWorkspace, } export const Canceled = Template.bind({}) Canceled.args = { - build: MockCanceledWorkspace.latest_build, + workspace: MockCanceledWorkspace, } export const Failed = Template.bind({}) Failed.args = { - build: MockFailedWorkspace.latest_build, + workspace: MockFailedWorkspace, } export const Pending = Template.bind({}) Pending.args = { - build: MockPendingWorkspace.latest_build, + workspace: MockPendingWorkspace, +} + +export const ImpendingDeletion = Template.bind({}) +ImpendingDeletion.args = { + workspace: MockWorkspaceImpendingDeletion, } diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 0ce52f7edf..0768173cd4 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -3,12 +3,13 @@ import ErrorIcon from "@mui/icons-material/ErrorOutline" import StopIcon from "@mui/icons-material/StopOutlined" import PlayIcon from "@mui/icons-material/PlayArrowOutlined" import QueuedIcon from "@mui/icons-material/HourglassEmpty" -import { WorkspaceBuild } from "api/typesGenerated" +import { Workspace, WorkspaceBuild } from "api/typesGenerated" import { Pill } from "components/Pill/Pill" import i18next from "i18next" import { FC, PropsWithChildren } from "react" import { makeStyles } from "@mui/styles" import { combineClasses } from "utils/combineClasses" +import { displayImpendingDeletion } from "utils/workspace" const LoadingIcon: FC = () => { return @@ -87,22 +88,33 @@ export const getStatus = (buildStatus: WorkspaceBuild["status"]) => { } export type WorkspaceStatusBadgeProps = { - build: WorkspaceBuild + workspace: Workspace className?: string } export const WorkspaceStatusBadge: FC< PropsWithChildren -> = ({ build, className }) => { - const { text, icon, type } = getStatus(build.status) +> = ({ workspace, className }) => { + if (displayImpendingDeletion(workspace)) { + return ( + } + text="Impending deletion" + type="error" + /> + ) + } + + const { text, icon, type } = getStatus(workspace.latest_build.status) return } export const WorkspaceStatusText: FC< PropsWithChildren -> = ({ build, className }) => { +> = ({ workspace, className }) => { const styles = useStyles() - const { text, type } = getStatus(build.status) + const { text, type } = getStatus(workspace.latest_build.status) return ( - + diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index eb5179853d..9df3cc527e 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -721,7 +721,6 @@ export const MockWorkspace: TypesGen.Workspace = { ttl_ms: 2 * 60 * 60 * 1000, latest_build: MockWorkspaceBuild, last_used_at: "2022-05-16T15:29:10.302441433Z", - deleting_at: "0001-01-01T00:00:00Z", } export const MockStoppedWorkspace: TypesGen.Workspace = { @@ -807,6 +806,12 @@ export const MockPendingWorkspace: TypesGen.Workspace = { }, } +export const MockWorkspaceImpendingDeletion: TypesGen.Workspace = { + ...MockWorkspace, + id: "test-workspace-impending-deletion", + deleting_at: new Date().toISOString(), +} + // just over one page of workspaces export const MockWorkspacesResponse: TypesGen.WorkspacesResponse = { workspaces: range(1, 27).map((id: number) => ({ diff --git a/site/src/utils/workspace.test.ts b/site/src/utils/workspace.test.ts index 26bb223c6b..bfb621acfe 100644 --- a/site/src/utils/workspace.test.ts +++ b/site/src/utils/workspace.test.ts @@ -6,6 +6,7 @@ import { getDisplayVersionStatus, getDisplayWorkspaceBuildInitiatedBy, getDisplayWorkspaceTemplateName, + displayImpendingDeletion, isWorkspaceOn, } from "./workspace" @@ -139,4 +140,21 @@ describe("util > workspace", () => { expect(displayed).toEqual(workspace.template_display_name) }) }) + + describe("displayImpendingDeletion", () => { + const today = new Date() + it.each<[string, boolean]>([ + [new Date(new Date().setDate(today.getDate() + 15)).toISOString(), false], // today + 15 days out + [new Date(new Date().setDate(today.getDate() + 14)).toISOString(), true], // today + 14 + [new Date(new Date().setDate(today.getDate() + 13)).toISOString(), true], // today + 13 + [new Date(new Date().setDate(today.getDate() + 1)).toISOString(), true], // today + 1 + [new Date().toISOString(), true], // today + 0 + ])(`deleting_at=%p, isWorkspaceOn=%p`, (deleting_at, shouldDisplay) => { + const workspace: TypesGen.Workspace = { + ...Mocks.MockWorkspace, + deleting_at, + } + expect(displayImpendingDeletion(workspace)).toBe(shouldDisplay) + }) + }) }) diff --git a/site/src/utils/workspace.ts b/site/src/utils/workspace.ts index d180222e0a..5e101209a7 100644 --- a/site/src/utils/workspace.ts +++ b/site/src/utils/workspace.ts @@ -185,3 +185,45 @@ export const getDisplayWorkspaceTemplateName = ( ? workspace.template_display_name : workspace.template_name } + +// This const dictates how far out we alert the user that a workspace +// has an impending deletion (due to template.InactivityTTL being set) +const IMPENDING_DELETION_DISPLAY_THRESHOLD = 14 // 14 days + +/** + * Returns a boolean indicating if an impending deletion indicator should be + * displayed in the UI. Impending deletions are configured by setting the + * Template.InactivityTTL + * @param {TypesGen.Workspace} workspace + * @returns {boolean} + */ +export const displayImpendingDeletion = (workspace: TypesGen.Workspace) => { + const today = new Date() + if (!workspace.deleting_at) { + return false + } + + console.log("today", today) + console.log("workspace.deletingAt", new Date(workspace.deleting_at)) + console.log( + "set", + new Date( + today.setDate(today.getDate() - IMPENDING_DELETION_DISPLAY_THRESHOLD), + ), + ) + + console.log( + "compare", + new Date(workspace.deleting_at) <= + new Date( + today.setDate(today.getDate() + IMPENDING_DELETION_DISPLAY_THRESHOLD), + ), + ) + + return ( + new Date(workspace.deleting_at) <= + new Date( + today.setDate(today.getDate() + IMPENDING_DELETION_DISPLAY_THRESHOLD), + ) + ) +}