Skip to content

Commit 224d25d

Browse files
authored
feat: add 'impending deletion' badges to workspaces page (#7530)
* update deleting logic * added status badge on workspaces page * licensing and feature flagging * preset filter for failed workspaces * remove comment * PR feedback * Revert "PR feedback" This reverts commit 2dfbb50. * PR feedback 2
1 parent 854e974 commit 224d25d

File tree

12 files changed

+120
-43
lines changed

12 files changed

+120
-43
lines changed

coderd/workspaces.go

+7-9
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/go-chi/chi/v5"
1515
"github.com/google/uuid"
1616
"github.com/tabbed/pqtype"
17+
"golang.org/x/exp/slices"
1718
"golang.org/x/xerrors"
1819

1920
"cdr.dev/slog"
@@ -1171,7 +1172,7 @@ func convertWorkspace(
11711172

11721173
var (
11731174
ttlMillis = convertWorkspaceTTLMillis(workspace.Ttl)
1174-
deletingAt = calculateDeletingAt(workspace, template)
1175+
deletingAt = calculateDeletingAt(workspace, template, workspaceBuild)
11751176
)
11761177
return codersdk.Workspace{
11771178
ID: workspace.ID,
@@ -1206,14 +1207,11 @@ func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 {
12061207

12071208
// Calculate the time of the upcoming workspace deletion, if applicable; otherwise, return nil.
12081209
// Workspaces may have impending deletions if InactivityTTL feature is turned on and the workspace is inactive.
1209-
func calculateDeletingAt(workspace database.Workspace, template database.Template) *time.Time {
1210-
var (
1211-
year, month, day = time.Now().Date()
1212-
beginningOfToday = time.Date(year, month, day, 0, 0, 0, 0, time.Now().Location())
1213-
)
1214-
// If InactivityTTL is turned off (set to 0), if the workspace has already been deleted,
1215-
// or if the workspace was used sometime within the last day, there is no impending deletion
1216-
if template.InactivityTTL == 0 || workspace.Deleted || workspace.LastUsedAt.After(beginningOfToday) {
1210+
func calculateDeletingAt(workspace database.Workspace, template database.Template, build codersdk.WorkspaceBuild) *time.Time {
1211+
inactiveStatuses := []codersdk.WorkspaceStatus{codersdk.WorkspaceStatusStopped, codersdk.WorkspaceStatusCanceled, codersdk.WorkspaceStatusFailed, codersdk.WorkspaceStatusDeleted}
1212+
isInactive := slices.Contains(inactiveStatuses, build.Status)
1213+
// If InactivityTTL is turned off (set to 0) or if the workspace is active, there is no impending deletion
1214+
if template.InactivityTTL == 0 || !isInactive {
12171215
return nil
12181216
}
12191217

coderd/workspaces_internal_test.go

+15-15
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/coder/coder/coderd/database"
1010
"github.com/coder/coder/coderd/util/ptr"
11+
"github.com/coder/coder/codersdk"
1112
)
1213

1314
func Test_calculateDeletingAt(t *testing.T) {
@@ -17,17 +18,21 @@ func Test_calculateDeletingAt(t *testing.T) {
1718
name string
1819
workspace database.Workspace
1920
template database.Template
21+
build codersdk.WorkspaceBuild
2022
expected *time.Time
2123
}{
2224
{
23-
name: "DeletingAt",
25+
name: "InactiveWorkspace",
2426
workspace: database.Workspace{
2527
Deleted: false,
2628
LastUsedAt: time.Now().Add(time.Duration(-10) * time.Hour * 24), // 10 days ago
2729
},
2830
template: database.Template{
2931
InactivityTTL: int64(9 * 24 * time.Hour), // 9 days
3032
},
33+
build: codersdk.WorkspaceBuild{
34+
Status: codersdk.WorkspaceStatusStopped,
35+
},
3136
expected: ptr.Ref(time.Now().Add(time.Duration(-1) * time.Hour * 24)), // yesterday
3237
},
3338
{
@@ -39,27 +44,22 @@ func Test_calculateDeletingAt(t *testing.T) {
3944
template: database.Template{
4045
InactivityTTL: 0,
4146
},
42-
expected: nil,
43-
},
44-
{
45-
name: "DeletedWorkspace",
46-
workspace: database.Workspace{
47-
Deleted: true,
48-
LastUsedAt: time.Now().Add(time.Duration(-10) * time.Hour * 24),
49-
},
50-
template: database.Template{
51-
InactivityTTL: int64(9 * 24 * time.Hour),
47+
build: codersdk.WorkspaceBuild{
48+
Status: codersdk.WorkspaceStatusStopped,
5249
},
5350
expected: nil,
5451
},
5552
{
5653
name: "ActiveWorkspace",
5754
workspace: database.Workspace{
58-
Deleted: true,
59-
LastUsedAt: time.Now().Add(time.Duration(-5) * time.Hour), // 5 hours ago
55+
Deleted: false,
56+
LastUsedAt: time.Now(),
6057
},
6158
template: database.Template{
62-
InactivityTTL: int64(1 * 24 * time.Hour), // 1 day
59+
InactivityTTL: int64(1 * 24 * time.Hour),
60+
},
61+
build: codersdk.WorkspaceBuild{
62+
Status: codersdk.WorkspaceStatusRunning,
6363
},
6464
expected: nil,
6565
},
@@ -70,7 +70,7 @@ func Test_calculateDeletingAt(t *testing.T) {
7070
t.Run(tc.name, func(t *testing.T) {
7171
t.Parallel()
7272

73-
found := calculateDeletingAt(tc.workspace, tc.template)
73+
found := calculateDeletingAt(tc.workspace, tc.template, tc.build)
7474
if tc.expected == nil {
7575
require.Nil(t, found, "impending deletion should be nil")
7676
} else {

site/src/components/WorkspaceStats/WorkspaceStats.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
7272
<StatsItem
7373
className={styles.statsItem}
7474
label="Status"
75-
value={<WorkspaceStatusText build={workspace.latest_build} />}
75+
value={<WorkspaceStatusText workspace={workspace} />}
7676
/>
7777
<StatsItem
7878
className={styles.statsItem}

site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.stories.tsx

+10-10
Original file line numberDiff line numberDiff line change
@@ -27,50 +27,50 @@ const Template: Story<WorkspaceStatusBadgeProps> = (args) => (
2727

2828
export const Running = Template.bind({})
2929
Running.args = {
30-
build: MockWorkspace.latest_build,
30+
workspace: MockWorkspace,
3131
}
3232

3333
export const Starting = Template.bind({})
3434
Starting.args = {
35-
build: MockStartingWorkspace.latest_build,
35+
workspace: MockStartingWorkspace,
3636
}
3737

3838
export const Stopped = Template.bind({})
3939
Stopped.args = {
40-
build: MockStoppedWorkspace.latest_build,
40+
workspace: MockStoppedWorkspace,
4141
}
4242

4343
export const Stopping = Template.bind({})
4444
Stopping.args = {
45-
build: MockStoppingWorkspace.latest_build,
45+
workspace: MockStoppingWorkspace,
4646
}
4747

4848
export const Deleting = Template.bind({})
4949
Deleting.args = {
50-
build: MockDeletingWorkspace.latest_build,
50+
workspace: MockDeletingWorkspace,
5151
}
5252

5353
export const Deleted = Template.bind({})
5454
Deleted.args = {
55-
build: MockDeletedWorkspace.latest_build,
55+
workspace: MockDeletedWorkspace,
5656
}
5757

5858
export const Canceling = Template.bind({})
5959
Canceling.args = {
60-
build: MockCancelingWorkspace.latest_build,
60+
workspace: MockCancelingWorkspace,
6161
}
6262

6363
export const Canceled = Template.bind({})
6464
Canceled.args = {
65-
build: MockCanceledWorkspace.latest_build,
65+
workspace: MockCanceledWorkspace,
6666
}
6767

6868
export const Failed = Template.bind({})
6969
Failed.args = {
70-
build: MockFailedWorkspace.latest_build,
70+
workspace: MockFailedWorkspace,
7171
}
7272

7373
export const Pending = Template.bind({})
7474
Pending.args = {
75-
build: MockPendingWorkspace.latest_build,
75+
workspace: MockPendingWorkspace,
7676
}

site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx

+38-6
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import ErrorIcon from "@mui/icons-material/ErrorOutline"
33
import StopIcon from "@mui/icons-material/StopOutlined"
44
import PlayIcon from "@mui/icons-material/PlayArrowOutlined"
55
import QueuedIcon from "@mui/icons-material/HourglassEmpty"
6-
import { WorkspaceBuild } from "api/typesGenerated"
6+
import { Workspace, WorkspaceBuild } from "api/typesGenerated"
77
import { Pill } from "components/Pill/Pill"
88
import i18next from "i18next"
99
import { FC, PropsWithChildren } from "react"
1010
import { makeStyles } from "@mui/styles"
1111
import { combineClasses } from "utils/combineClasses"
12+
import { displayImpendingDeletion } from "utils/workspace"
13+
import { useDashboard } from "components/Dashboard/DashboardProvider"
1214

1315
const LoadingIcon: FC = () => {
1416
return <CircularProgress size={10} style={{ color: "#FFF" }} />
@@ -87,22 +89,52 @@ export const getStatus = (buildStatus: WorkspaceBuild["status"]) => {
8789
}
8890

8991
export type WorkspaceStatusBadgeProps = {
90-
build: WorkspaceBuild
92+
workspace: Workspace
9193
className?: string
9294
}
9395

96+
const ImpendingDeletionBadge: FC<Partial<WorkspaceStatusBadgeProps>> = ({
97+
className,
98+
}) => {
99+
const { entitlements, experiments } = useDashboard()
100+
const allowAdvancedScheduling =
101+
entitlements.features["advanced_template_scheduling"].enabled
102+
// This check can be removed when https://github.com/coder/coder/milestone/19
103+
// is merged up
104+
const allowWorkspaceActions = experiments.includes("workspace_actions")
105+
106+
if (!allowAdvancedScheduling || !allowWorkspaceActions) {
107+
return null
108+
}
109+
return (
110+
<Pill
111+
className={className}
112+
icon={<ErrorIcon />}
113+
text="Impending deletion"
114+
type="error"
115+
/>
116+
)
117+
}
118+
94119
export const WorkspaceStatusBadge: FC<
95120
PropsWithChildren<WorkspaceStatusBadgeProps>
96-
> = ({ build, className }) => {
97-
const { text, icon, type } = getStatus(build.status)
121+
> = ({ workspace, className }) => {
122+
// The ImpendingDeletionBadge component itself checks to see if the
123+
// Advanced Scheduling feature is turned on and if the
124+
// Workspace Actions flag is turned on.
125+
if (displayImpendingDeletion(workspace)) {
126+
return <ImpendingDeletionBadge className={className} />
127+
}
128+
129+
const { text, icon, type } = getStatus(workspace.latest_build.status)
98130
return <Pill className={className} icon={icon} text={text} type={type} />
99131
}
100132

101133
export const WorkspaceStatusText: FC<
102134
PropsWithChildren<WorkspaceStatusBadgeProps>
103-
> = ({ build, className }) => {
135+
> = ({ workspace, className }) => {
104136
const styles = useStyles()
105-
const { text, type } = getStatus(build.status)
137+
const { text, type } = getStatus(workspace.latest_build.status)
106138
return (
107139
<span
108140
role="status"

site/src/components/WorkspacesTable/WorkspacesRow.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const WorkspacesRow: FC<{
6262
</TableCell>
6363

6464
<TableCell>
65-
<WorkspaceStatusBadge build={workspace.latest_build} />
65+
<WorkspaceStatusBadge workspace={workspace} />
6666
</TableCell>
6767

6868
<TableCell>

site/src/pages/WorkspacesPage/WorkspacesPageView.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export const WorkspacesPageView: FC<
5858
query: workspaceFilterQuery.running,
5959
name: Language.runningWorkspacesButton,
6060
},
61+
{
62+
query: workspaceFilterQuery.failed,
63+
name: "Failed workspaces",
64+
},
6165
]
6266

6367
return (

site/src/testHelpers/entities.ts

-1
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,6 @@ export const MockWorkspace: TypesGen.Workspace = {
762762
ttl_ms: 2 * 60 * 60 * 1000,
763763
latest_build: MockWorkspaceBuild,
764764
last_used_at: "2022-05-16T15:29:10.302441433Z",
765-
deleting_at: "0001-01-01T00:00:00Z",
766765
}
767766

768767
export const MockStoppedWorkspace: TypesGen.Workspace = {

site/src/utils/filters.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe("queryToFilter", () => {
1313
["me/dev", { q: "me/dev" }],
1414
["me/", { q: "me/" }],
1515
[" key:val owner:me ", { q: "key:val owner:me" }],
16+
["status:failed", { q: "status:failed" }],
1617
])(`query=%p, filter=%p`, (query, filter) => {
1718
expect(queryToFilter(query)).toEqual(filter)
1819
})

site/src/utils/filters.ts

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export const workspaceFilterQuery = {
1313
me: "owner:me",
1414
all: "",
1515
running: "status:running",
16+
failed: "status:failed",
1617
}
1718

1819
export const userFilterQuery = {

site/src/utils/workspace.test.ts

+18
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getDisplayVersionStatus,
77
getDisplayWorkspaceBuildInitiatedBy,
88
getDisplayWorkspaceTemplateName,
9+
displayImpendingDeletion,
910
isWorkspaceOn,
1011
} from "./workspace"
1112

@@ -139,4 +140,21 @@ describe("util > workspace", () => {
139140
expect(displayed).toEqual(workspace.template_display_name)
140141
})
141142
})
143+
144+
describe("displayImpendingDeletion", () => {
145+
const today = new Date()
146+
it.each<[string, boolean]>([
147+
[new Date(new Date().setDate(today.getDate() + 15)).toISOString(), false], // today + 15 days out
148+
[new Date(new Date().setDate(today.getDate() + 14)).toISOString(), true], // today + 14
149+
[new Date(new Date().setDate(today.getDate() + 13)).toISOString(), true], // today + 13
150+
[new Date(new Date().setDate(today.getDate() + 1)).toISOString(), true], // today + 1
151+
[new Date().toISOString(), true], // today + 0
152+
])(`deleting_at=%p, isWorkspaceOn=%p`, (deleting_at, shouldDisplay) => {
153+
const workspace: TypesGen.Workspace = {
154+
...Mocks.MockWorkspace,
155+
deleting_at,
156+
}
157+
expect(displayImpendingDeletion(workspace)).toBe(shouldDisplay)
158+
})
159+
})
142160
})

site/src/utils/workspace.ts

+24
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,27 @@ export const getDisplayWorkspaceTemplateName = (
185185
? workspace.template_display_name
186186
: workspace.template_name
187187
}
188+
189+
// This const dictates how far out we alert the user that a workspace
190+
// has an impending deletion (due to template.InactivityTTL being set)
191+
const IMPENDING_DELETION_DISPLAY_THRESHOLD = 14 // 14 days
192+
193+
/**
194+
* Returns a boolean indicating if an impending deletion indicator should be
195+
* displayed in the UI. Impending deletions are configured by setting the
196+
* Template.InactivityTTL
197+
* @param {TypesGen.Workspace} workspace
198+
* @returns {boolean}
199+
*/
200+
export const displayImpendingDeletion = (workspace: TypesGen.Workspace) => {
201+
const today = new Date()
202+
if (!workspace.deleting_at) {
203+
return false
204+
}
205+
return (
206+
new Date(workspace.deleting_at) <=
207+
new Date(
208+
today.setDate(today.getDate() + IMPENDING_DELETION_DISPLAY_THRESHOLD),
209+
)
210+
)
211+
}

0 commit comments

Comments
 (0)