feat: workspace quotas (#4184)

This commit is contained in:
Garrett Delfosse
2022-09-30 14:01:20 -04:00
committed by GitHub
parent f9b7588963
commit 69c73b2d28
28 changed files with 712 additions and 83 deletions
+5
View File
@@ -500,3 +500,8 @@ export const getApplicationsHost = async (): Promise<TypesGen.GetAppHostResponse
const response = await axios.get(`/api/v2/applications/host`)
return response.data
}
export const getWorkspaceQuota = async (userID: string): Promise<TypesGen.WorkspaceQuota> => {
const response = await axios.get(`/api/v2/workspace-quota/${userID}`)
return response.data
}
+2
View File
@@ -20,4 +20,6 @@ export enum FeatureNames {
AuditLog = "audit_log",
UserLimit = "user_limit",
BrowserOnly = "browser_only",
SCIM = "scim",
WorkspaceQuota = "workspace_quota",
}
+6
View File
@@ -641,6 +641,12 @@ export interface WorkspaceOptions {
readonly include_deleted?: boolean
}
// From codersdk/workspacequota.go
export interface WorkspaceQuota {
readonly user_workspace_count: number
readonly user_workspace_limit: number
}
// From codersdk/workspaceresources.go
export interface WorkspaceResource {
readonly id: string
@@ -12,6 +12,7 @@ export interface FormFooterProps {
onCancel: () => void
isLoading: boolean
submitLabel?: string
submitDisabled?: boolean
}
const useStyles = makeStyles((theme) => ({
@@ -34,6 +35,7 @@ export const FormFooter: FC<React.PropsWithChildren<FormFooterProps>> = ({
onCancel,
isLoading,
submitLabel = Language.defaultSubmitLabel,
submitDisabled,
}) => {
const styles = useStyles()
return (
@@ -45,6 +47,7 @@ export const FormFooter: FC<React.PropsWithChildren<FormFooterProps>> = ({
variant="contained"
color="primary"
type="submit"
disabled={submitDisabled}
>
{submitLabel}
</LoadingButton>
@@ -0,0 +1,51 @@
import { Story } from "@storybook/react"
import { WorkspaceQuota, WorkspaceQuotaProps } from "./WorkspaceQuota"
export default {
title: "components/WorkspaceQuota",
component: WorkspaceQuota,
}
const Template: Story<WorkspaceQuotaProps> = (args) => <WorkspaceQuota {...args} />
export const Example = Template.bind({})
Example.args = {
quota: {
user_workspace_count: 1,
user_workspace_limit: 3,
},
}
export const LimitOf1 = Template.bind({})
LimitOf1.args = {
quota: {
user_workspace_count: 1,
user_workspace_limit: 1,
},
}
export const Loading = Template.bind({})
Loading.args = {
quota: undefined,
}
export const Error = Template.bind({})
Error.args = {
quota: undefined,
error: {
response: {
data: {
message: "Failed to fetch workspace quotas!",
},
},
isAxiosError: true,
},
}
export const Disabled = Template.bind({})
Disabled.args = {
quota: {
user_workspace_count: 1,
user_workspace_limit: 0,
},
}
@@ -0,0 +1,114 @@
import Box from "@material-ui/core/Box"
import LinearProgress from "@material-ui/core/LinearProgress"
import { makeStyles } from "@material-ui/core/styles"
import Skeleton from "@material-ui/lab/Skeleton"
import { ErrorSummary } from "components/ErrorSummary/ErrorSummary"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
import { MONOSPACE_FONT_FAMILY } from "../../theme/constants"
export const Language = {
of: "of",
workspace: "workspace",
workspaces: "workspaces",
}
export interface WorkspaceQuotaProps {
quota?: TypesGen.WorkspaceQuota
error: Error | unknown
}
export const WorkspaceQuota: FC<WorkspaceQuotaProps> = ({ quota, error }) => {
const styles = useStyles()
// error state
if (error !== undefined) {
return (
<Box>
<Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Workspace Quota</span>
<ErrorSummary error={error} />
</Stack>
</Box>
)
}
// loading
if (quota === undefined) {
return (
<Box>
<Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Workspace Quota</span>
<LinearProgress color="primary" />
<div className={styles.label}>
<Skeleton className={styles.skeleton} />
</div>
</Stack>
</Box>
)
}
// don't show if limit is 0, this means the feature is disabled.
if (quota.user_workspace_limit === 0) {
return null
}
let value = Math.round((quota.user_workspace_count / quota.user_workspace_limit) * 100)
// we don't want to round down to zero if the count is > 0
if (quota.user_workspace_count > 0 && value === 0) {
value = 1
}
return (
<Box>
<Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Workspace Quota</span>
<LinearProgress
className={
quota.user_workspace_count >= quota.user_workspace_limit
? styles.maxProgress
: undefined
}
value={value}
variant="determinate"
/>
<div className={styles.label}>
{quota.user_workspace_count} {Language.of} {quota.user_workspace_limit}{" "}
{quota.user_workspace_limit === 1 ? Language.workspace : Language.workspaces}
{" used"}
</div>
</Stack>
</Box>
)
}
const useStyles = makeStyles((theme) => ({
stack: {
paddingTop: theme.spacing(2.5),
},
maxProgress: {
"& .MuiLinearProgress-colorPrimary": {
backgroundColor: theme.palette.error.main,
},
"& .MuiLinearProgress-barColorPrimary": {
backgroundColor: theme.palette.error.main,
},
},
title: {
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 21,
paddingBottom: "8px",
},
label: {
fontFamily: MONOSPACE_FONT_FAMILY,
fontSize: 12,
textTransform: "uppercase",
display: "block",
fontWeight: 600,
color: theme.palette.text.secondary,
},
skeleton: {
minWidth: "150px",
},
}))
@@ -4,7 +4,13 @@ import userEvent from "@testing-library/user-event"
import * as API from "api/api"
import { Language as FooterLanguage } from "components/FormFooter/FormFooter"
import i18next from "i18next"
import { MockTemplate, MockUser, MockWorkspace, MockWorkspaceRequest } from "testHelpers/entities"
import {
MockTemplate,
MockUser,
MockWorkspace,
MockWorkspaceQuota,
MockWorkspaceRequest,
} from "testHelpers/entities"
import { renderWithAuth } from "testHelpers/renderHelpers"
import CreateWorkspacePage from "./CreateWorkspacePage"
@@ -28,6 +34,7 @@ describe("CreateWorkspacePage", () => {
it("succeeds with default owner", async () => {
jest.spyOn(API, "getUsers").mockResolvedValueOnce([MockUser])
jest.spyOn(API, "getWorkspaceQuota").mockResolvedValueOnce(MockWorkspaceQuota)
jest.spyOn(API, "createWorkspace").mockResolvedValueOnce(MockWorkspace)
renderCreateWorkspacePage()
@@ -1,21 +1,32 @@
import { useActor, useMachine } from "@xstate/react"
import { User } from "api/typesGenerated"
import { shallowEqual, useActor, useMachine, useSelector } from "@xstate/react"
import { FeatureNames } from "api/types"
import { useOrganizationId } from "hooks/useOrganizationId"
import { FC, useContext, useState } from "react"
import { FC, useContext } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate, useParams } from "react-router-dom"
import { pageTitle } from "util/page"
import { createWorkspaceMachine } from "xServices/createWorkspace/createWorkspaceXService"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { XServiceContext } from "xServices/StateContext"
import { CreateWorkspaceErrors, CreateWorkspacePageView } from "./CreateWorkspacePageView"
const CreateWorkspacePage: FC = () => {
const xServices = useContext(XServiceContext)
const organizationId = useOrganizationId()
const { template } = useParams()
const templateName = template ? template : ""
const navigate = useNavigate()
const featureVisibility = useSelector(
xServices.entitlementsXService,
selectFeatureVisibility,
shallowEqual,
)
const workspaceQuotaEnabled = featureVisibility[FeatureNames.WorkspaceQuota]
const [authState] = useActor(xServices.authXService)
const { me } = authState.context
const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, {
context: { organizationId, templateName },
context: { organizationId, templateName, workspaceQuotaEnabled, owner: me ?? null },
actions: {
onCreateWorkspace: (_, event) => {
navigate(`/@${event.data.owner_name}/${event.data.name}`)
@@ -31,14 +42,11 @@ const CreateWorkspacePage: FC = () => {
getTemplatesError,
createWorkspaceError,
permissions,
workspaceQuota,
getWorkspaceQuotaError,
owner,
} = createWorkspaceState.context
const xServices = useContext(XServiceContext)
const [authState] = useActor(xServices.authXService)
const { me } = authState.context
const [owner, setOwner] = useState<User | null>(me ?? null)
return (
<>
<Helmet>
@@ -53,14 +61,21 @@ const CreateWorkspacePage: FC = () => {
templates={templates}
selectedTemplate={selectedTemplate}
templateSchema={templateSchema}
workspaceQuota={workspaceQuota}
createWorkspaceErrors={{
[CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: getTemplatesError,
[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: getTemplateSchemaError,
[CreateWorkspaceErrors.CREATE_WORKSPACE_ERROR]: createWorkspaceError,
[CreateWorkspaceErrors.GET_WORKSPACE_QUOTA_ERROR]: getWorkspaceQuotaError,
}}
canCreateForUser={permissions?.createWorkspaceForUser}
defaultWorkspaceOwner={me ?? null}
setOwner={setOwner}
owner={owner}
setOwner={(user) => {
send({
type: "SELECT_OWNER",
owner: user,
})
}}
onCancel={() => {
navigate("/templates")
}}
@@ -7,6 +7,7 @@ import { Loader } from "components/Loader/Loader"
import { ParameterInput } from "components/ParameterInput/ParameterInput"
import { Stack } from "components/Stack/Stack"
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"
import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import { i18n } from "i18n"
import { FC, useState } from "react"
@@ -18,6 +19,7 @@ export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError",
GET_TEMPLATE_SCHEMA_ERROR = "getTemplateSchemaError",
CREATE_WORKSPACE_ERROR = "createWorkspaceError",
GET_WORKSPACE_QUOTA_ERROR = "getWorkspaceQuotaError",
}
export interface CreateWorkspacePageViewProps {
@@ -29,9 +31,10 @@ export interface CreateWorkspacePageViewProps {
templates?: TypesGen.Template[]
selectedTemplate?: TypesGen.Template
templateSchema?: TypesGen.ParameterSchema[]
workspaceQuota?: TypesGen.WorkspaceQuota
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
canCreateForUser?: boolean
defaultWorkspaceOwner: TypesGen.User | null
owner: TypesGen.User | null
setOwner: (arg0: TypesGen.User | null) => void
onCancel: () => void
onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void
@@ -99,20 +102,21 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
<ErrorSummary
error={props.createWorkspaceErrors[CreateWorkspaceErrors.GET_TEMPLATES_ERROR]}
/>
) : (
<></>
)}
) : null}
{props.createWorkspaceErrors[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR] ? (
<ErrorSummary
error={props.createWorkspaceErrors[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]}
/>
) : (
<></>
)}
) : null}
</Stack>
)
}
const canSubmit =
props.workspaceQuota && props.workspaceQuota.user_workspace_limit > 0
? props.workspaceQuota.user_workspace_count < props.workspaceQuota.user_workspace_limit
: true
return (
<FullPageForm title="Create workspace" onCancel={props.onCancel}>
<form onSubmit={form.handleSubmit}>
@@ -145,8 +149,8 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
{props.canCreateForUser && (
<UserAutocomplete
value={props.defaultWorkspaceOwner}
onChange={(user) => props.setOwner(user)}
value={props.owner}
onChange={props.setOwner}
label={t("ownerLabel")}
inputMargin="dense"
showAvatar
@@ -171,7 +175,20 @@ export const CreateWorkspacePageView: FC<React.PropsWithChildren<CreateWorkspace
</Stack>
)}
<FormFooter onCancel={props.onCancel} isLoading={props.creatingWorkspace} />
{props.workspaceQuota && (
<WorkspaceQuota
quota={props.workspaceQuota}
error={
props.createWorkspaceErrors[CreateWorkspaceErrors.GET_WORKSPACE_QUOTA_ERROR]
}
/>
)}
<FormFooter
onCancel={props.onCancel}
isLoading={props.creatingWorkspace}
submitDisabled={!canSubmit}
/>
</>
)}
</Stack>
+5
View File
@@ -850,3 +850,8 @@ export const MockAuditLog2: TypesGen.AuditLog = {
},
},
}
export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = {
user_workspace_count: 0,
user_workspace_limit: 100,
}
@@ -3,6 +3,7 @@ import {
createWorkspace,
getTemplates,
getTemplateVersionSchema,
getWorkspaceQuota,
} from "api/api"
import {
CreateWorkspaceRequest,
@@ -10,6 +11,7 @@ import {
Template,
User,
Workspace,
WorkspaceQuota,
} from "api/typesGenerated"
import { assign, createMachine } from "xstate"
@@ -17,6 +19,7 @@ type CreateWorkspaceContext = {
organizationId: string
owner: User | null
templateName: string
workspaceQuotaEnabled: boolean
templates?: Template[]
selectedTemplate?: Template
templateSchema?: ParameterSchema[]
@@ -27,6 +30,8 @@ type CreateWorkspaceContext = {
getTemplateSchemaError?: Error | unknown
permissions?: Record<string, boolean>
checkPermissionsError?: Error | unknown
workspaceQuota?: WorkspaceQuota
getWorkspaceQuotaError?: Error | unknown
}
type CreateWorkspaceEvent = {
@@ -35,6 +40,11 @@ type CreateWorkspaceEvent = {
owner: User | null
}
type SelectOwnerEvent = {
type: "SELECT_OWNER"
owner: User | null
}
export const createWorkspaceMachine = createMachine(
{
id: "createWorkspaceState",
@@ -42,7 +52,7 @@ export const createWorkspaceMachine = createMachine(
tsTypes: {} as import("./createWorkspaceXService.typegen").Typegen0,
schema: {
context: {} as CreateWorkspaceContext,
events: {} as CreateWorkspaceEvent,
events: {} as CreateWorkspaceEvent | SelectOwnerEvent,
services: {} as {
getTemplates: {
data: Template[]
@@ -50,6 +60,9 @@ export const createWorkspaceMachine = createMachine(
getTemplateSchema: {
data: ParameterSchema[]
}
getWorkspaceQuota: {
data: WorkspaceQuota
}
createWorkspace: {
data: Workspace
}
@@ -98,10 +111,23 @@ export const createWorkspaceMachine = createMachine(
id: "checkPermissions",
onDone: {
actions: ["assignPermissions"],
target: "fillingParams",
target: "gettingWorkspaceQuota",
},
onError: {
actions: ["assignCheckPermissionsError"],
},
},
},
gettingWorkspaceQuota: {
entry: "clearGetWorkspaceQuotaError",
invoke: {
src: "getWorkspaceQuota",
onDone: {
actions: ["assignWorkspaceQuota"],
target: "fillingParams",
},
onError: {
actions: ["assignGetWorkspaceQuotaError"],
target: "error",
},
},
@@ -112,6 +138,10 @@ export const createWorkspaceMachine = createMachine(
actions: ["assignCreateWorkspaceRequest", "assignOwner"],
target: "creatingWorkspace",
},
SELECT_OWNER: {
actions: ["assignOwner"],
target: "gettingWorkspaceQuota",
},
},
},
creatingWorkspace: {
@@ -178,6 +208,17 @@ export const createWorkspaceMachine = createMachine(
return createWorkspace(organizationId, owner?.id ?? "me", createWorkspaceRequest)
},
getWorkspaceQuota: (context) => {
if (!context.workspaceQuotaEnabled) {
// resolving with a limit of 0 will disable the component
return Promise.resolve({
user_workspace_count: 0,
user_workspace_limit: 0,
})
}
return getWorkspaceQuota(context.owner?.id ?? "me")
},
},
guards: {
areTemplatesEmpty: (_, event) => event.data.length === 0,
@@ -230,6 +271,15 @@ export const createWorkspaceMachine = createMachine(
clearGetTemplateSchemaError: assign({
getTemplateSchemaError: (_) => undefined,
}),
assignWorkspaceQuota: assign({
workspaceQuota: (_, event) => event.data,
}),
assignGetWorkspaceQuotaError: assign({
getWorkspaceQuotaError: (_, event) => event.data,
}),
clearGetWorkspaceQuotaError: assign({
getWorkspaceQuotaError: (_) => undefined,
}),
},
},
)