mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -21,6 +21,5 @@ export enum FeatureNames {
|
||||
UserLimit = "user_limit",
|
||||
BrowserOnly = "browser_only",
|
||||
SCIM = "scim",
|
||||
WorkspaceQuota = "workspace_quota",
|
||||
TemplateRBAC = "template_rbac",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user