diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 378063ed7e..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,14 +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 { - var ( - year, month, day = time.Now().Date() - beginningOfToday = time.Date(year, month, day, 0, 0, 0, 0, time.Now().Location()) - ) - // If InactivityTTL is turned off (set to 0), if the workspace has already been deleted, - // or if the workspace was used sometime within the last day, there is no impending deletion - if template.InactivityTTL == 0 || workspace.Deleted || workspace.LastUsedAt.After(beginningOfToday) { +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/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, } diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 0ce52f7edf..c7fff79d42 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -3,12 +3,14 @@ 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" +import { useDashboard } from "components/Dashboard/DashboardProvider" const LoadingIcon: FC = () => { return @@ -87,22 +89,52 @@ export const getStatus = (buildStatus: WorkspaceBuild["status"]) => { } export type WorkspaceStatusBadgeProps = { - build: WorkspaceBuild + workspace: Workspace className?: string } +const ImpendingDeletionBadge: FC> = ({ + className, +}) => { + const { entitlements, experiments } = useDashboard() + const allowAdvancedScheduling = + entitlements.features["advanced_template_scheduling"].enabled + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions") + + if (!allowAdvancedScheduling || !allowWorkspaceActions) { + return null + } + return ( + } + text="Impending deletion" + type="error" + /> + ) +} + export const WorkspaceStatusBadge: FC< PropsWithChildren -> = ({ build, className }) => { - const { text, icon, type } = getStatus(build.status) +> = ({ workspace, className }) => { + // The ImpendingDeletionBadge component itself checks to see if the + // Advanced Scheduling feature is turned on and if the + // Workspace Actions flag is turned on. + if (displayImpendingDeletion(workspace)) { + return + } + + 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/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 29f377bb79..6309a131ce 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -58,6 +58,10 @@ export const WorkspacesPageView: FC< query: workspaceFilterQuery.running, name: Language.runningWorkspacesButton, }, + { + query: workspaceFilterQuery.failed, + name: "Failed workspaces", + }, ] return ( diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 34c9fe726c..a4a68aa786 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -762,7 +762,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 = { diff --git a/site/src/utils/filters.test.ts b/site/src/utils/filters.test.ts index a9770d5098..0f0ad348d2 100644 --- a/site/src/utils/filters.test.ts +++ b/site/src/utils/filters.test.ts @@ -13,6 +13,7 @@ describe("queryToFilter", () => { ["me/dev", { q: "me/dev" }], ["me/", { q: "me/" }], [" key:val owner:me ", { q: "key:val owner:me" }], + ["status:failed", { q: "status:failed" }], ])(`query=%p, filter=%p`, (query, filter) => { expect(queryToFilter(query)).toEqual(filter) }) diff --git a/site/src/utils/filters.ts b/site/src/utils/filters.ts index a4e5cc04f9..698979de35 100644 --- a/site/src/utils/filters.ts +++ b/site/src/utils/filters.ts @@ -13,6 +13,7 @@ export const workspaceFilterQuery = { me: "owner:me", all: "", running: "status:running", + failed: "status:failed", } export const userFilterQuery = { 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..44bd1e6c9c 100644 --- a/site/src/utils/workspace.ts +++ b/site/src/utils/workspace.ts @@ -185,3 +185,27 @@ 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 + } + return ( + new Date(workspace.deleting_at) <= + new Date( + today.setDate(today.getDate() + IMPENDING_DELETION_DISPLAY_THRESHOLD), + ) + ) +}