From a0a8246bff86936bf5f210ac8b7d52d93b264d89 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 30 Aug 2022 17:25:18 +0000 Subject: [PATCH 01/46] initial mock --- site/src/components/Workspace/Workspace.tsx | 1 + .../WorkspaceQuota/WorkspaceQuota.tsx | 93 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 site/src/components/WorkspaceQuota/WorkspaceQuota.tsx diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index e8c1a82a41b76..d816f24343d1c 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,5 +1,6 @@ import { makeStyles } from "@material-ui/core/styles" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" +import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota" import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge" import { FC } from "react" import { useNavigate } from "react-router-dom" diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx new file mode 100644 index 0000000000000..c0598a18d84cc --- /dev/null +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -0,0 +1,93 @@ +import LinearProgress from '@material-ui/core/LinearProgress'; +import { FC } from "react" +import { makeStyles } from "@material-ui/core/styles" +import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" +import Skeleton from '@material-ui/lab/Skeleton'; + +export interface WorkspaceQuotaProps { + loading: boolean + count?: number + limit?: number +} + +export const WorkspaceQuota: FC = ({ loading, count, limit }) => { + const styles = useStyles() + + const safeCount = count ? count : 0; + const safeLimit = limit ? limit : 100; + const value = Math.round((safeCount / safeLimit) * 100) + const limitLanguage = limit ? limit : `∞` + + return ( +
+ + {loading ? ( +
+
+ +
+
+ +
+
+ FILLER TEXT IDK HOW CSS WORKS +
+
+ ) : ( +
+
+ +
+
+ {safeCount} of {limitLanguage} workspaces used +
+
+ )} +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + paddingLeft: theme.spacing(2), + paddingRight: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + display: "flex", + alignItems: "center", + color: theme.palette.text.secondary, + fontFamily: MONOSPACE_FONT_FAMILY, + margin: "0px", + [theme.breakpoints.down("sm")]: { + display: "block", + }, + }, + item: { + minWidth: "16%", + padding: theme.spacing(2), + }, + quotaBar: { + fontSize: 12, + textTransform: "uppercase", + display: "block", + fontWeight: 600, + wordWrap: "break-word", + paddingTop: theme.spacing(0.5), + }, + quotaLabel: { + fontSize: 12, + textTransform: "uppercase", + display: "block", + fontWeight: 600, + wordWrap: "break-word", + paddingTop: theme.spacing(0.5), + }, +})) From b19ce5781573e563a492ce90055977d38644653e Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 31 Aug 2022 19:10:57 +0000 Subject: [PATCH 02/46] move to navbar --- site/src/components/NavbarView/NavbarView.tsx | 22 +++++ site/src/components/Workspace/Workspace.tsx | 1 - .../WorkspaceQuota/WorkspaceQuota.tsx | 95 ++++++++----------- 3 files changed, 59 insertions(+), 59 deletions(-) diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 12eab8ca37c6c..062f7bcdc5e6e 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -4,6 +4,8 @@ import List from "@material-ui/core/List" import ListItem from "@material-ui/core/ListItem" import { makeStyles } from "@material-ui/core/styles" import MenuIcon from "@material-ui/icons/Menu" +import { Stack } from "components/Stack/Stack" +import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota" import { useState } from "react" import { NavLink, useLocation } from "react-router-dom" import { colors } from "theme/colors" @@ -105,12 +107,26 @@ export const NavbarView: React.FC> = ({ {user && } + +
+ +
+ +
+ {user && } +
+
) } const useStyles = makeStyles((theme) => ({ root: { + position: "relative", + display: "flex", + justifyContent: "space-between", + alignItems: "center", + alignContent: 'center', height: navHeight, background: theme.palette.background.paper, "@media (display-mode: standalone)": { @@ -148,6 +164,12 @@ const useStyles = makeStyles((theme) => ({ display: "flex", }, }, + quota: { + [theme.breakpoints.up("md")]: { + marginLeft: "auto", + }, + paddingTop: theme.spacing(1), + }, profileButton: { [theme.breakpoints.up("md")]: { marginLeft: "auto", diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index d816f24343d1c..e8c1a82a41b76 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,6 +1,5 @@ import { makeStyles } from "@material-ui/core/styles" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" -import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota" import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge" import { FC } from "react" import { useNavigate } from "react-router-dom" diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index c0598a18d84cc..5fa97efe0c484 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -3,6 +3,8 @@ import { FC } from "react" import { makeStyles } from "@material-ui/core/styles" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import Skeleton from '@material-ui/lab/Skeleton'; +import { Stack } from 'components/Stack/Stack'; +import Box from "@material-ui/core/Box"; export interface WorkspaceQuotaProps { loading: boolean @@ -15,79 +17,56 @@ export const WorkspaceQuota: FC = ({ loading, count, limit const safeCount = count ? count : 0; const safeLimit = limit ? limit : 100; - const value = Math.round((safeCount / safeLimit) * 100) + let value = Math.round((safeCount / safeLimit) * 100) + // we don't want to round down to zero if the count is > 0 + if (safeCount > 0 && value === 0) { + value = 1 + } const limitLanguage = limit ? limit : `∞` return ( -
- + + {loading ? ( -
-
- -
-
- -
-
- FILLER TEXT IDK HOW CSS WORKS -
+ <> + +
+
- ) : ( -
-
- -
-
- {safeCount} of {limitLanguage} workspaces used -
+ + ) : ( + <> + +
+ {safeCount} of {limitLanguage} workspaces used
- )} - -
+ + )} + + ) } const useStyles = makeStyles((theme) => ({ - root: { - paddingLeft: theme.spacing(2), - paddingRight: theme.spacing(2), - borderRadius: theme.shape.borderRadius, - border: `1px solid ${theme.palette.divider}`, - display: "flex", - alignItems: "center", - color: theme.palette.text.secondary, + schedule: { fontFamily: MONOSPACE_FONT_FAMILY, - margin: "0px", - [theme.breakpoints.down("sm")]: { - display: "block", - }, - }, - item: { - minWidth: "16%", - padding: theme.spacing(2), - }, - quotaBar: { - fontSize: 12, - textTransform: "uppercase", - display: "block", - fontWeight: 600, - wordWrap: "break-word", - paddingTop: theme.spacing(0.5), + display: 'inline-flex', }, - quotaLabel: { + scheduleLabel: { fontSize: 12, textTransform: "uppercase", display: "block", fontWeight: 600, - wordWrap: "break-word", - paddingTop: theme.spacing(0.5), + color: theme.palette.text.secondary, }, + skeleton: { + minWidth: "150px", + } })) From 06f3a1ae42f69f3868e846e00ebb836d7d793caa Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 31 Aug 2022 20:29:36 +0000 Subject: [PATCH 03/46] fix infinity size --- site/src/components/NavbarView/NavbarView.tsx | 2 +- site/src/components/WorkspaceQuota/WorkspaceQuota.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 062f7bcdc5e6e..d16ebd98478fb 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -109,7 +109,7 @@ export const NavbarView: React.FC> = ({
- +
diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index 5fa97efe0c484..59bfa1d6eca0e 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -22,7 +22,7 @@ export const WorkspaceQuota: FC = ({ loading, count, limit if (safeCount > 0 && value === 0) { value = 1 } - const limitLanguage = limit ? limit : `∞` + const limitLanguage = limit ? limit : () return ( @@ -68,5 +68,8 @@ const useStyles = makeStyles((theme) => ({ }, skeleton: { minWidth: "150px", - } + }, + infinity: { + fontSize: 18, + }, })) From 8342dcbdfdd9aa068ea0389a409bca17c733108c Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 31 Aug 2022 21:16:21 +0000 Subject: [PATCH 04/46] add storybook --- site/src/components/NavbarView/NavbarView.tsx | 2 +- .../WorkspaceQuota/WorkspaceQuota.stories.tsx | 29 ++++++++ .../WorkspaceQuota/WorkspaceQuota.tsx | 73 ++++++++++--------- 3 files changed, 67 insertions(+), 37 deletions(-) create mode 100644 site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index d16ebd98478fb..083384e574bf3 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -109,7 +109,7 @@ export const NavbarView: React.FC> = ({
- +
diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx new file mode 100644 index 0000000000000..bef6bfb912e3c --- /dev/null +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx @@ -0,0 +1,29 @@ +import { Story } from "@storybook/react" +import { WorkspaceQuota, WorkspaceQuotaProps } from "./WorkspaceQuota" + +export default { + title: "components/WorkspaceQuota", + component: WorkspaceQuota, +} + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + count: 1, + limit: 3, +} + +export const LimitOf1 = Template.bind({}) +LimitOf1.args = { + count: 1, + limit: 1, +} + +export const Loading = Template.bind({}) +Loading.args = { + count: undefined, + limit: undefined, +} + + diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index 59bfa1d6eca0e..c7119fd0f7304 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -6,60 +6,64 @@ import Skeleton from '@material-ui/lab/Skeleton'; import { Stack } from 'components/Stack/Stack'; import Box from "@material-ui/core/Box"; +export const Language = { + of: "of", + workspaceUsed: "workspace used", + workspacesUsed: "workspaces used", +} + export interface WorkspaceQuotaProps { - loading: boolean count?: number limit?: number } -export const WorkspaceQuota: FC = ({ loading, count, limit }) => { +export const WorkspaceQuota: FC = ({ count, limit }) => { const styles = useStyles() - const safeCount = count ? count : 0; - const safeLimit = limit ? limit : 100; - let value = Math.round((safeCount / safeLimit) * 100) + // loading state + if (count === undefined || limit === undefined) { + return ( + + + +
+ +
+
+
+ ) + } + + let value = Math.round((count / limit) * 100) // we don't want to round down to zero if the count is > 0 - if (safeCount > 0 && value === 0) { + if (count > 0 && value === 0) { value = 1 } - const limitLanguage = limit ? limit : () return ( - - {loading ? ( - <> - -
- -
- - ) : ( - <> - -
- {safeCount} of {limitLanguage} workspaces used -
- - )} + + +
+ {count} {Language.of} {limit} {limit === 1 ? Language.workspaceUsed : Language.workspacesUsed } +
) } const useStyles = makeStyles((theme) => ({ - schedule: { - fontFamily: MONOSPACE_FONT_FAMILY, + stack: { display: 'inline-flex', }, - scheduleLabel: { + label: { + fontFamily: MONOSPACE_FONT_FAMILY, fontSize: 12, textTransform: "uppercase", display: "block", @@ -69,7 +73,4 @@ const useStyles = makeStyles((theme) => ({ skeleton: { minWidth: "150px", }, - infinity: { - fontSize: 18, - }, })) From 03d1defd00b79a7e9f3811a3b6be85ece952728d Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 23 Sep 2022 20:40:54 +0000 Subject: [PATCH 05/46] connect entitlement --- codersdk/features.go | 17 ++++-- enterprise/cli/server.go | 12 +++-- enterprise/coderd/coderd.go | 52 ++++++++++++++----- enterprise/coderd/licenses.go | 9 ++-- site/src/api/types.ts | 2 + site/src/components/NavbarView/NavbarView.tsx | 12 ++--- .../WorkspaceQuota/WorkspaceQuota.stories.tsx | 2 - .../WorkspaceQuota/WorkspaceQuota.tsx | 27 ++++------ 8 files changed, 83 insertions(+), 50 deletions(-) diff --git a/codersdk/features.go b/codersdk/features.go index 89a3b7e9ed64f..0562d9e06c72e 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -15,13 +15,20 @@ const ( ) const ( - FeatureUserLimit = "user_limit" - FeatureAuditLog = "audit_log" - FeatureBrowserOnly = "browser_only" - FeatureSCIM = "scim" + FeatureUserLimit = "user_limit" + FeatureAuditLog = "audit_log" + FeatureBrowserOnly = "browser_only" + FeatureSCIM = "scim" + FeatureWorkspaceQuota = "workspace_quota" ) -var FeatureNames = []string{FeatureUserLimit, FeatureAuditLog, FeatureBrowserOnly, FeatureSCIM} +var FeatureNames = []string{ + FeatureUserLimit, + FeatureAuditLog, + FeatureBrowserOnly, + FeatureSCIM, + FeatureWorkspaceQuota, +} type Feature struct { Entitlement Entitlement `json:"entitlement"` diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index f9961eba0cb5d..0a0cdc7c8f84d 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -18,13 +18,15 @@ func server() *cobra.Command { auditLogging bool browserOnly bool scimAuthHeader string + workspaceQuota int ) cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) { api, err := coderd.New(ctx, &coderd.Options{ - AuditLogging: auditLogging, - BrowserOnly: browserOnly, - SCIMAPIKey: []byte(scimAuthHeader), - Options: options, + AuditLogging: auditLogging, + BrowserOnly: browserOnly, + SCIMAPIKey: []byte(scimAuthHeader), + WorkspaceQuota: workspaceQuota, + Options: options, }) if err != nil { return nil, err @@ -39,6 +41,8 @@ func server() *cobra.Command { "Whether Coder only allows connections to workspaces via the browser. "+enterpriseOnly) cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "", "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. "+enterpriseOnly) + cliflag.IntVarP(cmd.Flags(), &workspaceQuota, "workspace-quota", "", "CODER_WORKSPACE_QUOTA", 0, + "Whether Coder applies a limit on how many workspaces each user can create. "+enterpriseOnly) return cmd } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index b846447c55a43..2b9f1b1a0e4b1 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -43,9 +43,10 @@ func New(ctx context.Context, options *Options) (*API, error) { Entitlement: codersdk.EntitlementNotEntitled, Enabled: false, }, - auditLogs: codersdk.EntitlementNotEntitled, - browserOnly: codersdk.EntitlementNotEntitled, - scim: codersdk.EntitlementNotEntitled, + auditLogs: codersdk.EntitlementNotEntitled, + browserOnly: codersdk.EntitlementNotEntitled, + scim: codersdk.EntitlementNotEntitled, + workspaceQuota: codersdk.EntitlementNotEntitled, }, cancelEntitlementsLoop: cancelFunc, } @@ -96,8 +97,10 @@ type Options struct { AuditLogging bool // Whether to block non-browser connections. - BrowserOnly bool - SCIMAPIKey []byte + BrowserOnly bool + SCIMAPIKey []byte + WorkspaceQuota int + EntitlementsUpdateInterval time.Duration Keys map[string]ed25519.PublicKey } @@ -112,11 +115,12 @@ type API struct { } type entitlements struct { - hasLicense bool - activeUsers codersdk.Feature - auditLogs codersdk.Entitlement - browserOnly codersdk.Entitlement - scim codersdk.Entitlement + hasLicense bool + activeUsers codersdk.Feature + auditLogs codersdk.Entitlement + browserOnly codersdk.Entitlement + scim codersdk.Entitlement + workspaceQuota codersdk.Entitlement } func (api *API) Close() error { @@ -140,9 +144,10 @@ func (api *API) updateEntitlements(ctx context.Context) error { Enabled: false, Entitlement: codersdk.EntitlementNotEntitled, }, - auditLogs: codersdk.EntitlementNotEntitled, - scim: codersdk.EntitlementNotEntitled, - browserOnly: codersdk.EntitlementNotEntitled, + auditLogs: codersdk.EntitlementNotEntitled, + scim: codersdk.EntitlementNotEntitled, + browserOnly: codersdk.EntitlementNotEntitled, + workspaceQuota: codersdk.EntitlementNotEntitled, } // Here we loop through licenses to detect enabled features. @@ -181,6 +186,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { if claims.Features.SCIM > 0 { entitlements.scim = entitlement } + if claims.Features.WorkspaceQuota > 0 { + entitlements.workspaceQuota = entitlement + } } if entitlements.auditLogs != api.entitlements.auditLogs { @@ -205,6 +213,15 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler) } + // TODO(f0ssel) + if entitlements.workspaceQuota != api.entitlements.workspaceQuota { + // var handler func(rw http.ResponseWriter) bool + if entitlements.workspaceQuota != codersdk.EntitlementNotEntitled && api.WorkspaceQuota > 0 { + // handler = api.shouldBlockNonBrowserConnections + } + // api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler) + } + api.entitlements = entitlements return nil @@ -260,6 +277,15 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) { "Browser only connections are enabled but your license for this feature is expired.") } + resp.Features[codersdk.FeatureWorkspaceQuota] = codersdk.Feature{ + Entitlement: entitlements.workspaceQuota, + Enabled: api.WorkspaceQuota > 0, + } + if entitlements.workspaceQuota == codersdk.EntitlementGracePeriod && api.WorkspaceQuota > 0 { + resp.Warnings = append(resp.Warnings, + "Workspace quotas are enabled but your license for this feature is expired.") + } + httpapi.Write(ctx, rw, http.StatusOK, resp) } diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index a97e8ebcca4fe..9d43bbe6c2996 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -45,10 +45,11 @@ var key20220812 []byte var Keys = map[string]ed25519.PublicKey{"2022-08-12": ed25519.PublicKey(key20220812)} type Features struct { - UserLimit int64 `json:"user_limit"` - AuditLog int64 `json:"audit_log"` - BrowserOnly int64 `json:"browser_only"` - SCIM int64 `json:"scim"` + UserLimit int64 `json:"user_limit"` + AuditLog int64 `json:"audit_log"` + BrowserOnly int64 `json:"browser_only"` + SCIM int64 `json:"scim"` + WorkspaceQuota int64 `json:"workspace_quota"` } type Claims struct { diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 63d01a0ab55dd..420f805ccd85d 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -20,4 +20,6 @@ export enum FeatureNames { AuditLog = "audit_log", UserLimit = "user_limit", BrowserOnly = "browser_only", + SCIM = "scim", + WorkspaceQuota = "workspace_quota", } diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 083384e574bf3..f49ef5d1bf684 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -108,13 +108,13 @@ export const NavbarView: React.FC> = ({
-
- +
+
-
- {user && } -
+
+ {user && } +
) @@ -126,7 +126,7 @@ const useStyles = makeStyles((theme) => ({ display: "flex", justifyContent: "space-between", alignItems: "center", - alignContent: 'center', + alignContent: "center", height: navHeight, background: theme.palette.background.paper, "@media (display-mode: standalone)": { diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx index bef6bfb912e3c..9215fcc36119c 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx @@ -25,5 +25,3 @@ Loading.args = { count: undefined, limit: undefined, } - - diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index c7119fd0f7304..19ebc99a2f955 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -1,10 +1,10 @@ -import LinearProgress from '@material-ui/core/LinearProgress'; -import { FC } from "react" +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 { Stack } from "components/Stack/Stack" +import { FC } from "react" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" -import Skeleton from '@material-ui/lab/Skeleton'; -import { Stack } from 'components/Stack/Stack'; -import Box from "@material-ui/core/Box"; export const Language = { of: "of", @@ -25,11 +25,9 @@ export const WorkspaceQuota: FC = ({ count, limit }) => { return ( - +
- +
@@ -45,13 +43,10 @@ export const WorkspaceQuota: FC = ({ count, limit }) => { return ( - +
- {count} {Language.of} {limit} {limit === 1 ? Language.workspaceUsed : Language.workspacesUsed } + {count} {Language.of} {limit}{" "} + {limit === 1 ? Language.workspaceUsed : Language.workspacesUsed}
@@ -60,7 +55,7 @@ export const WorkspaceQuota: FC = ({ count, limit }) => { const useStyles = makeStyles((theme) => ({ stack: { - display: 'inline-flex', + display: "inline-flex", }, label: { fontFamily: MONOSPACE_FONT_FAMILY, From cce7c4538900a1297979124553ca1eafb68634d5 Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 23 Sep 2022 21:05:08 +0000 Subject: [PATCH 06/46] wip --- codersdk/workspacequota.go | 6 +++++ site/src/api/typesGenerated.ts | 6 +++++ site/src/components/Navbar/Navbar.tsx | 3 ++- site/src/components/NavbarView/NavbarView.tsx | 4 ++- .../WorkspaceQuota/WorkspaceQuota.stories.tsx | 23 ++++++++++++----- .../WorkspaceQuota/WorkspaceQuota.tsx | 25 +++++++++++-------- 6 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 codersdk/workspacequota.go diff --git a/codersdk/workspacequota.go b/codersdk/workspacequota.go new file mode 100644 index 0000000000000..7a1da175413ae --- /dev/null +++ b/codersdk/workspacequota.go @@ -0,0 +1,6 @@ +package codersdk + +type UserWorkspaceQuota struct { + Count int `json:"count"` + Limit int `json:"limit"` +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fdacbc6dacadf..fa3c9a1ac0b0a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -523,6 +523,12 @@ export interface UserRoles { readonly organization_roles: Record } +// From codersdk/workspacequota.go +export interface UserWorkspaceQuota { + readonly count: number + readonly limit: number +} + // From codersdk/users.go export interface UsersRequest extends Pagination { readonly q?: string diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index 09690c876e333..4a615ef680f3f 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -16,7 +16,8 @@ export const Navbar: React.FC = () => { ) const canViewAuditLog = featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) + const canViewWorkspaceQuota = featureVisibility[FeatureNames.WorkspaceQuota] const onSignOut = () => authSend("SIGN_OUT") - return + return } diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index f49ef5d1bf684..78c10135d2d42 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -19,6 +19,7 @@ export interface NavbarViewProps { user?: TypesGen.User onSignOut: () => void canViewAuditLog: boolean + canViewWorkspaceQuota: boolean } export const Language = { @@ -71,6 +72,7 @@ export const NavbarView: React.FC> = ({ user, onSignOut, canViewAuditLog, + canViewWorkspaceQuota, }) => { const styles = useStyles() const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -109,7 +111,7 @@ export const NavbarView: React.FC> = ({
- +
diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx index 9215fcc36119c..9857a956b12b1 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx @@ -10,18 +10,29 @@ const Template: Story = (args) => = ({ count, limit }) => { +export const WorkspaceQuota: FC = ({ quota }) => { const styles = useStyles() // loading state - if (count === undefined || limit === undefined) { + if (quota === undefined) { return ( @@ -34,9 +34,14 @@ export const WorkspaceQuota: FC = ({ count, limit }) => { ) } - let value = Math.round((count / limit) * 100) + // don't show if limit is 0, this means the feature is disabled. + if (quota.limit === 0) { + return (<>) + } + + let value = Math.round((quota.count / quota.limit) * 100) // we don't want to round down to zero if the count is > 0 - if (count > 0 && value === 0) { + if (quota.count > 0 && value === 0) { value = 1 } @@ -45,8 +50,8 @@ export const WorkspaceQuota: FC = ({ count, limit }) => {
- {count} {Language.of} {limit}{" "} - {limit === 1 ? Language.workspaceUsed : Language.workspacesUsed} + {quota.count} {Language.of} {quota.limit}{" "} + {quota.limit === 1 ? Language.workspace : Language.workspaces}{" used"}
From 27346fdd60747e98b6e52edf9ccfa4a556900e16 Mon Sep 17 00:00:00 2001 From: Garrett Date: Mon, 26 Sep 2022 15:45:06 +0000 Subject: [PATCH 07/46] demoable --- site/src/components/Navbar/Navbar.tsx | 6 +- site/src/components/NavbarView/NavbarView.tsx | 21 ++---- .../WorkspaceQuota/WorkspaceQuota.tsx | 7 +- .../pages/TemplatePage/TemplatePageView.tsx | 3 + .../pages/TemplatesPage/TemplatesPageView.tsx | 69 +++++++++++-------- .../WorkspacesPage/WorkspacesPageView.tsx | 9 ++- 6 files changed, 63 insertions(+), 52 deletions(-) diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index 4a615ef680f3f..c76dd193d1b6a 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -14,10 +14,8 @@ export const Navbar: React.FC = () => { selectFeatureVisibility, shallowEqual, ) - const canViewAuditLog = - featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) - const canViewWorkspaceQuota = featureVisibility[FeatureNames.WorkspaceQuota] + const canViewAuditLog = featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) const onSignOut = () => authSend("SIGN_OUT") - return + return } diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 78c10135d2d42..113a9b8df6760 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -19,7 +19,7 @@ export interface NavbarViewProps { user?: TypesGen.User onSignOut: () => void canViewAuditLog: boolean - canViewWorkspaceQuota: boolean + quota?: TypesGen.UserWorkspaceQuota } export const Language = { @@ -72,7 +72,6 @@ export const NavbarView: React.FC> = ({ user, onSignOut, canViewAuditLog, - canViewWorkspaceQuota, }) => { const styles = useStyles() const [isDrawerOpen, setIsDrawerOpen] = useState(false) @@ -105,30 +104,18 @@ export const NavbarView: React.FC> = ({ -
+ + {user && } -
+
- -
- -
-
- {user && } -
-
) } const useStyles = makeStyles((theme) => ({ root: { - position: "relative", - display: "flex", - justifyContent: "space-between", - alignItems: "center", - alignContent: "center", height: navHeight, background: theme.palette.background.paper, "@media (display-mode: standalone)": { diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index 2354228fdb597..27c7a117151cb 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -19,7 +19,10 @@ export interface WorkspaceQuotaProps { export const WorkspaceQuota: FC = ({ quota }) => { const styles = useStyles() - + quota = { + count: 1, + limit: 10, + } // loading state if (quota === undefined) { return ( @@ -60,7 +63,7 @@ export const WorkspaceQuota: FC = ({ quota }) => { const useStyles = makeStyles((theme) => ({ stack: { - display: "inline-flex", + paddingTop: theme.spacing(2.5), }, label: { fontFamily: MONOSPACE_FONT_FAMILY, diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx index 82414c32bba38..f865fdb404588 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePageView.tsx @@ -8,6 +8,7 @@ import { DeleteButton } from "components/DropdownButton/ActionCtas" import { DropdownButton } from "components/DropdownButton/DropdownButton" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" import { Markdown } from "components/Markdown/Markdown" +import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota" import frontMatter from "front-matter" import { FC } from "react" import { Link as RouterLink } from "react-router-dom" @@ -77,6 +78,7 @@ export const TemplatePageView: FC const createWorkspaceButton = (className?: string) => ( + {/* @@ -89,6 +91,7 @@ export const TemplatePageView: FC + {/* */} - - - - Templates - - - - 0)}> - - Choose a template to create a new workspace - {props.canCreateTemplate ? ( - <> - , or{" "} - - manage templates - {" "} - from the CLI. - - ) : ( - "." - )} - - - - + + + + } + > + + + Templates + + + + 0)}> + + Choose a template to create a new workspace + {props.canCreateTemplate ? ( + <> + , or{" "} + + manage templates + {" "} + from the CLI. + + ) : ( + "." + )} + + + ({ width: "100%", }, }, + quota: { + [theme.breakpoints.up("md")]: { + marginLeft: "auto", + }, + paddingTop: theme.spacing(6), + paddingBottom: theme.spacing(5), + }, })) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index d127dafea3cdc..55bf98dc7a9f1 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -1,4 +1,5 @@ import Link from "@material-ui/core/Link" +import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota" import { FC } from "react" import { Link as RouterLink } from "react-router-dom" import { Margins } from "../../components/Margins/Margins" @@ -42,7 +43,13 @@ export const WorkspacesPageView: FC - + + + + } + > {Language.pageTitle} From d3f7aa7eadace64ad50c93ccd68935ed53e4c3d9 Mon Sep 17 00:00:00 2001 From: Garrett Date: Mon, 26 Sep 2022 19:49:08 +0000 Subject: [PATCH 08/46] only on create page --- site/src/components/FormFooter/FormFooter.tsx | 1 + site/src/components/NavbarView/NavbarView.tsx | 1 - .../WorkspaceQuota/WorkspaceQuota.tsx | 27 ++++++++++++++++--- .../CreateWorkspacePageView.tsx | 3 +++ .../pages/TemplatePage/TemplatePageView.tsx | 2 -- .../pages/TemplatesPage/TemplatesPageView.tsx | 9 +------ .../WorkspacesPage/WorkspacesPageView.tsx | 8 +----- 7 files changed, 30 insertions(+), 21 deletions(-) diff --git a/site/src/components/FormFooter/FormFooter.tsx b/site/src/components/FormFooter/FormFooter.tsx index 916bcc9629d0c..14f84a618e47e 100644 --- a/site/src/components/FormFooter/FormFooter.tsx +++ b/site/src/components/FormFooter/FormFooter.tsx @@ -45,6 +45,7 @@ export const FormFooter: FC> = ({ variant="contained" color="primary" type="submit" + disabled > {submitLabel} diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 113a9b8df6760..ee8b9909b561d 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -105,7 +105,6 @@ export const NavbarView: React.FC> = ({ - {user && } diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index 27c7a117151cb..298212a3cb902 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -20,14 +20,18 @@ export interface WorkspaceQuotaProps { export const WorkspaceQuota: FC = ({ quota }) => { const styles = useStyles() quota = { - count: 1, - limit: 10, + count: 3, + limit: 3, } + // quota = undefined // loading state if (quota === undefined) { return ( + + Workspace Quota +
@@ -48,10 +52,14 @@ export const WorkspaceQuota: FC = ({ quota }) => { value = 1 } + return ( - + + Workspace Quota + + = quota.limit ? styles.maxProgress : undefined} value={value} variant="determinate" />
{quota.count} {Language.of} {quota.limit}{" "} {quota.limit === 1 ? Language.workspace : Language.workspaces}{" used"} @@ -65,6 +73,19 @@ 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, diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 1372f3517f46a..7f9cb44037023 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -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" @@ -170,6 +171,8 @@ export const CreateWorkspacePageView: FC )} + + )} diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx index f865fdb404588..6521e06ec27d8 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePageView.tsx @@ -8,7 +8,6 @@ import { DeleteButton } from "components/DropdownButton/ActionCtas" import { DropdownButton } from "components/DropdownButton/DropdownButton" import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" import { Markdown } from "components/Markdown/Markdown" -import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota" import frontMatter from "front-matter" import { FC } from "react" import { Link as RouterLink } from "react-router-dom" @@ -91,7 +90,6 @@ export const TemplatePageView: FC - {/* */} - - - - } - > + Templates diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 55bf98dc7a9f1..d17f35eaa864d 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -43,13 +43,7 @@ export const WorkspacesPageView: FC - - - - } - > + {Language.pageTitle} From 07b807c13949c8b4bad144d70704dc4502d0974c Mon Sep 17 00:00:00 2001 From: Garrett Date: Mon, 26 Sep 2022 20:18:36 +0000 Subject: [PATCH 09/46] wip --- site/src/components/WorkspaceQuota/WorkspaceQuota.tsx | 6 +----- site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx | 2 ++ .../pages/CreateWorkspacePage/CreateWorkspacePageView.tsx | 3 ++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index 298212a3cb902..79d2103b11518 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -19,11 +19,7 @@ export interface WorkspaceQuotaProps { export const WorkspaceQuota: FC = ({ quota }) => { const styles = useStyles() - quota = { - count: 3, - limit: 3, - } - // quota = undefined + // loading state if (quota === undefined) { return ( diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 94ff2fabbc779..0814a6e8740ab 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -31,6 +31,7 @@ const CreateWorkspacePage: FC = () => { getTemplatesError, createWorkspaceError, permissions, + quota, } = createWorkspaceState.context const xServices = useContext(XServiceContext) @@ -53,6 +54,7 @@ const CreateWorkspacePage: FC = () => { templates={templates} selectedTemplate={selectedTemplate} templateSchema={templateSchema} + quota={quota} createWorkspaceErrors={{ [CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: getTemplatesError, [CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: getTemplateSchemaError, diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 7f9cb44037023..536454563aa42 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -30,6 +30,7 @@ export interface CreateWorkspacePageViewProps { templates?: TypesGen.Template[] selectedTemplate?: TypesGen.Template templateSchema?: TypesGen.ParameterSchema[] + quota?: TypesGen.UserWorkspaceQuota createWorkspaceErrors: Partial> canCreateForUser?: boolean defaultWorkspaceOwner: TypesGen.User | null @@ -171,7 +172,7 @@ export const CreateWorkspacePageView: FC )} - + From 082a04fc5b219f3df0685e624f250c57b1083af6 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 15:26:37 +0000 Subject: [PATCH 10/46] add enforcers --- coderd/coderd.go | 12 ++++++++++-- coderd/workspacequota/workspacequota.go | 19 +++++++++++++++++++ enterprise/coderd/coderd.go | 8 ++++---- enterprise/coderd/workspacequota.go | 25 +++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 coderd/workspacequota/workspacequota.go create mode 100644 enterprise/coderd/workspacequota.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 0ac4a7c68dc5f..6d638324710e4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -35,6 +35,7 @@ import ( "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" + "github.com/coder/coder/coderd/workspacequota" "github.com/coder/coder/coderd/wsconncache" "github.com/coder/coder/codersdk" "github.com/coder/coder/site" @@ -55,6 +56,7 @@ type Options struct { CacheDir string Auditor audit.Auditor + WorkspaceQuotaEnforcer workspacequota.Enforcer AgentConnectionUpdateFrequency time.Duration AgentInactiveDisconnectTimeout time.Duration // APIRateLimit is the minutely throughput rate limit per user or ip. @@ -120,6 +122,9 @@ func New(options *Options) *API { if options.Auditor == nil { options.Auditor = audit.NewNop() } + if options.WorkspaceQuotaEnforcer == nil { + options.WorkspaceQuotaEnforcer = workspacequota.NewNop() + } siteCacheDir := options.CacheDir if siteCacheDir != "" { @@ -145,10 +150,12 @@ func New(options *Options) *API { Authorizer: options.Authorizer, Logger: options.Logger, }, - metricsCache: metricsCache, - Auditor: atomic.Pointer[audit.Auditor]{}, + metricsCache: metricsCache, + Auditor: atomic.Pointer[audit.Auditor]{}, + WorkspaceQuotaEnforcer: atomic.Pointer[workspacequota.Enforcer]{}, } api.Auditor.Store(&options.Auditor) + api.WorkspaceQuotaEnforcer.Store(&options.WorkspaceQuotaEnforcer) api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0) api.derpServer = derp.NewServer(key.NewNode(), tailnet.Logger(options.Logger)) oauthConfigs := &httpmw.OAuth2Configs{ @@ -516,6 +523,7 @@ type API struct { *Options Auditor atomic.Pointer[audit.Auditor] WorkspaceClientCoordinateOverride atomic.Pointer[func(rw http.ResponseWriter) bool] + WorkspaceQuotaEnforcer atomic.Pointer[workspacequota.Enforcer] HTTPAuth *HTTPAuthorizer // APIHandler serves "/api/v2" diff --git a/coderd/workspacequota/workspacequota.go b/coderd/workspacequota/workspacequota.go new file mode 100644 index 0000000000000..297492884444f --- /dev/null +++ b/coderd/workspacequota/workspacequota.go @@ -0,0 +1,19 @@ +package workspacequota + +type Enforcer interface { + UserWorkspaceLimit() int + CanCreateWorkspace(count int) bool +} + +type nop struct{} + +func NewNop() *nop { + return &nop{} +} + +func (_ *nop) UserWorkspaceLimit() int { + return 0 +} +func (_ *nop) CanCreateWorkspace(_ int) bool { + return true +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 2b9f1b1a0e4b1..9d242305be96e 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -18,6 +18,7 @@ import ( agplaudit "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/workspacequota" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/audit" "github.com/coder/coder/enterprise/audit/backends" @@ -213,13 +214,12 @@ func (api *API) updateEntitlements(ctx context.Context) error { api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler) } - // TODO(f0ssel) if entitlements.workspaceQuota != api.entitlements.workspaceQuota { - // var handler func(rw http.ResponseWriter) bool + var enforcer workspacequota.Enforcer if entitlements.workspaceQuota != codersdk.EntitlementNotEntitled && api.WorkspaceQuota > 0 { - // handler = api.shouldBlockNonBrowserConnections + enforcer = NewEnforcer(api.WorkspaceQuota) } - // api.AGPL.WorkspaceClientCoordinateOverride.Store(&handler) + api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer) } api.entitlements = entitlements diff --git a/enterprise/coderd/workspacequota.go b/enterprise/coderd/workspacequota.go new file mode 100644 index 0000000000000..dd0952c9e9901 --- /dev/null +++ b/enterprise/coderd/workspacequota.go @@ -0,0 +1,25 @@ +package coderd + +import "github.com/coder/coder/coderd/workspacequota" + +type enforcer struct { + userWorkspaceLimit int +} + +func NewEnforcer(userWorkspaceLimit int) workspacequota.Enforcer { + return &enforcer{ + userWorkspaceLimit: userWorkspaceLimit, + } +} + +func (e *enforcer) UserWorkspaceLimit() int { + return e.userWorkspaceLimit +} + +func (e *enforcer) CanCreateWorkspace(count int) bool { + if e.userWorkspaceLimit == 0 { + return true + } + + return count < e.userWorkspaceLimit +} From 1faa7c78a394911f5861209774141dfd28113591 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 15:53:43 +0000 Subject: [PATCH 11/46] add quota enforcer to routes --- coderd/coderd.go | 1 + coderd/workspacequota.go | 37 ++++++++++++++++++++++++++++++++ coderd/workspaces.go | 43 +++++++++++++++++++++++++------------- codersdk/workspacequota.go | 6 +++--- 4 files changed, 69 insertions(+), 18 deletions(-) create mode 100644 coderd/workspacequota.go diff --git a/coderd/coderd.go b/coderd/coderd.go index 6d638324710e4..ae6628fa2b0b3 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -412,6 +412,7 @@ func New(options *Options) *API { }) r.Get("/gitsshkey", api.gitSSHKey) r.Put("/gitsshkey", api.regenerateGitSSHKey) + r.Get("/workspace-quota", api.workspaceQuota) }) }) }) diff --git a/coderd/workspacequota.go b/coderd/workspacequota.go new file mode 100644 index 0000000000000..21129f32933b7 --- /dev/null +++ b/coderd/workspacequota.go @@ -0,0 +1,37 @@ +package coderd + +import ( + "net/http" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" +) + +func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceUser) { + httpapi.ResourceNotFound(rw) + return + } + + workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{ + OwnerID: user.ID, + }) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspaces.", + Detail: err.Error(), + }) + return + } + + e := *api.WorkspaceQuotaEnforcer.Load() + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceQuota{ + UserWorkspaceCount: len(workspaces), + UserWorkspaceLimit: e.UserWorkspaceLimit(), + }) +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 3d08d222990aa..9bcadb85c3d74 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -294,21 +294,9 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{ + workspaces, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{ OwnerID: user.ID, - Name: createWorkspace.Name, }) - if err == nil { - // If the workspace already exists, don't allow creation. - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: fmt.Sprintf("Workspace %q already exists.", createWorkspace.Name), - Validations: []codersdk.ValidationError{{ - Field: "name", - Detail: "This value is already in use and should be unique.", - }}, - }) - return - } if err != nil && !errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: fmt.Sprintf("Internal error fetching workspace by name %q.", createWorkspace.Name), @@ -316,6 +304,28 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req }) return } + for _, workspace := range workspaces { + if workspace.Name == createWorkspace.Name { + // If the workspace already exists, don't allow creation. + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("Workspace %q already exists.", createWorkspace.Name), + Validations: []codersdk.ValidationError{{ + Field: "name", + Detail: "This value is already in use and should be unique.", + }}, + }) + return + } + } + + // make sure the user has not hit their quota limit + e := *api.WorkspaceQuotaEnforcer.Load() + canCreate := e.CanCreateWorkspace(len(workspaces)) + if !canCreate { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("User workspace limit of %s is already reached.", e.UserWorkspaceLimit()), + }) + } templateVersion, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID) if err != nil { @@ -352,8 +362,11 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - var provisionerJob database.ProvisionerJob - var workspaceBuild database.WorkspaceBuild + var ( + workspace database.Workspace + provisionerJob database.ProvisionerJob + workspaceBuild database.WorkspaceBuild + ) err = api.Database.InTx(func(db database.Store) error { now := database.Now() workspaceBuildID := uuid.New() diff --git a/codersdk/workspacequota.go b/codersdk/workspacequota.go index 7a1da175413ae..8d236691d6011 100644 --- a/codersdk/workspacequota.go +++ b/codersdk/workspacequota.go @@ -1,6 +1,6 @@ package codersdk -type UserWorkspaceQuota struct { - Count int `json:"count"` - Limit int `json:"limit"` +type WorkspaceQuota struct { + UserWorkspaceCount int `json:"user_workspace_count"` + UserWorkspaceLimit int `json:"user_workspace_limit"` } From 347cfa7bdd18086d631fa7a398542d618c33c1f5 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 15:56:01 +0000 Subject: [PATCH 12/46] remove unused changes --- site/src/components/NavbarView/NavbarView.tsx | 14 +---- .../pages/TemplatePage/TemplatePageView.tsx | 1 - .../pages/TemplatesPage/TemplatesPageView.tsx | 62 +++++++++---------- .../WorkspacesPage/WorkspacesPageView.tsx | 1 - 4 files changed, 30 insertions(+), 48 deletions(-) diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index ee8b9909b561d..12eab8ca37c6c 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -4,8 +4,6 @@ import List from "@material-ui/core/List" import ListItem from "@material-ui/core/ListItem" import { makeStyles } from "@material-ui/core/styles" import MenuIcon from "@material-ui/icons/Menu" -import { Stack } from "components/Stack/Stack" -import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota" import { useState } from "react" import { NavLink, useLocation } from "react-router-dom" import { colors } from "theme/colors" @@ -19,7 +17,6 @@ export interface NavbarViewProps { user?: TypesGen.User onSignOut: () => void canViewAuditLog: boolean - quota?: TypesGen.UserWorkspaceQuota } export const Language = { @@ -104,11 +101,10 @@ export const NavbarView: React.FC> = ({ - +
{user && } - +
- ) } @@ -152,12 +148,6 @@ const useStyles = makeStyles((theme) => ({ display: "flex", }, }, - quota: { - [theme.breakpoints.up("md")]: { - marginLeft: "auto", - }, - paddingTop: theme.spacing(1), - }, profileButton: { [theme.breakpoints.up("md")]: { marginLeft: "auto", diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx index 6521e06ec27d8..82414c32bba38 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePageView.tsx @@ -77,7 +77,6 @@ export const TemplatePageView: FC const createWorkspaceButton = (className?: string) => ( - {/* diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 7984d0c426581..af2d3e9de6fcd 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -98,33 +98,34 @@ export const TemplatesPageView: FC - - - - Templates - - - - 0)}> - - Choose a template to create a new workspace - {props.canCreateTemplate ? ( - <> - , or{" "} - - manage templates - {" "} - from the CLI. - - ) : ( - "." - )} - - - + + + + Templates + + + + 0)}> + + Choose a template to create a new workspace + {props.canCreateTemplate ? ( + <> + , or{" "} + + manage templates + {" "} + from the CLI. + + ) : ( + "." + )} + + + + ({ width: "100%", }, }, - quota: { - [theme.breakpoints.up("md")]: { - marginLeft: "auto", - }, - paddingTop: theme.spacing(6), - paddingBottom: theme.spacing(5), - }, })) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index d17f35eaa864d..d127dafea3cdc 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -1,5 +1,4 @@ import Link from "@material-ui/core/Link" -import { WorkspaceQuota } from "components/WorkspaceQuota/WorkspaceQuota" import { FC } from "react" import { Link as RouterLink } from "react-router-dom" import { Margins } from "../../components/Margins/Margins" From f712ba85b309d54e384674c547896708a94d053b Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 15:58:14 +0000 Subject: [PATCH 13/46] remove unused changes --- site/src/components/FormFooter/FormFooter.tsx | 1 - site/src/components/Navbar/Navbar.tsx | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/components/FormFooter/FormFooter.tsx b/site/src/components/FormFooter/FormFooter.tsx index 14f84a618e47e..916bcc9629d0c 100644 --- a/site/src/components/FormFooter/FormFooter.tsx +++ b/site/src/components/FormFooter/FormFooter.tsx @@ -45,7 +45,6 @@ export const FormFooter: FC> = ({ variant="contained" color="primary" type="submit" - disabled > {submitLabel} diff --git a/site/src/components/Navbar/Navbar.tsx b/site/src/components/Navbar/Navbar.tsx index c76dd193d1b6a..09690c876e333 100644 --- a/site/src/components/Navbar/Navbar.tsx +++ b/site/src/components/Navbar/Navbar.tsx @@ -14,7 +14,8 @@ export const Navbar: React.FC = () => { selectFeatureVisibility, shallowEqual, ) - const canViewAuditLog = featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) + const canViewAuditLog = + featureVisibility[FeatureNames.AuditLog] && Boolean(permissions?.viewAuditLog) const onSignOut = () => authSend("SIGN_OUT") return From e464126b630bc66391496390e533fb376c04cfe6 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 16:01:39 +0000 Subject: [PATCH 14/46] make gen --- site/src/api/typesGenerated.ts | 12 +++---- .../WorkspaceQuota/WorkspaceQuota.stories.tsx | 8 ++--- .../WorkspaceQuota/WorkspaceQuota.tsx | 36 ++++++++++--------- .../CreateWorkspacePageView.tsx | 4 +-- 4 files changed, 32 insertions(+), 28 deletions(-) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fa3c9a1ac0b0a..caf536b8bcb48 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -523,12 +523,6 @@ export interface UserRoles { readonly organization_roles: Record } -// From codersdk/workspacequota.go -export interface UserWorkspaceQuota { - readonly count: number - readonly limit: number -} - // From codersdk/users.go export interface UsersRequest extends Pagination { readonly q?: string @@ -647,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 diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx index 9857a956b12b1..6d6efd09c6667 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx @@ -13,7 +13,7 @@ Example.args = { quota: { count: 1, limit: 3, - } + }, } export const LimitOf1 = Template.bind({}) @@ -21,12 +21,12 @@ LimitOf1.args = { quota: { count: 1, limit: 1, - } + }, } export const Loading = Template.bind({}) Loading.args = { - quota: undefined + quota: undefined, } export const Disabled = Template.bind({}) @@ -34,5 +34,5 @@ Disabled.args = { quota: { count: 1, limit: 0, - } + }, } diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index 79d2103b11518..c54b38c412111 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -4,8 +4,8 @@ import { makeStyles } from "@material-ui/core/styles" import Skeleton from "@material-ui/lab/Skeleton" import { Stack } from "components/Stack/Stack" import { FC } from "react" -import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" import * as TypesGen from "../../api/typesGenerated" +import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" export const Language = { of: "of", @@ -14,7 +14,7 @@ export const Language = { } export interface WorkspaceQuotaProps { - quota?: TypesGen.UserWorkspaceQuota + quota?: TypesGen.WorkspaceQuota } export const WorkspaceQuota: FC = ({ quota }) => { @@ -25,9 +25,7 @@ export const WorkspaceQuota: FC = ({ quota }) => { return ( - - Workspace Quota - + Workspace Quota
@@ -38,27 +36,33 @@ export const WorkspaceQuota: FC = ({ quota }) => { } // don't show if limit is 0, this means the feature is disabled. - if (quota.limit === 0) { - return (<>) + if (quota.user_workspace_limit === 0) { + return <> } - let value = Math.round((quota.count / quota.limit) * 100) + 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.count > 0 && value === 0) { + if (quota.user_workspace_count > 0 && value === 0) { value = 1 } - return ( - - Workspace Quota - - = quota.limit ? styles.maxProgress : undefined} value={value} variant="determinate" /> + Workspace Quota + = quota.user_workspace_limit + ? styles.maxProgress + : undefined + } + value={value} + variant="determinate" + />
- {quota.count} {Language.of} {quota.limit}{" "} - {quota.limit === 1 ? Language.workspace : Language.workspaces}{" used"} + {quota.user_workspace_count} {Language.of} {quota.user_workspace_limit}{" "} + {quota.user_workspace_limit === 1 ? Language.workspace : Language.workspaces} + {" used"}
diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 536454563aa42..00e239eb216ad 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -30,7 +30,7 @@ export interface CreateWorkspacePageViewProps { templates?: TypesGen.Template[] selectedTemplate?: TypesGen.Template templateSchema?: TypesGen.ParameterSchema[] - quota?: TypesGen.UserWorkspaceQuota + quota?: TypesGen.WorkspaceQuota createWorkspaceErrors: Partial> canCreateForUser?: boolean defaultWorkspaceOwner: TypesGen.User | null @@ -172,7 +172,7 @@ export const CreateWorkspacePageView: FC )} - + From cbe7882fe70294d10416cbe888cb45a30c05b477 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 16:14:24 +0000 Subject: [PATCH 15/46] lint --- coderd/workspacequota/workspacequota.go | 4 ++-- coderd/workspaces.go | 3 ++- .../WorkspaceQuota/WorkspaceQuota.stories.tsx | 12 ++++++------ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/coderd/workspacequota/workspacequota.go b/coderd/workspacequota/workspacequota.go index 297492884444f..2a511a954c73e 100644 --- a/coderd/workspacequota/workspacequota.go +++ b/coderd/workspacequota/workspacequota.go @@ -11,9 +11,9 @@ func NewNop() *nop { return &nop{} } -func (_ *nop) UserWorkspaceLimit() int { +func (*nop) UserWorkspaceLimit() int { return 0 } -func (_ *nop) CanCreateWorkspace(_ int) bool { +func (*nop) CanCreateWorkspace(_ int) bool { return true } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 9bcadb85c3d74..38b8c76bbd87a 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -323,8 +323,9 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req canCreate := e.CanCreateWorkspace(len(workspaces)) if !canCreate { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: fmt.Sprintf("User workspace limit of %s is already reached.", e.UserWorkspaceLimit()), + Message: fmt.Sprintf("User workspace limit of %d is already reached.", e.UserWorkspaceLimit()), }) + return } templateVersion, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID) diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx index 6d6efd09c6667..876fac379b64d 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx @@ -11,16 +11,16 @@ const Template: Story = (args) => Date: Tue, 27 Sep 2022 17:50:55 +0000 Subject: [PATCH 16/46] fix tests --- enterprise/cli/features_test.go | 2 +- enterprise/coderd/licenses_test.go | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index 4832082e28c32..6fbc3689e5305 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -57,7 +57,7 @@ func TestFeaturesList(t *testing.T) { var entitlements codersdk.Entitlements err := json.Unmarshal(buf.Bytes(), &entitlements) require.NoError(t, err, "unmarshal JSON output") - assert.Len(t, entitlements.Features, 3) + assert.Len(t, entitlements.Features, 4) assert.Empty(t, entitlements.Warnings) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureUserLimit].Entitlement) diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index 0b74c1caf6239..c4b7111597079 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -98,18 +98,20 @@ func TestGetLicense(t *testing.T) { assert.Equal(t, int32(1), licenses[0].ID) assert.Equal(t, "testing", licenses[0].Claims["account_id"]) assert.Equal(t, map[string]interface{}{ - codersdk.FeatureUserLimit: json.Number("0"), - codersdk.FeatureAuditLog: json.Number("1"), - codersdk.FeatureSCIM: json.Number("1"), - codersdk.FeatureBrowserOnly: json.Number("1"), + codersdk.FeatureUserLimit: json.Number("0"), + codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureBrowserOnly: json.Number("1"), + codersdk.FeatureWorkspaceQuota: json.Number("0"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) assert.Equal(t, map[string]interface{}{ - codersdk.FeatureUserLimit: json.Number("200"), - codersdk.FeatureAuditLog: json.Number("1"), - codersdk.FeatureSCIM: json.Number("1"), - codersdk.FeatureBrowserOnly: json.Number("1"), + codersdk.FeatureUserLimit: json.Number("200"), + codersdk.FeatureAuditLog: json.Number("1"), + codersdk.FeatureSCIM: json.Number("1"), + codersdk.FeatureBrowserOnly: json.Number("1"), + codersdk.FeatureWorkspaceQuota: json.Number("0"), }, licenses[1].Claims["features"]) }) } From bdce03a64c7815a050a27206c872ec02ea076657 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 19:37:52 +0000 Subject: [PATCH 17/46] add state --- site/src/api/api.ts | 5 +++ site/src/components/FormFooter/FormFooter.tsx | 3 ++ .../WorkspaceQuota/WorkspaceQuota.stories.tsx | 13 +++++++ .../WorkspaceQuota/WorkspaceQuota.tsx | 20 +++++++++-- .../CreateWorkspacePage.tsx | 6 ++-- .../CreateWorkspacePageView.tsx | 12 +++++-- .../createWorkspaceXService.ts | 36 +++++++++++++++++-- 7 files changed, 86 insertions(+), 9 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 00ba5f6630efb..ff4654c12689a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -500,3 +500,8 @@ export const getApplicationsHost = async (): Promise => { + const response = await axios.get(`/api/v2/users/me/workspace-quota`) + return response.data +} diff --git a/site/src/components/FormFooter/FormFooter.tsx b/site/src/components/FormFooter/FormFooter.tsx index 916bcc9629d0c..598237a742bcc 100644 --- a/site/src/components/FormFooter/FormFooter.tsx +++ b/site/src/components/FormFooter/FormFooter.tsx @@ -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> = ({ onCancel, isLoading, submitLabel = Language.defaultSubmitLabel, + submitDisabled, }) => { const styles = useStyles() return ( @@ -45,6 +47,7 @@ export const FormFooter: FC> = ({ variant="contained" color="primary" type="submit" + disabled={submitDisabled} > {submitLabel} diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx index 876fac379b64d..9915db7a89093 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.stories.tsx @@ -29,6 +29,19 @@ 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: { diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index c54b38c412111..84430333f4617 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -2,6 +2,7 @@ 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" @@ -15,12 +16,27 @@ export const Language = { export interface WorkspaceQuotaProps { quota?: TypesGen.WorkspaceQuota + error: Error | unknown } -export const WorkspaceQuota: FC = ({ quota }) => { +export const WorkspaceQuota: FC = ({ quota, error }) => { const styles = useStyles() - // loading state + // error state + if (error !== undefined) { + return ( + + + Workspace Quota + + + + ) + } + + // loading if (quota === undefined) { return ( diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 0814a6e8740ab..8a26a042f6d34 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -31,7 +31,8 @@ const CreateWorkspacePage: FC = () => { getTemplatesError, createWorkspaceError, permissions, - quota, + workspaceQuota, + getWorkspaceQuotaError, } = createWorkspaceState.context const xServices = useContext(XServiceContext) @@ -54,11 +55,12 @@ const CreateWorkspacePage: FC = () => { templates={templates} selectedTemplate={selectedTemplate} templateSchema={templateSchema} - quota={quota} + quota={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} diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 00e239eb216ad..f0ad7d4ab509a 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -19,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 { @@ -115,6 +116,8 @@ export const CreateWorkspacePageView: FC 0 ? props.quota.user_workspace_count < props.quota.user_workspace_limit : true + return (
@@ -172,9 +175,14 @@ export const CreateWorkspacePageView: FC )} - + {props.quota ? ( + + ) : ( + <> + ) + } - + )} diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index d7f5a58a32f0e..9a4f7fef42db8 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -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" @@ -27,6 +29,8 @@ type CreateWorkspaceContext = { getTemplateSchemaError?: Error | unknown permissions?: Record checkPermissionsError?: Error | unknown + workspaceQuota?: WorkspaceQuota + getWorkspaceQuotaError?: Error | unknown } type CreateWorkspaceEvent = { @@ -46,13 +50,16 @@ export const createWorkspaceMachine = createMachine( services: {} as { getTemplates: { data: Template[] - } + }, getTemplateSchema: { data: ParameterSchema[] - } + }, + getWorkspaceQuota: { + data: WorkspaceQuota + }, createWorkspace: { data: Workspace - } + }, }, }, initial: "gettingTemplates", @@ -102,6 +109,19 @@ export const createWorkspaceMachine = createMachine( }, onError: { actions: ["assignCheckPermissionsError"], + } + } + }, + gettingWorkspaceQuota: { + entry: "clearGetWorkspaceQuotaError", + invoke: { + src: "getWorkspaceQuota", + onDone: { + actions: ["assignWorkspaceQuota"], + target: "fillingParams", + }, + onError: { + actions: ["assignGetWorkspaceQuotaError"], target: "error", }, }, @@ -178,6 +198,7 @@ export const createWorkspaceMachine = createMachine( return createWorkspace(organizationId, owner?.id ?? "me", createWorkspaceRequest) }, + getWorkspaceQuota: () => getWorkspaceQuota(), }, guards: { areTemplatesEmpty: (_, event) => event.data.length === 0, @@ -230,6 +251,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, + }), }, }, ) From c3c7261a53324f0957ec7aad6d4d8b15885cc064 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 19:46:39 +0000 Subject: [PATCH 18/46] fmt --- .../WorkspaceQuota/WorkspaceQuota.tsx | 4 +-- .../CreateWorkspacePageView.tsx | 27 +++++++++++++------ .../createWorkspaceXService.ts | 8 +++--- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index 84430333f4617..a9c796fee246f 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -28,9 +28,7 @@ export const WorkspaceQuota: FC = ({ quota, error }) => { Workspace Quota - + ) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index f0ad7d4ab509a..35c018d300d30 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -19,7 +19,7 @@ export enum CreateWorkspaceErrors { GET_TEMPLATES_ERROR = "getTemplatesError", GET_TEMPLATE_SCHEMA_ERROR = "getTemplateSchemaError", CREATE_WORKSPACE_ERROR = "createWorkspaceError", - GET_WORKSPACE_QUOTA_ERROR = "getWorkspaceQuotaError" + GET_WORKSPACE_QUOTA_ERROR = "getWorkspaceQuotaError", } export interface CreateWorkspacePageViewProps { @@ -116,7 +116,10 @@ export const CreateWorkspacePageView: FC 0 ? props.quota.user_workspace_count < props.quota.user_workspace_limit : true + const canSubmit = + props.quota && props.quota.user_workspace_limit > 0 + ? props.quota.user_workspace_count < props.quota.user_workspace_limit + : true return ( @@ -176,13 +179,21 @@ export const CreateWorkspacePageView: FC - ) : ( - <> - ) - } + + ) : ( + <> + )} - + )} diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 9a4f7fef42db8..f213d11d176ab 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -50,16 +50,16 @@ export const createWorkspaceMachine = createMachine( services: {} as { getTemplates: { data: Template[] - }, + } getTemplateSchema: { data: ParameterSchema[] - }, + } getWorkspaceQuota: { data: WorkspaceQuota - }, + } createWorkspace: { data: Workspace - }, + } }, }, initial: "gettingTemplates", From 347c83441f45013919d2a015fd2fd5ccdca63e99 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 20:18:07 +0000 Subject: [PATCH 19/46] cleanup names --- enterprise/cli/features_test.go | 2 ++ enterprise/cli/server.go | 14 +++++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index 6fbc3689e5305..4621dd07e3def 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -65,6 +65,8 @@ func TestFeaturesList(t *testing.T) { entitlements.Features[codersdk.FeatureAuditLog].Entitlement) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureWorkspaceQuota].Entitlement) assert.False(t, entitlements.HasLicense) }) } diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 0a0cdc7c8f84d..b7ccbf563c033 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -15,17 +15,17 @@ import ( func server() *cobra.Command { var ( - auditLogging bool - browserOnly bool - scimAuthHeader string - workspaceQuota int + auditLogging bool + browserOnly bool + scimAuthHeader string + userWorkspaceQuota int ) cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) { api, err := coderd.New(ctx, &coderd.Options{ AuditLogging: auditLogging, BrowserOnly: browserOnly, SCIMAPIKey: []byte(scimAuthHeader), - WorkspaceQuota: workspaceQuota, + WorkspaceQuota: userWorkspaceQuota, Options: options, }) if err != nil { @@ -41,8 +41,8 @@ func server() *cobra.Command { "Whether Coder only allows connections to workspaces via the browser. "+enterpriseOnly) cliflag.StringVarP(cmd.Flags(), &scimAuthHeader, "scim-auth-header", "", "CODER_SCIM_API_KEY", "", "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication. "+enterpriseOnly) - cliflag.IntVarP(cmd.Flags(), &workspaceQuota, "workspace-quota", "", "CODER_WORKSPACE_QUOTA", 0, - "Whether Coder applies a limit on how many workspaces each user can create. "+enterpriseOnly) + cliflag.IntVarP(cmd.Flags(), &userWorkspaceQuota, "user-workspace-quota", "", "CODER_USER_WORKSPACE_QUOTA", 0, + "A positive number applies a limit on how many workspaces each user can create. "+enterpriseOnly) return cmd } From 76db33918536db878bedba4ffa5fcf126f5ad5ea Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 20:38:55 +0000 Subject: [PATCH 20/46] options --- enterprise/cli/server.go | 12 ++++++---- enterprise/coderd/coderd.go | 24 ++++++++----------- .../coderd/coderdenttest/coderdenttest.go | 2 ++ 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index b7ccbf563c033..777c11895f266 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -21,12 +21,14 @@ func server() *cobra.Command { userWorkspaceQuota int ) cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) { + coderd.NewEnforcer(userWorkspaceQuota) + api, err := coderd.New(ctx, &coderd.Options{ - AuditLogging: auditLogging, - BrowserOnly: browserOnly, - SCIMAPIKey: []byte(scimAuthHeader), - WorkspaceQuota: userWorkspaceQuota, - Options: options, + AuditLogging: auditLogging, + BrowserOnly: browserOnly, + SCIMAPIKey: []byte(scimAuthHeader), + UserWorkspaceQuota: userWorkspaceQuota, + Options: options, }) if err != nil { return nil, err diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 9d242305be96e..c35147dc72443 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -15,10 +15,8 @@ import ( "cdr.dev/slog" "github.com/coder/coder/coderd" - agplaudit "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/workspacequota" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/audit" "github.com/coder/coder/enterprise/audit/backends" @@ -98,9 +96,9 @@ type Options struct { AuditLogging bool // Whether to block non-browser connections. - BrowserOnly bool - SCIMAPIKey []byte - WorkspaceQuota int + BrowserOnly bool + SCIMAPIKey []byte + UserWorkspaceQuota int EntitlementsUpdateInterval time.Duration Keys map[string]ed25519.PublicKey @@ -193,17 +191,16 @@ func (api *API) updateEntitlements(ctx context.Context) error { } if entitlements.auditLogs != api.entitlements.auditLogs { - auditor := agplaudit.NewNop() // A flag could be added to the options that would allow disabling // enhanced audit logging here! if entitlements.auditLogs != codersdk.EntitlementNotEntitled && api.AuditLogging { - auditor = audit.NewAuditor( + auditor := audit.NewAuditor( audit.DefaultFilter, backends.NewPostgres(api.Database, true), backends.NewSlog(api.Logger), ) + api.AGPL.Auditor.Store(&auditor) } - api.AGPL.Auditor.Store(&auditor) } if entitlements.browserOnly != api.entitlements.browserOnly { @@ -215,11 +212,10 @@ func (api *API) updateEntitlements(ctx context.Context) error { } if entitlements.workspaceQuota != api.entitlements.workspaceQuota { - var enforcer workspacequota.Enforcer - if entitlements.workspaceQuota != codersdk.EntitlementNotEntitled && api.WorkspaceQuota > 0 { - enforcer = NewEnforcer(api.WorkspaceQuota) + if entitlements.workspaceQuota != codersdk.EntitlementNotEntitled && api.Options.UserWorkspaceQuota > 0 { + enforcer := NewEnforcer(api.Options.UserWorkspaceQuota) + api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer) } - api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer) } api.entitlements = entitlements @@ -279,9 +275,9 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) { resp.Features[codersdk.FeatureWorkspaceQuota] = codersdk.Feature{ Entitlement: entitlements.workspaceQuota, - Enabled: api.WorkspaceQuota > 0, + Enabled: api.UserWorkspaceQuota > 0, } - if entitlements.workspaceQuota == codersdk.EntitlementGracePeriod && api.WorkspaceQuota > 0 { + if entitlements.workspaceQuota == codersdk.EntitlementGracePeriod && api.UserWorkspaceQuota > 0 { resp.Warnings = append(resp.Warnings, "Workspace quotas are enabled but your license for this feature is expired.") } diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index bef7fdb313f6f..7dda7c35e0b66 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -39,6 +39,7 @@ type Options struct { BrowserOnly bool EntitlementsUpdateInterval time.Duration SCIMAPIKey []byte + UserWorkspaceQuota int } // New constructs a codersdk client connected to an in-memory Enterprise API instance. @@ -59,6 +60,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c AuditLogging: true, BrowserOnly: options.BrowserOnly, SCIMAPIKey: options.SCIMAPIKey, + UserWorkspaceQuota: options.UserWorkspaceQuota, Options: oop, EntitlementsUpdateInterval: options.EntitlementsUpdateInterval, Keys: map[string]ed25519.PublicKey{ From 7f4a2454d829d8b73537ac9e70824c50014ab2da Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 20:46:37 +0000 Subject: [PATCH 21/46] remove enforcer --- enterprise/cli/server.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 777c11895f266..6dde1c31cd75c 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -21,8 +21,6 @@ func server() *cobra.Command { userWorkspaceQuota int ) cmd := agpl.Server(func(ctx context.Context, options *agplcoderd.Options) (*agplcoderd.API, error) { - coderd.NewEnforcer(userWorkspaceQuota) - api, err := coderd.New(ctx, &coderd.Options{ AuditLogging: auditLogging, BrowserOnly: browserOnly, From d357edf02e902e9f39e2be334e8ed5f1e4f0e7c5 Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 27 Sep 2022 20:49:12 +0000 Subject: [PATCH 22/46] enttest options --- .../coderd/coderdenttest/coderdenttest.go | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 7dda7c35e0b66..912eb29090f4e 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -82,14 +82,15 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c } type LicenseOptions struct { - AccountType string - AccountID string - GraceAt time.Time - ExpiresAt time.Time - UserLimit int64 - AuditLog bool - BrowserOnly bool - SCIM bool + AccountType string + AccountID string + GraceAt time.Time + ExpiresAt time.Time + UserLimit int64 + AuditLog bool + BrowserOnly bool + SCIM bool + WorkspaceQuota bool } // AddLicense generates a new license with the options provided and inserts it. @@ -121,6 +122,10 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { if options.SCIM { scim = 1 } + workspaceQuota := int64(0) + if options.WorkspaceQuota { + workspaceQuota = 1 + } c := &coderd.Claims{ RegisteredClaims: jwt.RegisteredClaims{ @@ -134,10 +139,11 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { AccountID: options.AccountID, Version: coderd.CurrentVersion, Features: coderd.Features{ - UserLimit: options.UserLimit, - AuditLog: auditLog, - BrowserOnly: browserOnly, - SCIM: scim, + UserLimit: options.UserLimit, + AuditLog: auditLog, + BrowserOnly: browserOnly, + SCIM: scim, + WorkspaceQuota: workspaceQuota, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) From 63eed861bad8ce2e1c9497771c1c7ae45947592e Mon Sep 17 00:00:00 2001 From: Garrett Date: Thu, 29 Sep 2022 16:52:10 +0000 Subject: [PATCH 23/46] fix option usage --- coderd/workspacequota/workspacequota.go | 2 +- enterprise/coderd/coderd.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/workspacequota/workspacequota.go b/coderd/workspacequota/workspacequota.go index 2a511a954c73e..54bd46ca4165d 100644 --- a/coderd/workspacequota/workspacequota.go +++ b/coderd/workspacequota/workspacequota.go @@ -7,7 +7,7 @@ type Enforcer interface { type nop struct{} -func NewNop() *nop { +func NewNop() Enforcer { return &nop{} } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index c35147dc72443..205963d94f06c 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -212,7 +212,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { } if entitlements.workspaceQuota != api.entitlements.workspaceQuota { - if entitlements.workspaceQuota != codersdk.EntitlementNotEntitled && api.Options.UserWorkspaceQuota > 0 { + if entitlements.workspaceQuota != codersdk.EntitlementNotEntitled && api.UserWorkspaceQuota > 0 { enforcer := NewEnforcer(api.Options.UserWorkspaceQuota) api.AGPL.WorkspaceQuotaEnforcer.Store(&enforcer) } From 9d3ab76251ffd206e5000425de3b98ac78c66ec7 Mon Sep 17 00:00:00 2001 From: Garrett Date: Thu, 29 Sep 2022 18:12:00 +0000 Subject: [PATCH 24/46] add tests for flags --- codersdk/workspacequota.go | 20 +++++++++ enterprise/coderd/workspacequota_test.go | 57 ++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 enterprise/coderd/workspacequota_test.go diff --git a/codersdk/workspacequota.go b/codersdk/workspacequota.go index 8d236691d6011..e96596a2f66e1 100644 --- a/codersdk/workspacequota.go +++ b/codersdk/workspacequota.go @@ -1,6 +1,26 @@ package codersdk +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + type WorkspaceQuota struct { UserWorkspaceCount int `json:"user_workspace_count"` UserWorkspaceLimit int `json:"user_workspace_limit"` } + +func (c *Client) WorkspaceQuota(ctx context.Context, userID string) (WorkspaceQuota, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspace-quota", userID), nil) + if err != nil { + return WorkspaceQuota{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return WorkspaceQuota{}, readBodyAsError(res) + } + var quota WorkspaceQuota + return quota, json.NewDecoder(res.Body).Decode("a) +} diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go new file mode 100644 index 0000000000000..01fa7e476d604 --- /dev/null +++ b/enterprise/coderd/workspacequota_test.go @@ -0,0 +1,57 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" + "github.com/stretchr/testify/require" +) + +func TestWorkspaceQuota(t *testing.T) { + t.Parallel() + t.Run("Disabled", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdenttest.New(t, &coderdenttest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + WorkspaceQuota: true, + }) + q1, err := client.WorkspaceQuota(ctx, "me") + require.NoError(t, err) + require.EqualValues(t, q1.UserWorkspaceLimit, 0) + + }) + t.Run("Enabled", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + max := 3 + client := coderdenttest.New(t, &coderdenttest.Options{ + UserWorkspaceQuota: max, + }) + user := coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + WorkspaceQuota: true, + }) + q1, err := client.WorkspaceQuota(ctx, "me") + require.NoError(t, err) + require.EqualValues(t, q1.UserWorkspaceLimit, max) + + // ensure other user IDs work too + u2, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "whatever@yo.com", + Username: "haha", + Password: "laskjdnvkaj", + OrganizationID: user.OrganizationID, + }) + q2, err := client.WorkspaceQuota(ctx, u2.ID.String()) + require.NoError(t, err) + require.EqualValues(t, q1, q2) + }) +} From 6527c43262f367548ee621ed04d662995a5f1dc5 Mon Sep 17 00:00:00 2001 From: Garrett Date: Thu, 29 Sep 2022 18:24:23 +0000 Subject: [PATCH 25/46] add more tests --- enterprise/coderd/workspacequota_test.go | 72 +++++++++++++++++++++++- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index 01fa7e476d604..b2fc5c3c4dfeb 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -3,12 +3,18 @@ package coderd_test import ( "context" "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/testutil" - "github.com/stretchr/testify/require" ) func TestWorkspaceQuota(t *testing.T) { @@ -22,7 +28,7 @@ func TestWorkspaceQuota(t *testing.T) { coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ WorkspaceQuota: true, }) - q1, err := client.WorkspaceQuota(ctx, "me") + q1, err := client.WorkspaceQuota(ctx, codersdk.Me) require.NoError(t, err) require.EqualValues(t, q1.UserWorkspaceLimit, 0) @@ -39,7 +45,7 @@ func TestWorkspaceQuota(t *testing.T) { coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ WorkspaceQuota: true, }) - q1, err := client.WorkspaceQuota(ctx, "me") + q1, err := client.WorkspaceQuota(ctx, codersdk.Me) require.NoError(t, err) require.EqualValues(t, q1.UserWorkspaceLimit, max) @@ -50,8 +56,68 @@ func TestWorkspaceQuota(t *testing.T) { Password: "laskjdnvkaj", OrganizationID: user.OrganizationID, }) + require.NoError(t, err) q2, err := client.WorkspaceQuota(ctx, u2.ID.String()) require.NoError(t, err) require.EqualValues(t, q1, q2) }) + t.Run("BlocksBuild", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + max := 1 + client := coderdenttest.New(t, &coderdenttest.Options{ + UserWorkspaceQuota: max, + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + WorkspaceQuota: true, + }) + q1, err := client.WorkspaceQuota(ctx, codersdk.Me) + require.NoError(t, err) + require.EqualValues(t, q1.UserWorkspaceCount, 0) + require.EqualValues(t, q1.UserWorkspaceLimit, max) + + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Name: "example", + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _, err = client.CreateWorkspace(context.Background(), user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "ajksdnvksjd", + AutostartSchedule: ptr.Ref("CRON_TZ=US/Central 30 9 * * 1-5"), + TTLMillis: ptr.Ref((8 * time.Hour).Milliseconds()), + }) + require.Error(t, err) + require.ErrorContains(t, err, "User workspace limit") + + // ensure count increments + q1, err = client.WorkspaceQuota(ctx, codersdk.Me) + require.NoError(t, err) + require.EqualValues(t, q1.UserWorkspaceCount, 1) + require.EqualValues(t, q1.UserWorkspaceLimit, max) + }) } From 5e661755da82d433e341f4a28b8f594c49947724 Mon Sep 17 00:00:00 2001 From: Garrett Date: Thu, 29 Sep 2022 18:24:45 +0000 Subject: [PATCH 26/46] add more tests --- enterprise/coderd/workspacequota_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/enterprise/coderd/workspacequota_test.go b/enterprise/coderd/workspacequota_test.go index b2fc5c3c4dfeb..fb4f8f00b9dfd 100644 --- a/enterprise/coderd/workspacequota_test.go +++ b/enterprise/coderd/workspacequota_test.go @@ -31,7 +31,6 @@ func TestWorkspaceQuota(t *testing.T) { q1, err := client.WorkspaceQuota(ctx, codersdk.Me) require.NoError(t, err) require.EqualValues(t, q1.UserWorkspaceLimit, 0) - }) t.Run("Enabled", func(t *testing.T) { t.Parallel() From 3f09989a6392cba13c89704c14dd6bf72251e2df Mon Sep 17 00:00:00 2001 From: Garrett Date: Thu, 29 Sep 2022 18:31:33 +0000 Subject: [PATCH 27/46] pr review --- site/src/components/WorkspaceQuota/WorkspaceQuota.tsx | 2 +- .../CreateWorkspacePage/CreateWorkspacePageView.tsx | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx index a9c796fee246f..e55ebf10c9f0d 100644 --- a/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx +++ b/site/src/components/WorkspaceQuota/WorkspaceQuota.tsx @@ -51,7 +51,7 @@ export const WorkspaceQuota: FC = ({ quota, error }) => { // don't show if limit is 0, this means the feature is disabled. if (quota.user_workspace_limit === 0) { - return <> + return null } let value = Math.round((quota.user_workspace_count / quota.user_workspace_limit) * 100) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 35c018d300d30..583652be1d7ae 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -103,23 +103,20 @@ export const CreateWorkspacePageView: FC ) : ( - <> + null )} {props.createWorkspaceErrors[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR] ? ( ) : ( - <> + null )} ) } - const canSubmit = - props.quota && props.quota.user_workspace_limit > 0 - ? props.quota.user_workspace_count < props.quota.user_workspace_limit - : true + const canSubmit = props.quota && props.quota.user_workspace_limit > 0 && props.quota.user_workspace_count < props.quota.user_workspace_limit return ( @@ -186,7 +183,7 @@ export const CreateWorkspacePageView: FC ) : ( - <> + null )} Date: Thu, 29 Sep 2022 18:32:05 +0000 Subject: [PATCH 28/46] fmt --- .../CreateWorkspacePageView.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 583652be1d7ae..73cccf9f529c3 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -102,21 +102,20 @@ export const CreateWorkspacePageView: FC - ) : ( - null - )} + ) : null} {props.createWorkspaceErrors[CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR] ? ( - ) : ( - null - )} + ) : null} ) } - const canSubmit = props.quota && props.quota.user_workspace_limit > 0 && props.quota.user_workspace_count < props.quota.user_workspace_limit + const canSubmit = + props.quota && + props.quota.user_workspace_limit > 0 && + props.quota.user_workspace_count < props.quota.user_workspace_limit return ( @@ -182,9 +181,7 @@ export const CreateWorkspacePageView: FC - ) : ( - null - )} + ) : null} Date: Thu, 29 Sep 2022 18:46:43 +0000 Subject: [PATCH 29/46] fmt --- .../src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx | 4 ++-- site/src/xServices/createWorkspace/createWorkspaceXService.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 73cccf9f529c3..942f2dece4ebf 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -174,14 +174,14 @@ export const CreateWorkspacePageView: FC )} - {props.quota ? ( + {props.quota && ( - ) : null} + )} Date: Thu, 29 Sep 2022 18:47:54 +0000 Subject: [PATCH 30/46] fix xstate merge --- site/src/xServices/createWorkspace/createWorkspaceXService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 353f93b429ead..18982637ce14a 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -105,7 +105,7 @@ export const createWorkspaceMachine = createMachine( id: "checkPermissions", onDone: { actions: ["assignPermissions"], - target: "fillingParams", + target: "gettingWorkspaceQuota", }, onError: { actions: ["assignCheckPermissionsError"], From bf7f462a54f9984c01bf4313c06fcecd00e82bbc Mon Sep 17 00:00:00 2001 From: Garrett Date: Thu, 29 Sep 2022 19:38:20 +0000 Subject: [PATCH 31/46] support selecting users --- site/src/api/api.ts | 4 ++-- .../CreateWorkspacePage.test.tsx | 3 ++- .../CreateWorkspacePage/CreateWorkspacePage.tsx | 6 ++++++ .../CreateWorkspacePageView.tsx | 6 +++++- site/src/testHelpers/entities.ts | 5 +++++ .../createWorkspace/createWorkspaceXService.ts | 15 +++++++++++++-- 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ff4654c12689a..4232af0c16b2a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -501,7 +501,7 @@ export const getApplicationsHost = async (): Promise => { - const response = await axios.get(`/api/v2/users/me/workspace-quota`) +export const getWorkspaceQuota = async (userID: string): Promise => { + const response = await axios.get(`/api/v2/users/${userID}/workspace-quota`) return response.data } diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index c61ea82b64551..f30834fc62fd6 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -4,7 +4,7 @@ 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, MockWorkspaceRequest, MockWorkspaceQuota } from "testHelpers/entities" import { renderWithAuth } from "testHelpers/renderHelpers" import CreateWorkspacePage from "./CreateWorkspacePage" @@ -28,6 +28,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() diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 8a26a042f6d34..30370eeaa557d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -75,6 +75,12 @@ const CreateWorkspacePage: FC = () => { owner, }) }} + onSelectOwner={(owner) => { + send({ + type: "SELECT_OWNER", + owner: owner, + }) + }} /> ) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 942f2dece4ebf..6d3495d34d708 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -38,6 +38,7 @@ export interface CreateWorkspacePageViewProps { setOwner: (arg0: TypesGen.User | null) => void onCancel: () => void onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void + onSelectOwner: (owner: TypesGen.User | null) => void // initialTouched is only used for testing the error state of the form. initialTouched?: FormikTouched } @@ -150,7 +151,10 @@ export const CreateWorkspacePageView: FC props.setOwner(user)} + onChange={(user) => { + props.setOwner(user) + props.onSelectOwner(user) + }} label={t("ownerLabel")} inputMargin="dense" /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index fa889484de0ea..87c918baaacbc 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -850,3 +850,8 @@ export const MockAuditLog2: TypesGen.AuditLog = { }, }, } + +export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { + user_workspace_count: 0, + user_workspace_limit: 100, +} diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 18982637ce14a..6c3cd196763b6 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -39,6 +39,11 @@ type CreateWorkspaceEvent = { owner: User | null } +type SelectOwnerEvent = { + type: "SELECT_OWNER" + owner: User | null +} + export const createWorkspaceMachine = createMachine( { id: "createWorkspaceState", @@ -46,7 +51,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[] @@ -132,6 +137,10 @@ export const createWorkspaceMachine = createMachine( actions: ["assignCreateWorkspaceRequest", "assignOwner"], target: "creatingWorkspace", }, + SELECT_OWNER: { + actions: ["assignOwner"], + target: "gettingWorkspaceQuota" + }, }, }, creatingWorkspace: { @@ -198,7 +207,9 @@ export const createWorkspaceMachine = createMachine( return createWorkspace(organizationId, owner?.id ?? "me", createWorkspaceRequest) }, - getWorkspaceQuota: () => getWorkspaceQuota(), + getWorkspaceQuota: (context) => { + return getWorkspaceQuota(context.owner?.id ?? "me") + }, }, guards: { areTemplatesEmpty: (_, event) => event.data.length === 0, From d962f1bd9d2b9f8c861156d77bed8d57772534dd Mon Sep 17 00:00:00 2001 From: Garrett Date: Thu, 29 Sep 2022 19:43:58 +0000 Subject: [PATCH 32/46] fmt --- .../CreateWorkspacePage/CreateWorkspacePage.test.tsx | 8 +++++++- .../src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx | 2 +- .../pages/CreateWorkspacePage/CreateWorkspacePageView.tsx | 4 ++-- .../xServices/createWorkspace/createWorkspaceXService.ts | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx index f30834fc62fd6..883b0cf0e1b7b 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.test.tsx @@ -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, MockWorkspaceQuota } from "testHelpers/entities" +import { + MockTemplate, + MockUser, + MockWorkspace, + MockWorkspaceQuota, + MockWorkspaceRequest, +} from "testHelpers/entities" import { renderWithAuth } from "testHelpers/renderHelpers" import CreateWorkspacePage from "./CreateWorkspacePage" diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 30370eeaa557d..9a10a07742f41 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -63,7 +63,7 @@ const CreateWorkspacePage: FC = () => { [CreateWorkspaceErrors.GET_WORKSPACE_QUOTA_ERROR]: getWorkspaceQuotaError, }} canCreateForUser={permissions?.createWorkspaceForUser} - defaultWorkspaceOwner={me ?? null} + owner={owner} setOwner={setOwner} onCancel={() => { navigate("/templates") diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 6d3495d34d708..116ed74d7b8d0 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -34,7 +34,7 @@ export interface CreateWorkspacePageViewProps { quota?: TypesGen.WorkspaceQuota createWorkspaceErrors: Partial> canCreateForUser?: boolean - defaultWorkspaceOwner: TypesGen.User | null + owner: TypesGen.User | null setOwner: (arg0: TypesGen.User | null) => void onCancel: () => void onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void @@ -150,7 +150,7 @@ export const CreateWorkspacePageView: FC { props.setOwner(user) props.onSelectOwner(user) diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 6c3cd196763b6..139686b36e0db 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -139,7 +139,7 @@ export const createWorkspaceMachine = createMachine( }, SELECT_OWNER: { actions: ["assignOwner"], - target: "gettingWorkspaceQuota" + target: "gettingWorkspaceQuota", }, }, }, From 0145ee8d20158b1dca66d17e7686e983fcb07d6a Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 16:00:25 +0000 Subject: [PATCH 33/46] only fetch if enabled --- .../CreateWorkspacePage/CreateWorkspacePage.tsx | 17 +++++++++++++---- .../CreateWorkspacePageView.tsx | 12 ++++++------ .../createWorkspace/createWorkspaceXService.ts | 5 +++++ 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 9a10a07742f41..192edf8fd1ced 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -1,4 +1,5 @@ -import { useActor, useMachine } from "@xstate/react" +import { useActor, useMachine, useSelector, shallowEqual } from "@xstate/react" +import { FeatureNames } from "api/types" import { User } from "api/typesGenerated" import { useOrganizationId } from "hooks/useOrganizationId" import { FC, useContext, useState } from "react" @@ -6,16 +7,24 @@ 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 [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { - context: { organizationId, templateName }, + context: { organizationId, templateName, workspaceQuotaEnabled }, actions: { onCreateWorkspace: (_, event) => { navigate(`/@${event.data.owner_name}/${event.data.name}`) @@ -23,6 +32,7 @@ const CreateWorkspacePage: FC = () => { }, }) + const { templates, templateSchema, @@ -35,7 +45,6 @@ const CreateWorkspacePage: FC = () => { getWorkspaceQuotaError, } = createWorkspaceState.context - const xServices = useContext(XServiceContext) const [authState] = useActor(xServices.authXService) const { me } = authState.context @@ -55,7 +64,7 @@ const CreateWorkspacePage: FC = () => { templates={templates} selectedTemplate={selectedTemplate} templateSchema={templateSchema} - quota={workspaceQuota} + workspaceQuota={workspaceQuota} createWorkspaceErrors={{ [CreateWorkspaceErrors.GET_TEMPLATES_ERROR]: getTemplatesError, [CreateWorkspaceErrors.GET_TEMPLATE_SCHEMA_ERROR]: getTemplateSchemaError, diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 116ed74d7b8d0..cef604a1ad279 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -31,7 +31,7 @@ export interface CreateWorkspacePageViewProps { templates?: TypesGen.Template[] selectedTemplate?: TypesGen.Template templateSchema?: TypesGen.ParameterSchema[] - quota?: TypesGen.WorkspaceQuota + workspaceQuota?: TypesGen.WorkspaceQuota createWorkspaceErrors: Partial> canCreateForUser?: boolean owner: TypesGen.User | null @@ -114,9 +114,9 @@ export const CreateWorkspacePageView: FC 0 && - props.quota.user_workspace_count < props.quota.user_workspace_limit + props.workspaceQuota && + props.workspaceQuota.user_workspace_limit > 0 && + props.workspaceQuota.user_workspace_count < props.workspaceQuota.user_workspace_limit return ( @@ -178,9 +178,9 @@ export const CreateWorkspacePageView: FC )} - {props.quota && ( + {props.workspaceQuota && ( { + if (!context.workspaceQuotaEnabled) { + return Promise.resolve(undefined) + } + return getWorkspaceQuota(context.owner?.id ?? "me") }, }, From 308a9e5c1d4a05ac2e28b6053c90b0431a1ee710 Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 16:03:11 +0000 Subject: [PATCH 34/46] fmt --- site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 192edf8fd1ced..339b9156008bb 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -1,4 +1,4 @@ -import { useActor, useMachine, useSelector, shallowEqual } from "@xstate/react" +import { shallowEqual, useActor, useMachine, useSelector } from "@xstate/react" import { FeatureNames } from "api/types" import { User } from "api/typesGenerated" import { useOrganizationId } from "hooks/useOrganizationId" @@ -32,7 +32,6 @@ const CreateWorkspacePage: FC = () => { }, }) - const { templates, templateSchema, From cd2c26377a7ecf56374ce61d0f9b7f8d3aa1abf5 Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 16:05:13 +0000 Subject: [PATCH 35/46] fix promise --- site/src/xServices/createWorkspace/createWorkspaceXService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 0c337216adb5a..0d62437984295 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -210,7 +210,7 @@ export const createWorkspaceMachine = createMachine( }, getWorkspaceQuota: (context) => { if (!context.workspaceQuotaEnabled) { - return Promise.resolve(undefined) + return Promise.resolve({} as WorkspaceQuota) } return getWorkspaceQuota(context.owner?.id ?? "me") From aad4f4109bf0ef78b9335a7ea44abb2c52fd5e5c Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 16:17:01 +0000 Subject: [PATCH 36/46] fix return again --- .../xServices/createWorkspace/createWorkspaceXService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 0d62437984295..c1176c55f09ec 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -210,7 +210,11 @@ export const createWorkspaceMachine = createMachine( }, getWorkspaceQuota: (context) => { if (!context.workspaceQuotaEnabled) { - return Promise.resolve({} as WorkspaceQuota) + // 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") From e2ae55e590d941695ec78e3a20b669eb5bce697f Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 16:27:19 +0000 Subject: [PATCH 37/46] move quota route to ent --- coderd/coderd.go | 1 - coderd/workspacequota.go | 37 ----------------------------- enterprise/coderd/coderd.go | 7 ++++++ enterprise/coderd/workspacequota.go | 37 ++++++++++++++++++++++++++++- 4 files changed, 43 insertions(+), 39 deletions(-) delete mode 100644 coderd/workspacequota.go diff --git a/coderd/coderd.go b/coderd/coderd.go index ae6628fa2b0b3..6d638324710e4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -412,7 +412,6 @@ func New(options *Options) *API { }) r.Get("/gitsshkey", api.gitSSHKey) r.Put("/gitsshkey", api.regenerateGitSSHKey) - r.Get("/workspace-quota", api.workspaceQuota) }) }) }) diff --git a/coderd/workspacequota.go b/coderd/workspacequota.go deleted file mode 100644 index 21129f32933b7..0000000000000 --- a/coderd/workspacequota.go +++ /dev/null @@ -1,37 +0,0 @@ -package coderd - -import ( - "net/http" - - "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/httpapi" - "github.com/coder/coder/coderd/httpmw" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" -) - -func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) { - user := httpmw.UserParam(r) - - if !api.Authorize(r, rbac.ActionRead, rbac.ResourceUser) { - httpapi.ResourceNotFound(rw) - return - } - - workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{ - OwnerID: user.ID, - }) - if err != nil { - httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspaces.", - Detail: err.Error(), - }) - return - } - - e := *api.WorkspaceQuotaEnforcer.Load() - httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceQuota{ - UserWorkspaceCount: len(workspaces), - UserWorkspaceLimit: e.UserWorkspaceLimit(), - }) -} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 205963d94f06c..4d5b17d00a3bd 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -67,6 +67,13 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Get("/", api.licenses) r.Delete("/{id}", api.deleteLicense) }) + r.Route("/users", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Route("/{user}", func(r chi.Router) { + r.Use(httpmw.ExtractUserParam(options.Database)) + r.Get("/workspace-quota", api.workspaceQuota) + }) + }) }) if len(options.SCIMAPIKey) != 0 { diff --git a/enterprise/coderd/workspacequota.go b/enterprise/coderd/workspacequota.go index dd0952c9e9901..ab345d3681f87 100644 --- a/enterprise/coderd/workspacequota.go +++ b/enterprise/coderd/workspacequota.go @@ -1,6 +1,15 @@ package coderd -import "github.com/coder/coder/coderd/workspacequota" +import ( + "net/http" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/coderd/workspacequota" + "github.com/coder/coder/codersdk" +) type enforcer struct { userWorkspaceLimit int @@ -23,3 +32,29 @@ func (e *enforcer) CanCreateWorkspace(count int) bool { return count < e.userWorkspaceLimit } + +func (api *API) workspaceQuota(rw http.ResponseWriter, r *http.Request) { + user := httpmw.UserParam(r) + + if !api.AGPL.Authorize(r, rbac.ActionRead, rbac.ResourceUser) { + httpapi.ResourceNotFound(rw) + return + } + + workspaces, err := api.Database.GetWorkspaces(r.Context(), database.GetWorkspacesParams{ + OwnerID: user.ID, + }) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspaces.", + Detail: err.Error(), + }) + return + } + + e := *api.AGPL.WorkspaceQuotaEnforcer.Load() + httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.WorkspaceQuota{ + UserWorkspaceCount: len(workspaces), + UserWorkspaceLimit: e.UserWorkspaceLimit(), + }) +} From 1c2d7d64309d581359e12f8e1a9def0c9ae9bf31 Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 16:50:14 +0000 Subject: [PATCH 38/46] fix ent route --- codersdk/workspacequota.go | 2 +- enterprise/coderd/coderd.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/codersdk/workspacequota.go b/codersdk/workspacequota.go index e96596a2f66e1..823e843e0e6e6 100644 --- a/codersdk/workspacequota.go +++ b/codersdk/workspacequota.go @@ -13,7 +13,7 @@ type WorkspaceQuota struct { } func (c *Client) WorkspaceQuota(ctx context.Context, userID string) (WorkspaceQuota, error) { - res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspace-quota", userID), nil) + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspace-quota/%s", userID), nil) if err != nil { return WorkspaceQuota{}, err } diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 4d5b17d00a3bd..f251f08d7f0d4 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -67,11 +67,11 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Get("/", api.licenses) r.Delete("/{id}", api.deleteLicense) }) - r.Route("/users", func(r chi.Router) { + r.Route("/workspace-quota", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Route("/{user}", func(r chi.Router) { r.Use(httpmw.ExtractUserParam(options.Database)) - r.Get("/workspace-quota", api.workspaceQuota) + r.Get("/", api.workspaceQuota) }) }) }) From 94f6abc2ddd6d85e47279cb709fa55946c350abe Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 16:51:59 +0000 Subject: [PATCH 39/46] fix ent route in site --- site/src/api/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4232af0c16b2a..5aaec5bda4458 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -502,6 +502,6 @@ export const getApplicationsHost = async (): Promise => { - const response = await axios.get(`/api/v2/users/${userID}/workspace-quota`) + const response = await axios.get(`/api/v2/workspace-quota/${userID}`) return response.data } From aa3fd9ca8e6fef5d4dcd9233ba785d3d1133f73b Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 17:06:34 +0000 Subject: [PATCH 40/46] replace loop with count query --- coderd/database/databasefake/databasefake.go | 16 +++++++++ coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 18 ++++++++++ coderd/database/queries/workspaces.sql | 10 ++++++ coderd/workspaces.go | 37 +++++++++++--------- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 839633f7f815e..f8598b8a6aadc 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -698,6 +698,22 @@ func (q *fakeQuerier) GetWorkspaceBuildByID(_ context.Context, id uuid.UUID) (da return database.WorkspaceBuild{}, sql.ErrNoRows } +func (q *fakeQuerier) GetWorkspaceCountByUserID(_ context.Context, id uuid.UUID) (int64, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + var count int64 + for _, workspace := range q.workspaces { + if workspace.OwnerID.String() == id.String() { + if workspace.Deleted { + continue + } + + count++ + } + } + return count, sql.ErrNoRows +} + func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUID) (database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 110c5d4410efe..56c5f677115d3 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -92,6 +92,7 @@ type querier interface { GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) + GetWorkspaceCountByUserID(ctx context.Context, ownerID uuid.UUID) (int64, error) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) GetWorkspaceResourceMetadataByResourceID(ctx context.Context, workspaceResourceID uuid.UUID) ([]WorkspaceResourceMetadatum, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 669a9d2e07d84..3f846e7bebf69 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4971,6 +4971,24 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo return i, err } +const getWorkspaceCountByUserID = `-- name: GetWorkspaceCountByUserID :one +SELECT + COUNT(id) +FROM + workspaces +WHERE + owner_id = $1 + -- Ignore deleted workspaces + AND deleted != true +` + +func (q *sqlQuerier) GetWorkspaceCountByUserID(ctx context.Context, ownerID uuid.UUID) (int64, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceCountByUserID, ownerID) + var count int64 + err := row.Scan(&count) + return count, err +} + const getWorkspaceOwnerCountsByTemplateIDs = `-- name: GetWorkspaceOwnerCountsByTemplateIDs :many SELECT template_id, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index f2c1723f84ba4..8e9e0b60902eb 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -74,6 +74,16 @@ WHERE GROUP BY template_id; +-- name: GetWorkspaceCountByUserID :one +SELECT + COUNT(id) +FROM + workspaces +WHERE + owner_id = @owner_id + -- Ignore deleted workspaces + AND deleted != true; + -- name: InsertWorkspace :one INSERT INTO workspaces ( diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 38b8c76bbd87a..7a1c56355561c 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -294,33 +294,39 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - workspaces, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{ + workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{ OwnerID: user.ID, + Name: createWorkspace.Name, }) + if err == nil { + // If the workspace already exists, don't allow creation. + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("Workspace %q already exists.", createWorkspace.Name), + Validations: []codersdk.ValidationError{{ + Field: "name", + Detail: "This value is already in use and should be unique.", + }}, + }) + return + } if err != nil && !errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: fmt.Sprintf("Internal error fetching workspace by name %q.", createWorkspace.Name), Detail: err.Error(), }) - return } - for _, workspace := range workspaces { - if workspace.Name == createWorkspace.Name { - // If the workspace already exists, don't allow creation. - httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ - Message: fmt.Sprintf("Workspace %q already exists.", createWorkspace.Name), - Validations: []codersdk.ValidationError{{ - Field: "name", - Detail: "This value is already in use and should be unique.", - }}, - }) - return - } + + workspaceCount, err := api.Database.GetWorkspaceCountByUserID(ctx, user.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: fmt.Sprintf("Internal error fetching workspace count."), + Detail: err.Error(), + }) } // make sure the user has not hit their quota limit e := *api.WorkspaceQuotaEnforcer.Load() - canCreate := e.CanCreateWorkspace(len(workspaces)) + canCreate := e.CanCreateWorkspace(int(workspaceCount)) if !canCreate { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("User workspace limit of %d is already reached.", e.UserWorkspaceLimit()), @@ -364,7 +370,6 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req } var ( - workspace database.Workspace provisionerJob database.ProvisionerJob workspaceBuild database.WorkspaceBuild ) From 725b3ee7e7749f51cd4227882b34cfe3466fc5b9 Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 17:06:58 +0000 Subject: [PATCH 41/46] missing returns --- coderd/workspaces.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 7a1c56355561c..567a7f833d626 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -314,6 +314,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req Message: fmt.Sprintf("Internal error fetching workspace by name %q.", createWorkspace.Name), Detail: err.Error(), }) + return } workspaceCount, err := api.Database.GetWorkspaceCountByUserID(ctx, user.ID) @@ -322,6 +323,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req Message: fmt.Sprintf("Internal error fetching workspace count."), Detail: err.Error(), }) + return } // make sure the user has not hit their quota limit From d679ef5e51a61e4ca30b7208111df7d33573a2b8 Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 17:07:40 +0000 Subject: [PATCH 42/46] lint --- coderd/workspaces.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 567a7f833d626..aa032a947d645 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -320,7 +320,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req workspaceCount, err := api.Database.GetWorkspaceCountByUserID(ctx, user.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("Internal error fetching workspace count."), + Message: "Internal error fetching workspace count.", Detail: err.Error(), }) return From ca672f8f8457c4a64f2fa86ca4cdcbe28131715f Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 17:33:59 +0000 Subject: [PATCH 43/46] fix page view --- .../CreateWorkspacePage.tsx | 27 +++++++++---------- .../CreateWorkspacePageView.tsx | 6 +---- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 339b9156008bb..3e343c294ec88 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -1,8 +1,7 @@ import { shallowEqual, useActor, useMachine, useSelector } from "@xstate/react" import { FeatureNames } from "api/types" -import { User } from "api/typesGenerated" 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" @@ -23,8 +22,11 @@ const CreateWorkspacePage: FC = () => { shallowEqual, ) const workspaceQuotaEnabled = featureVisibility[FeatureNames.WorkspaceQuota] + + const [authState] = useActor(xServices.authXService) + const { me } = authState.context const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { - context: { organizationId, templateName, workspaceQuotaEnabled }, + context: { organizationId, templateName, workspaceQuotaEnabled, owner: (me ?? null) }, actions: { onCreateWorkspace: (_, event) => { navigate(`/@${event.data.owner_name}/${event.data.name}`) @@ -42,13 +44,9 @@ const CreateWorkspacePage: FC = () => { permissions, workspaceQuota, getWorkspaceQuotaError, + owner, } = createWorkspaceState.context - const [authState] = useActor(xServices.authXService) - const { me } = authState.context - - const [owner, setOwner] = useState(me ?? null) - return ( <> @@ -72,7 +70,12 @@ const CreateWorkspacePage: FC = () => { }} canCreateForUser={permissions?.createWorkspaceForUser} owner={owner} - setOwner={setOwner} + setOwner={(user) => { + send({ + type: "SELECT_OWNER", + owner: user, + }) + }} onCancel={() => { navigate("/templates") }} @@ -83,12 +86,6 @@ const CreateWorkspacePage: FC = () => { owner, }) }} - onSelectOwner={(owner) => { - send({ - type: "SELECT_OWNER", - owner: owner, - }) - }} /> ) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index cef604a1ad279..468543bef9e44 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -38,7 +38,6 @@ export interface CreateWorkspacePageViewProps { setOwner: (arg0: TypesGen.User | null) => void onCancel: () => void onSubmit: (req: TypesGen.CreateWorkspaceRequest) => void - onSelectOwner: (owner: TypesGen.User | null) => void // initialTouched is only used for testing the error state of the form. initialTouched?: FormikTouched } @@ -151,10 +150,7 @@ export const CreateWorkspacePageView: FC { - props.setOwner(user) - props.onSelectOwner(user) - }} + onChange={props.setOwner} label={t("ownerLabel")} inputMargin="dense" /> From 01239c177a59a87e902251b80cd536f7f93025a3 Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 17:38:15 +0000 Subject: [PATCH 44/46] fix db mock --- coderd/database/databasefake/databasefake.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index f8598b8a6aadc..ca8c49f36eb8c 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -711,7 +711,7 @@ func (q *fakeQuerier) GetWorkspaceCountByUserID(_ context.Context, id uuid.UUID) count++ } } - return count, sql.ErrNoRows + return count, nil } func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUID) (database.WorkspaceBuild, error) { From 112926465a503b0f0e53c8b9b460c26788cf0cbd Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 17:40:19 +0000 Subject: [PATCH 45/46] fmt --- site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 3e343c294ec88..b863821525cae 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -26,7 +26,7 @@ const CreateWorkspacePage: FC = () => { const [authState] = useActor(xServices.authXService) const { me } = authState.context const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { - context: { organizationId, templateName, workspaceQuotaEnabled, owner: (me ?? null) }, + context: { organizationId, templateName, workspaceQuotaEnabled, owner: me ?? null }, actions: { onCreateWorkspace: (_, event) => { navigate(`/@${event.data.owner_name}/${event.data.name}`) From 706fabdcd8f73312c910a12aac6014a054dd46dc Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 30 Sep 2022 17:54:35 +0000 Subject: [PATCH 46/46] fix logic --- .../pages/CreateWorkspacePage/CreateWorkspacePageView.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 468543bef9e44..fbafa7bc03b09 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -113,9 +113,9 @@ export const CreateWorkspacePageView: FC 0 && - props.workspaceQuota.user_workspace_count < props.workspaceQuota.user_workspace_limit + props.workspaceQuota && props.workspaceQuota.user_workspace_limit > 0 + ? props.workspaceQuota.user_workspace_count < props.workspaceQuota.user_workspace_limit + : true return (