Implement Quotas v3 (#5012)

* provisioner/terraform: add cost to resource_metadata

* provisionerd/runner: use Options struct

* Complete provisionerd implementation

* Add quota_allowance to groups

* Combine Quota and RBAC licenses

* Add Opts to InTx
This commit is contained in:
Ammar Bandukwala
2022-11-14 17:57:33 +00:00
committed by GitHub
parent 3fb7892c07
commit 97dbd4dc5d
101 changed files with 1575 additions and 845 deletions
-1
View File
@@ -21,6 +21,5 @@ export enum FeatureNames {
UserLimit = "user_limit",
BrowserOnly = "browser_only",
SCIM = "scim",
WorkspaceQuota = "workspace_quota",
TemplateRBAC = "template_rbac",
}
+8 -4
View File
@@ -156,6 +156,7 @@ export interface CreateFirstUserResponse {
export interface CreateGroupRequest {
readonly name: string
readonly avatar_url: string
readonly quota_allowance: number
}
// From codersdk/users.go
@@ -301,7 +302,6 @@ export interface DeploymentConfig {
readonly audit_logging: DeploymentConfigField<boolean>
readonly browser_only: DeploymentConfigField<boolean>
readonly scim_api_key: DeploymentConfigField<string>
readonly user_workspace_quota: DeploymentConfigField<number>
readonly provisioner: ProvisionerConfig
readonly api_rate_limit: DeploymentConfigField<number>
readonly experimental: DeploymentConfigField<boolean>
@@ -374,6 +374,7 @@ export interface Group {
readonly organization_id: string
readonly members: User[]
readonly avatar_url: string
readonly quota_allowance: number
}
// From codersdk/workspaceapps.go
@@ -502,6 +503,7 @@ export interface PatchGroupRequest {
readonly remove_users: string[]
readonly name: string
readonly avatar_url?: string
readonly quota_allowance?: number
}
// From codersdk/deploymentconfig.go
@@ -881,6 +883,7 @@ export interface WorkspaceBuild {
readonly resources: WorkspaceResource[]
readonly deadline?: string
readonly status: WorkspaceStatus
readonly daily_cost: number
}
// From codersdk/workspaces.go
@@ -899,10 +902,10 @@ export interface WorkspaceOptions {
readonly include_deleted?: boolean
}
// From codersdk/workspacequota.go
// From codersdk/quota.go
export interface WorkspaceQuota {
readonly user_workspace_count: number
readonly user_workspace_limit: number
readonly credits_consumed: number
readonly budget: number
}
// From codersdk/workspacebuilds.go
@@ -917,6 +920,7 @@ export interface WorkspaceResource {
readonly icon: string
readonly agents?: WorkspaceAgent[]
readonly metadata?: WorkspaceResourceMetadata[]
readonly daily_cost: number
}
// From codersdk/workspacebuilds.go
+2 -1
View File
@@ -48,7 +48,8 @@ const useStyles = makeStyles((theme) => ({
overflowX: "auto",
},
line: {
whiteSpace: "nowrap",
// Whitespace is significant in terminal output for alignment
whiteSpace: "pre",
},
space: {
userSelect: "none",
@@ -53,6 +53,16 @@ export const ResourceCard: FC<ResourceCardProps> = ({ resource, agentRow }) => {
<Stack alignItems="flex-start" direction="row" spacing={5}>
<div className={styles.metadataHeader}>
{resource.daily_cost > 0 && (
<div className={styles.metadata}>
<div className={styles.metadataLabel}>
<b>cost</b>
</div>
<div className={styles.metadataValue}>
{resource.daily_cost}
</div>
</div>
)}
{visibleMetadata.map((meta) => {
return (
<div className={styles.metadata} key={meta.key}>
+7 -1
View File
@@ -55,6 +55,7 @@ export interface WorkspaceProps {
buildInfo?: TypesGen.BuildInfoResponse
applicationsHost?: string
template?: TypesGen.Template
quota_budget?: number
}
/**
@@ -77,6 +78,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
buildInfo,
applicationsHost,
template,
quota_budget,
}) => {
const { t } = useTranslation("workspacePage")
const styles = useStyles()
@@ -187,7 +189,11 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
handleClick={() => navigate(`/templates`)}
/>
<WorkspaceStats workspace={workspace} handleUpdate={handleUpdate} />
<WorkspaceStats
workspace={workspace}
quota_budget={quota_budget}
handleUpdate={handleUpdate}
/>
{isTransitioning !== undefined && isTransitioning && (
<WorkspaceBuildProgress
@@ -1,53 +0,0 @@
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,
},
}
@@ -1,123 +0,0 @@
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 { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Stack } from "components/Stack/Stack"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
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}>Usage Quota</span>
<AlertBanner severity="error" error={error} />
</Stack>
</Box>
)
}
// loading
if (quota === undefined) {
return (
<Box>
<Stack spacing={1} className={styles.stack}>
<span className={styles.title}>Usage 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.5} className={styles.stack}>
<Stack direction="row" justifyContent="space-between">
<span className={styles.title}>Usage Quota</span>
<div className={styles.label}>
<span className={styles.labelHighlight}>
{quota.user_workspace_count}
</span>{" "}
{Language.of}{" "}
<span className={styles.labelHighlight}>
{quota.user_workspace_limit}
</span>{" "}
{quota.user_workspace_limit === 1
? Language.workspace
: Language.workspaces}
{" used"}
</div>
</Stack>
<LinearProgress
className={
quota.user_workspace_count >= quota.user_workspace_limit
? styles.maxProgress
: undefined
}
value={value}
variant="determinate"
/>
</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: {
fontSize: 16,
},
label: {
fontSize: 14,
display: "block",
color: theme.palette.text.secondary,
},
labelHighlight: {
color: theme.palette.text.primary,
},
skeleton: {
minWidth: "150px",
},
}))
@@ -13,19 +13,22 @@ const Language = {
templateLabel: "Template",
statusLabel: "Workspace Status",
versionLabel: "Version",
lastBuiltLabel: "Last Built",
lastBuiltLabel: "Last built",
outdated: "Outdated",
upToDate: "Up to date",
byLabel: "Last built by",
costLabel: "Daily cost",
}
export interface WorkspaceStatsProps {
workspace: Workspace
quota_budget?: number
handleUpdate: () => void
}
export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
workspace,
quota_budget,
handleUpdate,
}) => {
const styles = useStyles()
@@ -74,6 +77,14 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
<span className={styles.statsLabel}>{Language.byLabel}:</span>
<span className={styles.statsValue}>{initiatedBy}</span>
</div>
{workspace.latest_build.daily_cost > 0 && (
<div className={styles.statItem}>
<span className={styles.statsLabel}>{Language.costLabel}:</span>
<span className={styles.statsValue}>
{workspace.latest_build.daily_cost} / {quota_budget}
</span>
</div>
)}
</div>
)
}
@@ -1,12 +1,10 @@
import { shallowEqual, useActor, useMachine, useSelector } from "@xstate/react"
import { FeatureNames } from "api/types"
import { useActor, useMachine } from "@xstate/react"
import { useOrganizationId } from "hooks/useOrganizationId"
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,
@@ -19,19 +17,12 @@ const CreateWorkspacePage: FC = () => {
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,
workspaceQuotaEnabled,
owner: me ?? null,
},
actions: {
@@ -49,8 +40,6 @@ const CreateWorkspacePage: FC = () => {
getTemplatesError,
createWorkspaceError,
permissions,
workspaceQuota,
getWorkspaceQuotaError,
owner,
} = createWorkspaceState.context
@@ -70,14 +59,11 @@ 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}
owner={owner}
@@ -4,7 +4,6 @@ import { FormFooter } from "components/FormFooter/FormFooter"
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"
@@ -20,7 +19,6 @@ export enum CreateWorkspaceErrors {
GET_TEMPLATES_ERROR = "getTemplatesError",
GET_TEMPLATE_SCHEMA_ERROR = "getTemplateSchemaError",
CREATE_WORKSPACE_ERROR = "createWorkspaceError",
GET_WORKSPACE_QUOTA_ERROR = "getWorkspaceQuotaError",
}
export interface CreateWorkspacePageViewProps {
@@ -32,7 +30,6 @@ export interface CreateWorkspacePageViewProps {
templates?: TypesGen.Template[]
selectedTemplate?: TypesGen.Template
templateSchema?: TypesGen.ParameterSchema[]
workspaceQuota?: TypesGen.WorkspaceQuota
createWorkspaceErrors: Partial<Record<CreateWorkspaceErrors, Error | unknown>>
canCreateForUser?: boolean
owner: TypesGen.User | null
@@ -94,12 +91,6 @@ export const CreateWorkspacePageView: FC<
},
})
const canSubmit =
props.workspaceQuota && props.workspaceQuota.user_workspace_limit > 0
? props.workspaceQuota.user_workspace_count <
props.workspaceQuota.user_workspace_limit
: true
const isLoading = props.loadingTemplateSchema || props.loadingTemplates
const getFieldHelpers = getFormHelpers<TypesGen.CreateWorkspaceRequest>(
@@ -237,17 +228,6 @@ export const CreateWorkspacePageView: FC<
inputMargin="dense"
showAvatar
/>
{props.workspaceQuota && (
<WorkspaceQuota
quota={props.workspaceQuota}
error={
props.createWorkspaceErrors[
CreateWorkspaceErrors.GET_WORKSPACE_QUOTA_ERROR
]
}
/>
)}
</Stack>
</div>
)}
@@ -289,7 +269,6 @@ export const CreateWorkspacePageView: FC<
styles={formFooterStyles}
onCancel={props.onCancel}
isLoading={props.creatingWorkspace}
submitDisabled={!canSubmit}
submitLabel={t("createWorkspace")}
/>
</Stack>
@@ -29,6 +29,7 @@ export const CreateGroupPageView: React.FC<CreateGroupPageViewProps> = ({
initialValues: {
name: "",
avatar_url: "",
quota_allowance: 0,
},
validationSchema,
onSubmit,
@@ -22,10 +22,12 @@ import * as Yup from "yup"
type FormData = {
name: string
avatar_url: string
quota_allowance: number
}
const validationSchema = Yup.object({
name: nameValidator("Name"),
quota_allowance: Yup.number().required().positive().integer(),
})
const UpdateGroupForm: React.FC<{
@@ -40,6 +42,7 @@ const UpdateGroupForm: React.FC<{
initialValues: {
name: group.name,
avatar_url: group.avatar_url,
quota_allowance: group.quota_allowance,
},
validationSchema,
onSubmit,
@@ -121,6 +124,20 @@ const UpdateGroupForm: React.FC<{
/>
</Popover>
<TextField
{...getFieldHelpers("quota_allowance")}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
type="number"
label="Quota Allowance"
variant="outlined"
/>
<span>
This group gives {form.values.quota_allowance} quota credits to each
of its members.
</span>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</form>
</FullPageForm>
+18 -1
View File
@@ -8,6 +8,7 @@ import { Loader } from "components/Loader/Loader"
import { firstOrItem } from "util/array"
import { workspaceMachine } from "xServices/workspace/workspaceXService"
import { WorkspaceReadyPage } from "./WorkspaceReadyPage"
import { quotaMachine } from "xServices/quotas/quotasXService"
export const WorkspacePage: FC = () => {
const { username: usernameQueryParam, workspace: workspaceQueryParam } =
@@ -21,6 +22,8 @@ export const WorkspacePage: FC = () => {
getTemplateWarning,
checkPermissionsError,
} = workspaceState.context
const [quotaState, quotaSend] = useMachine(quotaMachine)
const { getQuotaError } = quotaState.context
const styles = useStyles()
/**
@@ -33,6 +36,10 @@ export const WorkspacePage: FC = () => {
workspaceSend({ type: "GET_WORKSPACE", username, workspaceName })
}, [username, workspaceName, workspaceSend])
useEffect(() => {
username && quotaSend({ type: "GET_QUOTA", username })
}, [username, quotaSend])
return (
<ChooseOne>
<Cond condition={workspaceState.matches("error")}>
@@ -46,11 +53,21 @@ export const WorkspacePage: FC = () => {
{Boolean(checkPermissionsError) && (
<AlertBanner severity="error" error={checkPermissionsError} />
)}
{Boolean(getQuotaError) && (
<AlertBanner severity="error" error={getQuotaError} />
)}
</div>
</Cond>
<Cond condition={Boolean(workspace) && workspaceState.matches("ready")}>
<Cond
condition={
Boolean(workspace) &&
workspaceState.matches("ready") &&
quotaState.matches("success")
}
>
<WorkspaceReadyPage
workspaceState={workspaceState}
quotaState={quotaState}
workspaceSend={workspaceSend}
/>
</Cond>
@@ -10,6 +10,7 @@ import {
getMinDeadline,
} from "util/schedule"
import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors"
import { quotaMachine } from "xServices/quotas/quotasXService"
import { StateFrom } from "xstate"
import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog"
import {
@@ -26,11 +27,13 @@ import {
interface WorkspaceReadyPageProps {
workspaceState: StateFrom<typeof workspaceMachine>
quotaState: StateFrom<typeof quotaMachine>
workspaceSend: (event: WorkspaceEvent) => void
}
export const WorkspaceReadyPage = ({
workspaceState,
quotaState,
workspaceSend,
}: WorkspaceReadyPageProps): JSX.Element => {
const [bannerState, bannerSend] = useActor(
@@ -124,6 +127,7 @@ export const WorkspaceReadyPage = ({
buildInfo={buildInfoState.context.buildInfo}
applicationsHost={applicationsHost}
template={template}
quota_budget={quotaState.context.quota?.budget}
/>
<DeleteDialog
entity="workspace"
+8 -2
View File
@@ -311,6 +311,7 @@ export const MockWorkspaceResource: TypesGen.WorkspaceResource = {
{ key: "type", value: "a-workspace-resource", sensitive: false },
{ key: "api_key", value: "12345678", sensitive: true },
],
daily_cost: 10,
}
export const MockWorkspaceResource2: TypesGen.WorkspaceResource = {
@@ -331,6 +332,7 @@ export const MockWorkspaceResource2: TypesGen.WorkspaceResource = {
{ key: "type", value: "google_compute_disk", sensitive: false },
{ key: "size", value: "32GB", sensitive: false },
],
daily_cost: 10,
}
export const MockWorkspaceResource3: TypesGen.WorkspaceResource = {
@@ -351,6 +353,7 @@ export const MockWorkspaceResource3: TypesGen.WorkspaceResource = {
{ key: "type", value: "google_compute_disk", sensitive: false },
{ key: "size", value: "32GB", sensitive: false },
],
daily_cost: 20,
}
export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest =
@@ -383,6 +386,7 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
reason: "initiator",
resources: [MockWorkspaceResource],
status: "running",
daily_cost: 20,
}
export const MockFailedWorkspaceBuild = (
@@ -405,6 +409,7 @@ export const MockFailedWorkspaceBuild = (
reason: "initiator",
resources: [],
status: "running",
daily_cost: 20,
})
export const MockWorkspaceBuildStop: TypesGen.WorkspaceBuild = {
@@ -988,8 +993,8 @@ export const MockAuditLogWithWorkspaceBuild: TypesGen.AuditLog = {
}
export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = {
user_workspace_count: 0,
user_workspace_limit: 100,
credits_consumed: 0,
budget: 100,
}
export const MockGroup: TypesGen.Group = {
@@ -998,6 +1003,7 @@ export const MockGroup: TypesGen.Group = {
avatar_url: "https://example.com",
organization_id: MockOrganization.id,
members: [MockUser, MockUser2],
quota_allowance: 5,
}
export const MockTemplateACL: TypesGen.TemplateACL = {
+5 -1
View File
@@ -3,7 +3,7 @@ import { WorkspaceBuildTransition } from "../api/types"
import { CreateWorkspaceBuildRequest } from "../api/typesGenerated"
import { permissionsToCheck } from "../xServices/auth/authXService"
import * as M from "./entities"
import { MockGroup } from "./entities"
import { MockGroup, MockWorkspaceQuota } from "./entities"
export const handlers = [
rest.get("/api/v2/templates/:templateId/daus", async (req, res, ctx) => {
@@ -243,4 +243,8 @@ export const handlers = [
rest.delete("/api/v2/groups/:groupId", (req, res, ctx) => {
return res(ctx.status(204))
}),
rest.get("/api/v2/workspace-quota/:userId", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockWorkspaceQuota))
}),
]
+1
View File
@@ -6,6 +6,7 @@ export const everyOneGroup = (organizationId: string): Group => ({
organization_id: organizationId,
members: [],
avatar_url: "",
quota_allowance: 0,
})
export const getGroupSubtitle = (group: Group): string => {
@@ -3,7 +3,6 @@ import {
createWorkspace,
getTemplates,
getTemplateVersionSchema,
getWorkspaceQuota,
} from "api/api"
import {
CreateWorkspaceRequest,
@@ -11,7 +10,6 @@ import {
Template,
User,
Workspace,
WorkspaceQuota,
} from "api/typesGenerated"
import { assign, createMachine } from "xstate"
@@ -19,7 +17,6 @@ type CreateWorkspaceContext = {
organizationId: string
owner: User | null
templateName: string
workspaceQuotaEnabled: boolean
templates?: Template[]
selectedTemplate?: Template
templateSchema?: ParameterSchema[]
@@ -30,8 +27,6 @@ type CreateWorkspaceContext = {
getTemplateSchemaError?: Error | unknown
permissions?: Record<string, boolean>
checkPermissionsError?: Error | unknown
workspaceQuota?: WorkspaceQuota
getWorkspaceQuotaError?: Error | unknown
}
type CreateWorkspaceEvent = {
@@ -60,9 +55,6 @@ export const createWorkspaceMachine = createMachine(
getTemplateSchema: {
data: ParameterSchema[]
}
getWorkspaceQuota: {
data: WorkspaceQuota
}
createWorkspace: {
data: Workspace
}
@@ -110,25 +102,11 @@ export const createWorkspaceMachine = createMachine(
src: "checkPermissions",
id: "checkPermissions",
onDone: {
actions: ["assignPermissions"],
target: "gettingWorkspaceQuota",
},
onError: {
actions: ["assignCheckPermissionsError"],
},
},
},
gettingWorkspaceQuota: {
entry: "clearGetWorkspaceQuotaError",
invoke: {
src: "getWorkspaceQuota",
onDone: {
actions: ["assignWorkspaceQuota"],
actions: "assignPermissions",
target: "fillingParams",
},
onError: {
actions: ["assignGetWorkspaceQuotaError"],
target: "error",
actions: ["assignCheckPermissionsError"],
},
},
},
@@ -140,7 +118,7 @@ export const createWorkspaceMachine = createMachine(
},
SELECT_OWNER: {
actions: ["assignOwner"],
target: "gettingWorkspaceQuota",
target: ["fillingParams"],
},
},
},
@@ -212,17 +190,6 @@ export const createWorkspaceMachine = createMachine(
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,
@@ -278,15 +245,6 @@ export const createWorkspaceMachine = createMachine(
clearGetTemplateSchemaError: assign({
getTemplateSchemaError: (_) => undefined,
}),
assignWorkspaceQuota: assign({
workspaceQuota: (_, event) => event.data,
}),
assignGetWorkspaceQuotaError: assign({
getWorkspaceQuotaError: (_, event) => event.data,
}),
clearGetWorkspaceQuotaError: assign({
getWorkspaceQuotaError: (_) => undefined,
}),
},
},
)
@@ -29,7 +29,7 @@ export const editGroupMachine = createMachine(
},
events: {} as {
type: "UPDATE"
data: { name: string; avatar_url: string }
data: { name: string; avatar_url: string; quota_allowance: number }
},
},
tsTypes: {} as import("./editGroupXService.typegen").Typegen0,
@@ -0,0 +1,77 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { WorkspaceQuota } from "../../api/typesGenerated"
export const Language = {
getQuotaError: "Failed to get Quota",
}
export type QuotaContext = {
quota?: WorkspaceQuota
getQuotaError?: Error | unknown
}
export type QuotaEvent = {
type: "GET_QUOTA"
username: string
}
export const quotaMachine = createMachine(
{
id: "quotasMachine",
predictableActionArguments: true,
tsTypes: {} as import("./quotasXService.typegen").Typegen0,
schema: {
context: {} as QuotaContext,
events: {} as QuotaEvent,
services: {
getQuota: {
data: {} as WorkspaceQuota,
},
},
},
context: {},
initial: "idle",
states: {
idle: {
on: {
GET_QUOTA: "gettingQuotas",
},
},
gettingQuotas: {
entry: "clearGetQuotaError",
invoke: {
id: "getQuota",
src: "getQuota",
onDone: {
target: "success",
actions: ["assignQuota"],
},
onError: {
target: "idle",
actions: ["assignGetQuotaError"],
},
},
},
success: {
type: "final",
},
},
},
{
actions: {
assignQuota: assign({
quota: (_, event) => event.data,
}),
assignGetQuotaError: assign({
getQuotaError: (_, event) => event.data,
}),
clearGetQuotaError: assign({
getQuotaError: (_) => undefined,
}),
},
services: {
getQuota: (context, event) => API.getWorkspaceQuota(event.username),
},
},
)