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 2dfbb50acd.

* PR feedback 2
This commit is contained in:
Kira Pilot
2023-05-15 07:59:17 -07:00
committed by GitHub
parent 854e974bb4
commit 224d25d4e1
12 changed files with 120 additions and 43 deletions
+7 -9
View File
@@ -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
}
+15 -15
View File
@@ -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 {
@@ -72,7 +72,7 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
<StatsItem
className={styles.statsItem}
label="Status"
value={<WorkspaceStatusText build={workspace.latest_build} />}
value={<WorkspaceStatusText workspace={workspace} />}
/>
<StatsItem
className={styles.statsItem}
@@ -27,50 +27,50 @@ const Template: Story<WorkspaceStatusBadgeProps> = (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,
}
@@ -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 <CircularProgress size={10} style={{ color: "#FFF" }} />
@@ -87,22 +89,52 @@ export const getStatus = (buildStatus: WorkspaceBuild["status"]) => {
}
export type WorkspaceStatusBadgeProps = {
build: WorkspaceBuild
workspace: Workspace
className?: string
}
const ImpendingDeletionBadge: FC<Partial<WorkspaceStatusBadgeProps>> = ({
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 (
<Pill
className={className}
icon={<ErrorIcon />}
text="Impending deletion"
type="error"
/>
)
}
export const WorkspaceStatusBadge: FC<
PropsWithChildren<WorkspaceStatusBadgeProps>
> = ({ 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 <ImpendingDeletionBadge className={className} />
}
const { text, icon, type } = getStatus(workspace.latest_build.status)
return <Pill className={className} icon={icon} text={text} type={type} />
}
export const WorkspaceStatusText: FC<
PropsWithChildren<WorkspaceStatusBadgeProps>
> = ({ build, className }) => {
> = ({ workspace, className }) => {
const styles = useStyles()
const { text, type } = getStatus(build.status)
const { text, type } = getStatus(workspace.latest_build.status)
return (
<span
role="status"
@@ -62,7 +62,7 @@ export const WorkspacesRow: FC<{
</TableCell>
<TableCell>
<WorkspaceStatusBadge build={workspace.latest_build} />
<WorkspaceStatusBadge workspace={workspace} />
</TableCell>
<TableCell>
@@ -58,6 +58,10 @@ export const WorkspacesPageView: FC<
query: workspaceFilterQuery.running,
name: Language.runningWorkspacesButton,
},
{
query: workspaceFilterQuery.failed,
name: "Failed workspaces",
},
]
return (
-1
View File
@@ -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 = {
+1
View File
@@ -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)
})
+1
View File
@@ -13,6 +13,7 @@ export const workspaceFilterQuery = {
me: "owner:me",
all: "",
running: "status:running",
failed: "status:failed",
}
export const userFilterQuery = {
+18
View File
@@ -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)
})
})
})
+24
View File
@@ -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),
)
)
}