Skip to content

feat: add frontend for locked workspaces #8655

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 16 commits into from
Aug 4, 2023
Next Next commit
feat: add frontend for locked workspaces
- Fix workspaces query for locked workspaces.
  • Loading branch information
sreya committed Jul 25, 2023
commit 0d79b8fee46e5445edd54c105d44f4e6c5f14bf0
1 change: 1 addition & 0 deletions coderd/database/modelqueries.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
arg.Name,
arg.HasAgent,
arg.AgentInactiveDisconnectTimeoutSeconds,
arg.LockedAt,
arg.Offset,
arg.Limit,
)
Expand Down
14 changes: 11 additions & 3 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions coderd/database/queries/workspaces.sql
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,12 @@ WHERE
) > 0
ELSE true
END
-- Filter by locked workspaces.
AND CASE
WHEN @locked_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN
locked_at IS NOT NULL AND locked_at >= @locked_at
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
ORDER BY
Expand Down
1 change: 1 addition & 0 deletions coderd/searchquery/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
filter.Name = parser.String(values, "", "name")
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
filter.HasAgent = parser.String(values, "", "has-agent")
filter.LockedAt = parser.Time(values, time.Time{}, "locked_at", "2006-01-02")

if _, ok := values["deleting_by"]; ok {
postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02"))
Expand Down
40 changes: 40 additions & 0 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,46 @@ func TestWorkspaceFilterManual(t *testing.T) {
// and template.InactivityTTL should be 0
assert.Len(t, res.Workspaces, 0)
})

t.Run("LockedAt", func(t *testing.T) {
// this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)

// update template with inactivity ttl
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID)

// Create another workspace to validate that we do not return unlocked workspaces.
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID)

err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{
Lock: true,
})
require.NoError(t, err)

res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
FilterQuery: fmt.Sprintf("locked_at:%s", time.Now().Add(-time.Minute).Format("2006-01-02")),
})
require.NoError(t, err)
require.Len(t, res.Workspaces, 1)
require.NotNil(t, res.Workspaces[0].LockedAt)
})
}

func TestOffsetLimit(t *testing.T) {
Expand Down
15 changes: 15 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,21 @@ export const cancelWorkspaceBuild = async (
return response.data
}

export const updateWorkspaceLock = async (
workspaceId: string,
lock: boolean,
): Promise<Types.Message> => {
const data: TypesGen.UpdateWorkspaceLock = {
lock: lock,
}

const response = await axios.put(
`/api/v2/workspaces/${workspaceId}/lock`,
data,
)
return response.data
}

export const restartWorkspace = async ({
workspace,
buildParameters,
Expand Down
31 changes: 20 additions & 11 deletions site/src/components/Workspace/Workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Button from "@mui/material/Button"
import { makeStyles } from "@mui/styles"
import LockIcon from "@mui/icons-material/Lock"
import { Avatar } from "components/Avatar/Avatar"
import { AgentRow } from "components/Resources/AgentRow"
import {
Expand All @@ -26,7 +27,7 @@ import {
} from "components/PageHeader/FullWidthPageHeader"
import { TemplateVersionWarnings } from "components/TemplateVersionWarnings/TemplateVersionWarnings"
import { ErrorAlert } from "components/Alert/ErrorAlert"
import { ImpendingDeletionBanner } from "components/WorkspaceDeletion"
import { LockedWorkspaceBanner } from "components/WorkspaceDeletion"
import { useLocalStorage } from "hooks"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import AlertTitle from "@mui/material/AlertTitle"
Expand All @@ -53,6 +54,7 @@ export interface WorkspaceProps {
handleCancel: () => void
handleSettings: () => void
handleChangeVersion: () => void
handleUnlock: () => void
isUpdating: boolean
isRestarting: boolean
workspace: TypesGen.Workspace
Expand Down Expand Up @@ -86,6 +88,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleCancel,
handleSettings,
handleChangeVersion,
handleUnlock,
workspace,
isUpdating,
isRestarting,
Expand Down Expand Up @@ -167,14 +170,19 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
<>
<FullWidthPageHeader>
<Stack direction="row" spacing={3} alignItems="center">
<Avatar
size="md"
src={workspace.template_icon}
variant={workspace.template_icon ? "square" : undefined}
fitImage={Boolean(workspace.template_icon)}
>
{workspace.name}
</Avatar>
{workspace.locked_at ? (
<LockIcon fontSize="large" color="error" />
) : (
<Avatar
size="md"
src={workspace.template_icon}
variant={workspace.template_icon ? "square" : undefined}
fitImage={Boolean(workspace.template_icon)}
>
{workspace.name}
</Avatar>
)}

<div>
<PageHeaderTitle>{workspace.name}</PageHeaderTitle>
<PageHeaderSubtitle>{workspace.owner_name}</PageHeaderSubtitle>
Expand Down Expand Up @@ -203,6 +211,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleCancel={handleCancel}
handleSettings={handleSettings}
handleChangeVersion={handleChangeVersion}
handleUnlock={handleUnlock}
canChangeVersions={canChangeVersions}
isUpdating={isUpdating}
isRestarting={isRestarting}
Expand Down Expand Up @@ -240,8 +249,8 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
</Cond>
<Cond>
{/* <ImpendingDeletionBanner/> determines its own visibility */}
<ImpendingDeletionBanner
workspace={workspace}
<LockedWorkspaceBanner
workspaces={[workspace]}
shouldRedisplayBanner={
getLocal("dismissedWorkspace") !== workspace.id
}
Expand Down
18 changes: 18 additions & 0 deletions site/src/components/WorkspaceActions/Buttons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import BlockIcon from "@mui/icons-material/Block"
import CloudQueueIcon from "@mui/icons-material/CloudQueue"
import CropSquareIcon from "@mui/icons-material/CropSquare"
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"
import LockOpenIcon from "@mui/icons-material/LockOpen"
import ReplayIcon from "@mui/icons-material/Replay"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { FC } from "react"
Expand Down Expand Up @@ -34,6 +35,23 @@ export const UpdateButton: FC<WorkspaceAction> = ({
)
}

export const UnlockButton: FC<WorkspaceAction> = ({
handleAction,
loading,
}) => {
return (
<LoadingButton
loading={loading}
loadingIndicator="Unlocking..."
loadingPosition="start"
startIcon={<LockOpenIcon />}
onClick={handleAction}
>
Unlock
</LoadingButton>
)
}

export const StartButton: FC<
Omit<WorkspaceAction, "handleAction"> & {
workspace: Workspace
Expand Down
13 changes: 12 additions & 1 deletion site/src/components/WorkspaceActions/WorkspaceActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import Menu from "@mui/material/Menu"
import { makeStyles } from "@mui/styles"
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined"
import { FC, Fragment, ReactNode, useRef, useState } from "react"
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"
import {
Workspace,
WorkspaceStatus,
WorkspaceBuildParameter,
} from "api/typesGenerated"
import {
ActionLoadingButton,
CancelButton,
Expand All @@ -12,6 +16,7 @@ import {
StopButton,
RestartButton,
UpdateButton,
UnlockButton,
} from "./Buttons"
import {
ButtonMapping,
Expand All @@ -33,6 +38,7 @@ export interface WorkspaceActionsProps {
handleCancel: () => void
handleSettings: () => void
handleChangeVersion: () => void
handleUnlock: () => void
isUpdating: boolean
isRestarting: boolean
children?: ReactNode
Expand All @@ -49,6 +55,7 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
handleCancel,
handleSettings,
handleChangeVersion,
handleUnlock,
isUpdating,
isRestarting,
canChangeVersions,
Expand Down Expand Up @@ -93,6 +100,10 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
[ButtonTypesEnum.canceling]: <DisabledButton label="Canceling..." />,
[ButtonTypesEnum.deleted]: <DisabledButton label="Deleted" />,
[ButtonTypesEnum.pending]: <ActionLoadingButton label="Pending..." />,
[ButtonTypesEnum.unlock]: <UnlockButton handleAction={handleUnlock} />,
[ButtonTypesEnum.unlocking]: (
<UnlockButton loading handleAction={handleUnlock} />
),
}

// Returns a function that will execute the action and close the menu
Expand Down
12 changes: 11 additions & 1 deletion site/src/components/WorkspaceActions/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { WorkspaceStatus } from "api/typesGenerated"
import { Workspace, WorkspaceStatus } from "api/typesGenerated"
import { ReactNode } from "react"

// the button types we have
Expand All @@ -12,6 +12,8 @@ export enum ButtonTypesEnum {
deleting = "deleting",
update = "update",
updating = "updating",
unlock = "lock",
unlocking = "unlocking",
// disabled buttons
canceling = "canceling",
deleted = "deleted",
Expand All @@ -29,8 +31,16 @@ interface WorkspaceAbilities {
}

export const actionsByWorkspaceStatus = (
workspace: Workspace,
status: WorkspaceStatus,
): WorkspaceAbilities => {
if (workspace.locked_at) {
return {
actions: [ButtonTypesEnum.unlock],
canCancel: false,
canAcceptJobs: false,
}
}
return statusToActions[status]
}

Expand Down
10 changes: 5 additions & 5 deletions site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Workspace } from "api/typesGenerated"
import { displayImpendingDeletion } from "./utils"
import { displayLockedWorkspace } from "./utils"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { Pill } from "components/Pill/Pill"
import ErrorIcon from "@mui/icons-material/ErrorOutline"
import LockIcon from "@mui/icons-material/Lock"

export const ImpendingDeletionBadge = ({
export const LockedBadge = ({
workspace,
}: {
workspace: Workspace
Expand All @@ -18,7 +18,7 @@ export const ImpendingDeletionBadge = ({
// return null

if (
!displayImpendingDeletion(
!displayLockedWorkspace(
workspace,
allowAdvancedScheduling,
allowWorkspaceActions,
Expand All @@ -27,5 +27,5 @@ export const ImpendingDeletionBadge = ({
return null
}

return <Pill icon={<ErrorIcon />} text="Impending deletion" type="error" />
return <Pill icon={<LockIcon />} text="Locked" type="error" />
}
Loading