diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 20a7da094d14e..3a42b67c75f8e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -291,14 +291,9 @@ jobs: gotestsum --junitfile="gotests.xml" --jsonfile="gotests.json" \ --packages="./..." -- $PARALLEL_FLAG -short -failfast $COVERAGE_FLAGS - - name: Print test stats - if: success() || failure() - run: | - # Artifacts are not available after rerunning a job, - # so we need to print the test stats to the log. - go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json - - name: Upload test stats to Datadog + timeout-minutes: 1 + continue-on-error: true uses: ./.github/actions/upload-datadog if: success() || failure() with: @@ -343,14 +338,9 @@ jobs: export TS_DEBUG_DISCO=true make test-postgres - - name: Print test stats - if: success() || failure() - run: | - # Artifacts are not available after rerunning a job, - # so we need to print the test stats to the log. - go run ./scripts/ci-report/main.go gotests.json | tee gotests_stats.json - - name: Upload test stats to Datadog + timeout-minutes: 1 + continue-on-error: true uses: ./.github/actions/upload-datadog if: success() || failure() with: @@ -391,6 +381,8 @@ jobs: gotestsum --junitfile="gotests.xml" -- -race ./... - name: Upload test stats to Datadog + timeout-minutes: 1 + continue-on-error: true uses: ./.github/actions/upload-datadog if: always() with: diff --git a/cli/restart.go b/cli/restart.go index a936c30594878..e5182ff481d1c 100644 --- a/cli/restart.go +++ b/cli/restart.go @@ -2,15 +2,15 @@ package cli import ( "fmt" + "net/http" "time" "golang.org/x/xerrors" - "github.com/coder/pretty" - "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func (r *RootCmd) restart() *clibase.Cmd { @@ -40,19 +40,14 @@ func (r *RootCmd) restart() *clibase.Cmd { return err } - template, err := client.Template(inv.Context(), workspace.TemplateID) - if err != nil { - return err - } - buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) if err != nil { return xerrors.Errorf("can't parse build options: %w", err) } buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ - Action: WorkspaceRestart, - Template: template, + Action: WorkspaceRestart, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, LastBuildParameters: lastBuildParameters, @@ -82,13 +77,29 @@ func (r *RootCmd) restart() *clibase.Cmd { return err } - build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + req := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, RichParameterValues: buildParameters, - }) - if err != nil { + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + } + + build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, req) + // It's possible for a workspace build to fail due to the template requiring starting + // workspaces with the active version. + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusUnauthorized { + build, err = startWorkspaceActiveVersion(inv, client, startWorkspaceActiveVersionArgs{ + BuildOptions: buildOptions, + LastBuildParameters: lastBuildParameters, + PromptBuildOptions: parameterFlags.promptBuildOptions, + Workspace: workspace, + }) + if err != nil { + return xerrors.Errorf("start workspace with active template version: %w", err) + } + } else if err != nil { return err } + err = cliui.WorkspaceBuild(ctx, out, client, build.ID) if err != nil { return err diff --git a/cli/server.go b/cli/server.go index 9f33ced438f84..be855419a6052 100644 --- a/cli/server.go +++ b/cli/server.go @@ -41,6 +41,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/trace" "golang.org/x/mod/semver" "golang.org/x/oauth2" @@ -78,6 +80,7 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/oauthpki" "github.com/coder/coder/v2/coderd/prometheusmetrics" + "github.com/coder/coder/v2/coderd/prometheusmetrics/insights" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/coderd/tracing" @@ -198,6 +201,21 @@ func enablePrometheus( } afterCtx(ctx, closeWorkspacesFunc) + insightsMetricsCollector, err := insights.NewMetricsCollector(options.Database, options.Logger, 0, 0) + if err != nil { + return nil, xerrors.Errorf("unable to initialize insights metrics collector: %w", err) + } + err = options.PrometheusRegistry.Register(insightsMetricsCollector) + if err != nil { + return nil, xerrors.Errorf("unable to register insights metrics collector: %w", err) + } + + closeInsightsMetricsCollector, err := insightsMetricsCollector.Run(ctx) + if err != nil { + return nil, xerrors.Errorf("unable to run insights metrics collector: %w", err) + } + afterCtx(ctx, closeInsightsMetricsCollector) + if vals.Prometheus.CollectAgentStats { closeAgentStatsFunc, err := prometheusmetrics.AgentStats(ctx, logger, options.PrometheusRegistry, options.Database, time.Now(), 0) if err != nil { @@ -764,6 +782,8 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("create telemetry reporter: %w", err) } defer options.Telemetry.Close() + } else { + logger.Warn(ctx, `telemetry disabled, unable to notify of security issues. Read more: https://coder.com/docs/v2/latest/admin/telemetry`) } // This prevents the pprof import from being accidentally deleted. @@ -938,7 +958,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. autobuildTicker := time.NewTicker(vals.AutobuildPollInterval.Value()) defer autobuildTicker.Stop() autobuildExecutor := autobuild.NewExecutor( - ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, logger, autobuildTicker.C) + ctx, options.Database, options.Pubsub, coderAPI.TemplateScheduleStore, &coderAPI.Auditor, coderAPI.AccessControlStore, logger, autobuildTicker.C) autobuildExecutor.Run() hangDetectorTicker := time.NewTicker(vals.JobHangDetectorInterval.Value()) @@ -2020,6 +2040,13 @@ func ConfigureTraceProvider( sqlDriver = "postgres" ) + otel.SetTextMapPropagator( + propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), + ) + if cfg.Trace.Enable.Value() || cfg.Trace.DataDog.Value() || cfg.Trace.HoneycombAPIKey != "" { sdkTracerProvider, _closeTracing, err := tracing.TracerProvider(ctx, "coderd", tracing.TracerOpts{ Default: cfg.Trace.Enable.Value(), diff --git a/cli/start.go b/cli/start.go index 32f14985c7991..b74426570e75f 100644 --- a/cli/start.go +++ b/cli/start.go @@ -2,8 +2,10 @@ package cli import ( "fmt" + "net/http" "time" + "github.com/google/uuid" "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/clibase" @@ -35,19 +37,14 @@ func (r *RootCmd) start() *clibase.Cmd { return err } - template, err := client.Template(inv.Context(), workspace.TemplateID) - if err != nil { - return err - } - buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions) if err != nil { return xerrors.Errorf("unable to parse build options: %w", err) } buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ - Action: WorkspaceStart, - Template: template, + Action: WorkspaceStart, + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, LastBuildParameters: lastBuildParameters, @@ -58,11 +55,26 @@ func (r *RootCmd) start() *clibase.Cmd { return err } - build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + req := codersdk.CreateWorkspaceBuildRequest{ Transition: codersdk.WorkspaceTransitionStart, RichParameterValues: buildParameters, - }) - if err != nil { + TemplateVersionID: workspace.LatestBuild.TemplateVersionID, + } + + build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req) + // It's possible for a workspace build to fail due to the template requiring starting + // workspaces with the active version. + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusUnauthorized { + build, err = startWorkspaceActiveVersion(inv, client, startWorkspaceActiveVersionArgs{ + BuildOptions: buildOptions, + LastBuildParameters: lastBuildParameters, + PromptBuildOptions: parameterFlags.promptBuildOptions, + Workspace: workspace, + }) + if err != nil { + return xerrors.Errorf("start workspace with active template version: %w", err) + } + } else if err != nil { return err } @@ -82,8 +94,8 @@ func (r *RootCmd) start() *clibase.Cmd { } type prepStartWorkspaceArgs struct { - Action WorkspaceCLIAction - Template codersdk.Template + Action WorkspaceCLIAction + TemplateVersionID uuid.UUID LastBuildParameters []codersdk.WorkspaceBuildParameter @@ -94,7 +106,7 @@ type prepStartWorkspaceArgs struct { func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args prepStartWorkspaceArgs) ([]codersdk.WorkspaceBuildParameter, error) { ctx := inv.Context() - templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID) + templateVersion, err := client.TemplateVersion(ctx, args.TemplateVersionID) if err != nil { return nil, xerrors.Errorf("get template version: %w", err) } @@ -110,3 +122,43 @@ func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args p WithBuildOptions(args.BuildOptions) return resolver.Resolve(inv, args.Action, templateVersionParameters) } + +type startWorkspaceActiveVersionArgs struct { + BuildOptions []codersdk.WorkspaceBuildParameter + LastBuildParameters []codersdk.WorkspaceBuildParameter + PromptBuildOptions bool + Workspace codersdk.Workspace +} + +func startWorkspaceActiveVersion(inv *clibase.Invocation, client *codersdk.Client, args startWorkspaceActiveVersionArgs) (codersdk.WorkspaceBuild, error) { + _, _ = fmt.Fprintln(inv.Stdout, "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.") + + template, err := client.Template(inv.Context(), args.Workspace.TemplateID) + if err != nil { + return codersdk.WorkspaceBuild{}, xerrors.Errorf("get template: %w", err) + } + + buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{ + Action: WorkspaceStart, + TemplateVersionID: template.ActiveVersionID, + + LastBuildParameters: args.LastBuildParameters, + + PromptBuildOptions: args.PromptBuildOptions, + BuildOptions: args.BuildOptions, + }) + if err != nil { + return codersdk.WorkspaceBuild{}, err + } + + build, err := client.CreateWorkspaceBuild(inv.Context(), args.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + RichParameterValues: buildParameters, + TemplateVersionID: template.ActiveVersionID, + }) + if err != nil { + return codersdk.WorkspaceBuild{}, err + } + + return build, nil +} diff --git a/cli/templatecreate.go b/cli/templatecreate.go index b2e9a45cc8be8..da0793121d949 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -24,11 +24,12 @@ import ( func (r *RootCmd) templateCreate() *clibase.Cmd { var ( - provisioner string - provisionerTags []string - variablesFile string - variables []string - disableEveryone bool + provisioner string + provisionerTags []string + variablesFile string + variables []string + disableEveryone bool + requireActiveVersion bool defaultTTL time.Duration failureTTL time.Duration @@ -46,17 +47,47 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { r.InitClient(client), ), Handler: func(inv *clibase.Invocation) error { - if failureTTL != 0 || inactivityTTL != 0 || maxTTL != 0 { + isTemplateSchedulingOptionsSet := failureTTL != 0 || inactivityTTL != 0 || maxTTL != 0 + + if isTemplateSchedulingOptionsSet || requireActiveVersion { + if failureTTL != 0 || inactivityTTL != 0 { + // This call can be removed when workspace_actions is no longer experimental + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentWorkspaceActions) { + return xerrors.Errorf("--failure-ttl and --inactivity-ttl are experimental features. Use the workspace_actions CODER_EXPERIMENTS flag to set these configuration values.") + } + } + entitlements, err := client.Entitlements(inv.Context()) - var sdkErr *codersdk.Error - if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --failure-ttl or --inactivityTTL") + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusNotFound { + return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") } else if err != nil { return xerrors.Errorf("get entitlements: %w", err) } - if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { - return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --failure-ttl or --inactivityTTL") + if isTemplateSchedulingOptionsSet { + if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { + return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --failure-ttl, --inactivity-ttl, or --max-ttl") + } + } + + if requireActiveVersion { + if !entitlements.Features[codersdk.FeatureAccessControl].Enabled { + return xerrors.Errorf("your license is not entitled to use enterprise access control, so you cannot set --require-active-version") + } + + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentTemplateUpdatePolicies) { + return xerrors.Errorf("--require-active-version is an experimental feature, contact an administrator to enable the 'template_update_policies' experiment on your Coder server") + } } } @@ -129,6 +160,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), DisableEveryoneGroupAccess: disableEveryone, + RequireActiveVersion: requireActiveVersion, } _, err = client.CreateTemplate(inv.Context(), organization.ID, createReq) @@ -205,6 +237,13 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { Value: clibase.StringOf(&provisioner), Hidden: true, }, + { + Flag: "require-active-version", + Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature.", + Value: clibase.BoolOf(&requireActiveVersion), + Default: "false", + }, + cliui.SkipPromptOption(), } cmd.Options = append(cmd.Options, uploadFlags.options()...) diff --git a/cli/templatecreate_test.go b/cli/templatecreate_test.go index ba5dad7b4ac6a..ec1720ba2a6a4 100644 --- a/cli/templatecreate_test.go +++ b/cli/templatecreate_test.go @@ -13,6 +13,7 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/pty/ptytest" @@ -393,6 +394,36 @@ func TestTemplateCreate(t *testing.T) { } } }) + + t.Run("RequireActiveVersionInvalid", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{ + string(codersdk.ExperimentTemplateUpdatePolicies), + } + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + DeploymentValues: dv, + }) + coderdtest.CreateFirstUser(t, client) + source := clitest.CreateTemplateVersionSource(t, completeWithAgent()) + args := []string{ + "templates", + "create", + "my-template", + "--directory", source, + "--test.provisioner", string(database.ProvisionerTypeEcho), + "--require-active-version", + } + inv, root := clitest.New(t, args...) + clitest.SetupConfig(t, client, root) + + err := inv.Run() + require.Error(t, err) + require.Contains(t, err.Error(), "your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") + }) } // Need this for Windows because of a known issue with Go: diff --git a/cli/templateedit.go b/cli/templateedit.go index ba079f99f7dd0..1c17ec52bcab3 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -31,6 +31,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { allowUserCancelWorkspaceJobs bool allowUserAutostart bool allowUserAutostop bool + requireActiveVersion bool ) client := new(codersdk.Client) @@ -42,8 +43,20 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { ), Short: "Edit the metadata of a template by name.", Handler: func(inv *clibase.Invocation) error { + // This clause can be removed when workspace_actions is no longer experimental + if failureTTL != 0 || inactivityTTL != 0 { + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentWorkspaceActions) { + return xerrors.Errorf("--failure-ttl and --inactivity-ttl are experimental features. Use the workspace_actions CODER_EXPERIMENTS flag to set these configuration values.") + } + } + unsetAutostopRequirementDaysOfWeek := len(autostopRequirementDaysOfWeek) == 1 && autostopRequirementDaysOfWeek[0] == "none" - requiresEntitlement := (len(autostopRequirementDaysOfWeek) > 0 && !unsetAutostopRequirementDaysOfWeek) || + requiresScheduling := (len(autostopRequirementDaysOfWeek) > 0 && !unsetAutostopRequirementDaysOfWeek) || autostopRequirementWeeks > 0 || !allowUserAutostart || !allowUserAutostop || @@ -52,18 +65,33 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { inactivityTTL != 0 || len(autostartRequirementDaysOfWeek) > 0 + requiresEntitlement := requiresScheduling || requireActiveVersion if requiresEntitlement { entitlements, err := client.Entitlements(inv.Context()) - var sdkErr *codersdk.Error - if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { - return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set --max-ttl, --failure-ttl, --inactivityTTL, --allow-user-autostart=false or --allow-user-autostop=false") + if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusNotFound { + return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") } else if err != nil { return xerrors.Errorf("get entitlements: %w", err) } - if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { + if requiresScheduling && !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --max-ttl, --failure-ttl, --inactivityTTL, --allow-user-autostart=false or --allow-user-autostop=false") } + + if requireActiveVersion { + if !entitlements.Features[codersdk.FeatureAccessControl].Enabled { + return xerrors.Errorf("your license is not entitled to use enterprise access control, so you cannot set --require-active-version") + } + + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentTemplateUpdatePolicies) { + return xerrors.Errorf("--require-active-version is an experimental feature, contact an administrator to enable the 'template_update_policies' experiment on your Coder server") + } + } } organization, err := CurrentOrganization(inv, client) @@ -110,6 +138,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, AllowUserAutostart: allowUserAutostart, AllowUserAutostop: allowUserAutostop, + RequireActiveVersion: requireActiveVersion, } _, err = client.UpdateTemplateMeta(inv.Context(), template.ID, req) @@ -222,6 +251,12 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Default: "true", Value: clibase.BoolOf(&allowUserAutostop), }, + { + Flag: "require-active-version", + Description: "Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature.", + Value: clibase.BoolOf(&requireActiveVersion), + Default: "false", + }, cliui.SkipPromptOption(), } diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index cf286adacf427..de2e52894a444 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -1021,4 +1021,30 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.TimeTilDormantMillis, updated.TimeTilDormantMillis) }) }) + + t.Run("RequireActiveVersion", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + owner := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {}) + + // Test the cli command with --allow-user-autostart. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--require-active-version", + } + inv, root := clitest.New(t, cmdArgs...) + //nolint + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + require.Error(t, err) + require.ErrorContains(t, err, "appears to be an AGPL deployment") + }) } diff --git a/cli/templates.go b/cli/templates.go index 391ac7700870a..4f5b4f8f36d0b 100644 --- a/cli/templates.go +++ b/cli/templates.go @@ -6,11 +6,10 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/pretty" - "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func (r *RootCmd) templates() *clibase.Cmd { diff --git a/cli/templateversionarchive.go b/cli/templateversionarchive.go index d334bdb83fb4b..63c9d8a3de212 100644 --- a/cli/templateversionarchive.go +++ b/cli/templateversionarchive.go @@ -8,11 +8,10 @@ import ( "golang.org/x/xerrors" - "github.com/coder/pretty" - "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func (r *RootCmd) unarchiveTemplateVersion() *clibase.Cmd { diff --git a/cli/templateversions.go b/cli/templateversions.go index 4ccba09b63ba8..a27d6a6d65af3 100644 --- a/cli/templateversions.go +++ b/cli/templateversions.go @@ -8,11 +8,10 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "github.com/coder/pretty" - "github.com/coder/coder/v2/cli/clibase" "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/codersdk" + "github.com/coder/pretty" ) func (r *RootCmd) templateVersions() *clibase.Cmd { diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 4d04910796618..fb6bea96e82ba 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -12,6 +12,7 @@ "template_icon": "", "template_allow_user_cancel_workspace_jobs": false, "template_active_version_id": "[version ID]", + "template_require_active_version": false, "latest_build": { "id": "[workspace build ID]", "created_at": "[timestamp]", diff --git a/cli/testdata/coder_templates_create_--help.golden b/cli/testdata/coder_templates_create_--help.golden index 446c43f7e11ae..f458d3954dd62 100644 --- a/cli/testdata/coder_templates_create_--help.golden +++ b/cli/testdata/coder_templates_create_--help.golden @@ -49,6 +49,11 @@ OPTIONS: --provisioner-tag string-array Specify a set of tags to target provisioner daemons. + --require-active-version bool (default: false) + Requires workspace builds to use the active template version. This + setting does not apply to template admins. This is an enterprise-only + feature. + --var string-array Alias of --variable. diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index d86be791db616..fd5841125e708 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -59,6 +59,11 @@ OPTIONS: --name string Edit the template name. + --require-active-version bool (default: false) + Requires workspace builds to use the active template version. This + setting does not apply to template admins. This is an enterprise-only + feature. + -y, --yes bool Bypass prompts. diff --git a/cli/user_delete_test.go b/cli/user_delete_test.go index d8a6956577550..9ee546ca7a925 100644 --- a/cli/user_delete_test.go +++ b/cli/user_delete_test.go @@ -121,7 +121,6 @@ func TestUserDelete(t *testing.T) { // pw, err := cryptorand.String(16) // require.NoError(t, err) - // fmt.Println(aUser.OrganizationID) // toDelete, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ // Email: "colin5@coder.com", // Username: "coolin", diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index b5041179736d2..76daf4c798e2f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7816,6 +7816,10 @@ const docTemplate = `{ "description": "Name is the name of the template.", "type": "string" }, + "require_active_version": { + "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", + "type": "boolean" + }, "template_version_id": { "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", @@ -8533,19 +8537,23 @@ const docTemplate = `{ "type": "string", "enum": [ "moons", + "workspace_actions", "tailnet_pg_coordinator", "single_tailnet", "template_autostop_requirement", "deployment_health_page", - "dashboard_theme" + "dashboard_theme", + "template_update_policies" ], "x-enum-varnames": [ "ExperimentMoons", + "ExperimentWorkspaceActions", "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateAutostopRequirement", "ExperimentDeploymentHealthPage", - "ExperimentDashboardTheme" + "ExperimentDashboardTheme", + "ExperimentTemplateUpdatePolicies" ] }, "codersdk.ExternalAuth": { @@ -9994,6 +10002,10 @@ const docTemplate = `{ "terraform" ] }, + "require_active_version": { + "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", + "type": "boolean" + }, "time_til_dormant_autodelete_ms": { "type": "integer" }, @@ -11115,6 +11127,9 @@ const docTemplate = `{ "template_name": { "type": "string" }, + "template_require_active_version": { + "type": "boolean" + }, "ttl_ms": { "type": "integer" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 6e2e5c0902ddb..a6908efc1e61e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6969,6 +6969,10 @@ "description": "Name is the name of the template.", "type": "string" }, + "require_active_version": { + "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", + "type": "boolean" + }, "template_version_id": { "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", @@ -7645,19 +7649,23 @@ "type": "string", "enum": [ "moons", + "workspace_actions", "tailnet_pg_coordinator", "single_tailnet", "template_autostop_requirement", "deployment_health_page", - "dashboard_theme" + "dashboard_theme", + "template_update_policies" ], "x-enum-varnames": [ "ExperimentMoons", + "ExperimentWorkspaceActions", "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateAutostopRequirement", "ExperimentDeploymentHealthPage", - "ExperimentDashboardTheme" + "ExperimentDashboardTheme", + "ExperimentTemplateUpdatePolicies" ] }, "codersdk.ExternalAuth": { @@ -9028,6 +9036,10 @@ "type": "string", "enum": ["terraform"] }, + "require_active_version": { + "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", + "type": "boolean" + }, "time_til_dormant_autodelete_ms": { "type": "integer" }, @@ -10081,6 +10093,9 @@ "template_name": { "type": "string" }, + "template_require_active_version": { + "type": "boolean" + }, "ttl_ms": { "type": "integer" }, diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 812dc1e5c555f..cc1f60779a7dc 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -11,6 +11,8 @@ import ( "github.com/google/uuid" "github.com/sqlc-dev/pqtype" + "go.opentelemetry.io/otel/baggage" + "golang.org/x/xerrors" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" @@ -54,6 +56,7 @@ type BuildAuditParams[T Auditable] struct { Status int Action database.AuditAction OrganizationID uuid.UUID + IP string AdditionalFields json.RawMessage New T @@ -248,9 +251,7 @@ func InitRequest[T Auditable](w http.ResponseWriter, p *RequestParams) (*Request // WorkspaceBuildAudit creates an audit log for a workspace build. // The audit log is committed upon invocation. func WorkspaceBuildAudit[T Auditable](ctx context.Context, p *BuildAuditParams[T]) { - // As the audit request has not been initiated directly by a user, we omit - // certain user details. - ip := parseIP("") + ip := parseIP(p.IP) diff := Diff(p.Audit, p.Old, p.New) var err error @@ -280,16 +281,70 @@ func WorkspaceBuildAudit[T Auditable](ctx context.Context, p *BuildAuditParams[T RequestID: p.JobID, AdditionalFields: p.AdditionalFields, } - exportErr := p.Audit.Export(ctx, auditLog) - if exportErr != nil { + err = p.Audit.Export(ctx, auditLog) + if err != nil { p.Log.Error(ctx, "export audit log", slog.F("audit_log", auditLog), slog.Error(err), ) - return } } +type WorkspaceBuildBaggage struct { + IP string +} + +func (b WorkspaceBuildBaggage) Props() ([]baggage.Property, error) { + ipProp, err := baggage.NewKeyValueProperty("ip", b.IP) + if err != nil { + return nil, xerrors.Errorf("create ip kv property: %w", err) + } + + return []baggage.Property{ipProp}, nil +} + +func WorkspaceBuildBaggageFromRequest(r *http.Request) WorkspaceBuildBaggage { + return WorkspaceBuildBaggage{IP: r.RemoteAddr} +} + +type Baggage interface { + Props() ([]baggage.Property, error) +} + +func BaggageToContext(ctx context.Context, d Baggage) (context.Context, error) { + props, err := d.Props() + if err != nil { + return ctx, xerrors.Errorf("create baggage properties: %w", err) + } + + m, err := baggage.NewMember("audit", "baggage", props...) + if err != nil { + return ctx, xerrors.Errorf("create new baggage member: %w", err) + } + + b, err := baggage.New(m) + if err != nil { + return ctx, xerrors.Errorf("create new baggage carrier: %w", err) + } + + return baggage.ContextWithBaggage(ctx, b), nil +} + +func BaggageFromContext(ctx context.Context) WorkspaceBuildBaggage { + d := WorkspaceBuildBaggage{} + b := baggage.FromContext(ctx) + props := b.Member("audit").Properties() + for _, prop := range props { + switch prop.Key() { + case "ip": + d.IP, _ = prop.Value() + default: + } + } + + return d +} + func either[T Auditable, R any](old, new T, fn func(T) R, auditAction database.AuditAction) R { if ResourceID(new) != uuid.Nil { return fn(new) diff --git a/coderd/audit/request_test.go b/coderd/audit/request_test.go new file mode 100644 index 0000000000000..e0040425d4683 --- /dev/null +++ b/coderd/audit/request_test.go @@ -0,0 +1,33 @@ +package audit_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/propagation" + + "github.com/coder/coder/v2/coderd/audit" +) + +func TestBaggage(t *testing.T) { + t.Parallel() + prop := propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) + + expected := audit.WorkspaceBuildBaggage{ + IP: "127.0.0.1", + } + + ctx, err := audit.BaggageToContext(context.Background(), expected) + require.NoError(t, err) + + carrier := propagation.MapCarrier{} + prop.Inject(ctx, carrier) + bCtx := prop.Extract(ctx, carrier) + got := audit.BaggageFromContext(bCtx) + + require.Equal(t, expected, got) +} diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 07c586b2331bb..d2fb16b033946 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -32,6 +32,7 @@ type Executor struct { db database.Store ps pubsub.Pubsub templateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + accessControlStore *atomic.Pointer[dbauthz.AccessControlStore] auditor *atomic.Pointer[audit.Auditor] log slog.Logger tick <-chan time.Time @@ -46,7 +47,7 @@ type Stats struct { } // New returns a new wsactions executor. -func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], log slog.Logger, tick <-chan time.Time) *Executor { +func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss *atomic.Pointer[schedule.TemplateScheduleStore], auditor *atomic.Pointer[audit.Auditor], acs *atomic.Pointer[dbauthz.AccessControlStore], log slog.Logger, tick <-chan time.Time) *Executor { le := &Executor{ //nolint:gocritic // Autostart has a limited set of permissions. ctx: dbauthz.AsAutostart(ctx), @@ -56,6 +57,7 @@ func NewExecutor(ctx context.Context, db database.Store, ps pubsub.Pubsub, tss * tick: tick, log: log.Named("autobuild"), auditor: auditor, + accessControlStore: acs, } return le } @@ -159,6 +161,12 @@ func (e *Executor) runOnce(t time.Time) Stats { return nil } + template, err := tx.GetTemplateByID(e.ctx, ws.TemplateID) + if err != nil { + log.Warn(e.ctx, "get template by id", slog.Error(err)) + } + accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(template) + latestJob, err := tx.GetProvisionerJobByID(e.ctx, latestBuild.JobID) if err != nil { log.Warn(e.ctx, "get last provisioner job for workspace %q: %w", slog.Error(err)) @@ -179,12 +187,13 @@ func (e *Executor) runOnce(t time.Time) Stats { Reason(reason) log.Debug(e.ctx, "auto building workspace", slog.F("transition", nextTransition)) if nextTransition == database.WorkspaceTransitionStart && - ws.AutomaticUpdates == database.AutomaticUpdatesAlways { + useActiveVersion(accessControl, ws) { log.Debug(e.ctx, "autostarting with active version") builder = builder.ActiveVersion() } - build, job, err = builder.Build(e.ctx, tx, nil) + build, job, err = builder.Build(e.ctx, tx, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) + if err != nil { log.Error(e.ctx, "unable to transition workspace", slog.F("transition", nextTransition), @@ -469,3 +478,7 @@ func auditBuild(ctx context.Context, log slog.Logger, auditor audit.Auditor, par AdditionalFields: raw, }) } + +func useActiveVersion(opts dbauthz.TemplateAccessControl, ws database.Workspace) bool { + return opts.RequireActiveVersion || ws.AutomaticUpdates == database.AutomaticUpdatesAlways +} diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index fd37166ea86db..81bbf80603898 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -783,6 +783,56 @@ func TestExecutorAutostopTemplateDisabled(t *testing.T) { assert.Len(t, stats.Transitions, 0) } +// Test that an AGPL AccessControlStore properly disables +// functionality. +func TestExecutorRequireActiveVersion(t *testing.T) { + t.Parallel() + + var ( + sched = mustSchedule(t, "CRON_TZ=UTC 0 * * * *") + ticker = make(chan time.Time) + statCh = make(chan autobuild.Stats) + + ownerClient = coderdtest.New(t, &coderdtest.Options{ + AutobuildTicker: ticker, + IncludeProvisionerDaemon: true, + AutobuildStats: statCh, + TemplateScheduleStore: schedule.NewAGPLTemplateScheduleStore(), + }) + ) + owner := coderdtest.CreateFirstUser(t, ownerClient) + + // Create an active and inactive template version. We'll + // build a regular member's workspace using a non-active + // template version and assert that the field is not abided + // since there is no enterprise license. + activeVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, activeVersion.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.RequireActiveVersion = true + ctr.VersionID = activeVersion.ID + }) + inactiveVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, activeVersion.ID) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + ws := coderdtest.CreateWorkspace(t, memberClient, owner.OrganizationID, uuid.Nil, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TemplateVersionID = inactiveVersion.ID + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, ownerClient, ws.LatestBuild.ID) + ws = coderdtest.MustTransitionWorkspace(t, memberClient, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { + req.TemplateVersionID = inactiveVersion.ID + }) + require.Equal(t, inactiveVersion.ID, ws.LatestBuild.TemplateVersionID) + ticker <- sched.Next(ws.LatestBuild.CreatedAt) + stats := <-statCh + require.Len(t, stats.Transitions, 1) + + ws = coderdtest.MustWorkspace(t, memberClient, ws.ID) + require.Equal(t, inactiveVersion.ID, ws.LatestBuild.TemplateVersionID) +} + // TestExecutorFailedWorkspace test AGPL functionality which mainly // ensures that autostop actions as a result of a failed workspace // build do not trigger. diff --git a/coderd/coderd.go b/coderd/coderd.go index 7ab2e578462b1..81ec1e87e6b18 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -131,6 +131,7 @@ type Options struct { SetUserSiteRoles func(ctx context.Context, logger slog.Logger, tx database.Store, userID uuid.UUID, roles []string) error TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] + AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore] // AppSecurityKey is the crypto key used to sign and encrypt tokens related to // workspace applications. It consists of both a signing and encryption key. AppSecurityKey workspaceapps.SecurityKey @@ -208,11 +209,20 @@ func New(options *Options) *API { if options.Authorizer == nil { options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) } + + if options.AccessControlStore == nil { + options.AccessControlStore = &atomic.Pointer[dbauthz.AccessControlStore]{} + var tacs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} + options.AccessControlStore.Store(&tacs) + } + options.Database = dbauthz.New( options.Database, options.Authorizer, options.Logger.Named("authz_querier"), + options.AccessControlStore, ) + experiments := ReadExperiments( options.Logger, options.DeploymentValues.Experiments.Value(), ) @@ -369,6 +379,7 @@ func New(options *Options) *API { Auditor: atomic.Pointer[audit.Auditor]{}, TemplateScheduleStore: options.TemplateScheduleStore, UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, + AccessControlStore: options.AccessControlStore, Experiments: experiments, healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{}, Acquirer: provisionerdserver.NewAcquirer( @@ -1008,6 +1019,9 @@ type API struct { UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] // DERPMapper mutates the DERPMap to include workspace proxies. DERPMapper atomic.Pointer[func(derpMap *tailcfg.DERPMap) *tailcfg.DERPMap] + // AccessControlStore is a pointer to an atomic pointer since it is + // passed to dbauthz. + AccessControlStore *atomic.Pointer[dbauthz.AccessControlStore] HTTPAuth *HTTPAuthorizer diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 4eea44a226bfa..85ceeba1be349 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -218,7 +218,6 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can if options.Database == nil { options.Database, options.Pubsub = dbtestutil.NewDB(t) - options.Database = dbauthz.New(options.Database, options.Authorizer, options.Logger.Leveled(slog.LevelDebug)) } // Some routes expect a deployment ID, so just make sure one exists. @@ -260,6 +259,10 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can t.Cleanup(closeBatcher) } + accessControlStore := &atomic.Pointer[dbauthz.AccessControlStore]{} + var acs dbauthz.AccessControlStore = dbauthz.AGPLTemplateAccessControlStore{} + accessControlStore.Store(&acs) + var templateScheduleStore atomic.Pointer[schedule.TemplateScheduleStore] if options.TemplateScheduleStore == nil { options.TemplateScheduleStore = schedule.NewAGPLTemplateScheduleStore() @@ -279,6 +282,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can options.Pubsub, &templateScheduleStore, &auditor, + accessControlStore, *options.Logger, options.AutobuildTicker, ).WithStatsChannel(options.AutobuildStats) @@ -416,6 +420,7 @@ func NewOptions(t testing.TB, options *Options) (func(http.Handler), context.Can Authorizer: options.Authorizer, Telemetry: telemetry.NewNoop(), TemplateScheduleStore: &templateScheduleStore, + AccessControlStore: accessControlStore, TLSCertificates: options.TLSCertificates, TrialGenerator: options.TrialGenerator, TailnetCoordinator: options.Coordinator, @@ -605,7 +610,7 @@ func CreateAnotherUserMutators(t testing.TB, client *codersdk.Client, organizati func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, retries int, roles []string, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) { req := codersdk.CreateUserRequest{ Email: namesgenerator.GetRandomName(10) + "@coder.com", - Username: randomUsername(t), + Username: RandomUsername(t), Password: "SomeSecurePassword!", OrganizationID: organizationID, } @@ -739,7 +744,7 @@ func CreateWorkspaceBuild( // compatibility with testing. The name assigned is randomly generated. func CreateTemplate(t testing.TB, client *codersdk.Client, organization uuid.UUID, version uuid.UUID, mutators ...func(*codersdk.CreateTemplateRequest)) codersdk.Template { req := codersdk.CreateTemplateRequest{ - Name: randomUsername(t), + Name: RandomUsername(t), VersionID: version, } for _, mut := range mutators { @@ -901,7 +906,7 @@ func CreateWorkspace(t testing.TB, client *codersdk.Client, organization uuid.UU t.Helper() req := codersdk.CreateWorkspaceRequest{ TemplateID: templateID, - Name: randomUsername(t), + Name: RandomUsername(t), AutostartSchedule: ptr.Ref("CRON_TZ=US/Central 30 9 * * 1-5"), TTLMillis: ptr.Ref((8 * time.Hour).Milliseconds()), AutomaticUpdates: codersdk.AutomaticUpdatesNever, @@ -915,7 +920,7 @@ func CreateWorkspace(t testing.TB, client *codersdk.Client, organization uuid.UU } // TransitionWorkspace is a convenience method for transitioning a workspace from one state to another. -func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition) codersdk.Workspace { +func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID uuid.UUID, from, to database.WorkspaceTransition, muts ...func(req *codersdk.CreateWorkspaceBuildRequest)) codersdk.Workspace { t.Helper() ctx := context.Background() workspace, err := client.Workspace(ctx, workspaceID) @@ -925,10 +930,16 @@ func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID template, err := client.Template(ctx, workspace.TemplateID) require.NoError(t, err, "fetch workspace template") - build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + req := codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: template.ActiveVersionID, Transition: codersdk.WorkspaceTransition(to), - }) + } + + for _, mut := range muts { + mut(&req) + } + + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, req) require.NoError(t, err, "unexpected error transitioning workspace to %s", to) _ = AwaitWorkspaceBuildJobCompleted(t, client, build.ID) @@ -1159,7 +1170,7 @@ func NewAzureInstanceIdentity(t testing.TB, instanceID string) (x509.VerifyOptio } } -func randomUsername(t testing.TB) string { +func RandomUsername(t testing.TB) string { suffix, err := cryptorand.String(3) require.NoError(t, err) suffix = "-" + suffix diff --git a/coderd/database/dbauthz/accesscontrol.go b/coderd/database/dbauthz/accesscontrol.go new file mode 100644 index 0000000000000..92417ff4114ba --- /dev/null +++ b/coderd/database/dbauthz/accesscontrol.go @@ -0,0 +1,37 @@ +package dbauthz + +import ( + "context" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" +) + +// AccessControlStore fetches access control-related configuration +// that is used when determining whether an actor is authorized +// to interact with an RBAC object. +type AccessControlStore interface { + GetTemplateAccessControl(t database.Template) TemplateAccessControl + SetTemplateAccessControl(ctx context.Context, store database.Store, id uuid.UUID, opts TemplateAccessControl) error +} + +type TemplateAccessControl struct { + RequireActiveVersion bool +} + +// AGPLTemplateAccessControlStore always returns the defaults for access control +// settings. +type AGPLTemplateAccessControlStore struct{} + +var _ AccessControlStore = AGPLTemplateAccessControlStore{} + +func (AGPLTemplateAccessControlStore) GetTemplateAccessControl(database.Template) TemplateAccessControl { + return TemplateAccessControl{ + RequireActiveVersion: false, + } +} + +func (AGPLTemplateAccessControlStore) SetTemplateAccessControl(context.Context, database.Store, uuid.UUID, TemplateAccessControl) error { + return nil +} diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 038f4e0c92807..e9f4acc0a763a 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "sync/atomic" "time" "github.com/google/uuid" @@ -101,9 +102,10 @@ type querier struct { db database.Store auth rbac.Authorizer log slog.Logger + acs *atomic.Pointer[AccessControlStore] } -func New(db database.Store, authorizer rbac.Authorizer, logger slog.Logger) database.Store { +func New(db database.Store, authorizer rbac.Authorizer, logger slog.Logger, acs *atomic.Pointer[AccessControlStore]) database.Store { // If the underlying db store is already a querier, return it. // Do not double wrap. if slices.Contains(db.Wrappers(), wrapname) { @@ -113,6 +115,7 @@ func New(db database.Store, authorizer rbac.Authorizer, logger slog.Logger) data db: db, auth: authorizer, log: logger, + acs: acs, } } @@ -507,7 +510,7 @@ func (q *querier) Ping(ctx context.Context) (time.Duration, error) { func (q *querier) InTx(function func(querier database.Store) error, txOpts *sql.TxOptions) error { return q.db.InTx(func(tx database.Store) error { // Wrap the transaction store in a querier. - wrapped := New(tx, q.auth, q.log) + wrapped := New(tx, q.auth, q.log, q.acs) return function(wrapped) }, txOpts) } @@ -1324,6 +1327,13 @@ func (q *querier) GetTemplateInsightsByInterval(ctx context.Context, arg databas return q.db.GetTemplateInsightsByInterval(ctx, arg) } +func (q *querier) GetTemplateInsightsByTemplate(ctx context.Context, arg database.GetTemplateInsightsByTemplateParams) ([]database.GetTemplateInsightsByTemplateRow, error) { + if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceTemplate.All()); err != nil { + return nil, err + } + return q.db.GetTemplateInsightsByTemplate(ctx, arg) +} + func (q *querier) GetTemplateParameterInsights(ctx context.Context, arg database.GetTemplateParameterInsightsParams) ([]database.GetTemplateParameterInsightsRow, error) { for _, templateID := range arg.TemplateIDs { template, err := q.db.GetTemplateByID(ctx, templateID) @@ -2200,7 +2210,7 @@ func (q *querier) InsertWorkspaceAppStats(ctx context.Context, arg database.Inse func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertWorkspaceBuildParams) error { w, err := q.db.GetWorkspaceByID(ctx, arg.WorkspaceID) if err != nil { - return err + return xerrors.Errorf("get workspace by id: %w", err) } var action rbac.Action = rbac.ActionUpdate @@ -2209,7 +2219,28 @@ func (q *querier) InsertWorkspaceBuild(ctx context.Context, arg database.InsertW } if err = q.authorizeContext(ctx, action, w.WorkspaceBuildRBAC(arg.Transition)); err != nil { - return err + return xerrors.Errorf("authorize context: %w", err) + } + + // If we're starting a workspace we need to check the template. + if arg.Transition == database.WorkspaceTransitionStart { + t, err := q.db.GetTemplateByID(ctx, w.TemplateID) + if err != nil { + return xerrors.Errorf("get template by id: %w", err) + } + + accessControl := (*q.acs.Load()).GetTemplateAccessControl(t) + + // If the template requires the active version we need to check if + // the user is a template admin. If they aren't and are attempting + // to use a non-active version then we must fail the request. + if accessControl.RequireActiveVersion { + if arg.TemplateVersionID != t.ActiveVersionID { + if err = q.authorizeContext(ctx, rbac.ActionUpdate, t); err != nil { + return xerrors.Errorf("cannot use non-active version: %w", err) + } + } + } } return q.db.InsertWorkspaceBuild(ctx, arg) @@ -2442,6 +2473,13 @@ func (q *querier) UpdateTemplateACLByID(ctx context.Context, arg database.Update return fetchAndExec(q.log, q.auth, rbac.ActionCreate, fetch, q.db.UpdateTemplateACLByID)(ctx, arg) } +func (q *querier) UpdateTemplateAccessControlByID(ctx context.Context, arg database.UpdateTemplateAccessControlByIDParams) error { + fetch := func(ctx context.Context, arg database.UpdateTemplateAccessControlByIDParams) (database.Template, error) { + return q.db.GetTemplateByID(ctx, arg.ID) + } + return update(q.log, q.auth, fetch, q.db.UpdateTemplateAccessControlByID)(ctx, arg) +} + func (q *querier) UpdateTemplateActiveVersionByID(ctx context.Context, arg database.UpdateTemplateActiveVersionByIDParams) error { fetch := func(ctx context.Context, arg database.UpdateTemplateActiveVersionByIDParams) (database.Template, error) { return q.db.GetTemplateByID(ctx, arg.ID) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 28866396c8b86..a5847839b2478 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -21,6 +21,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/util/slice" + "github.com/coder/coder/v2/testutil" ) func TestAsNoActor(t *testing.T) { @@ -61,7 +62,7 @@ func TestAsNoActor(t *testing.T) { func TestPing(t *testing.T) { t.Parallel() - q := dbauthz.New(dbfake.New(), &coderdtest.RecordingAuthorizer{}, slog.Make()) + q := dbauthz.New(dbfake.New(), &coderdtest.RecordingAuthorizer{}, slog.Make(), accessControlStorePointer()) _, err := q.Ping(context.Background()) require.NoError(t, err, "must not error") } @@ -73,7 +74,7 @@ func TestInTX(t *testing.T) { db := dbfake.New() q := dbauthz.New(db, &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{AlwaysReturn: xerrors.New("custom error")}, - }, slog.Make()) + }, slog.Make(), accessControlStorePointer()) actor := rbac.Subject{ ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleOwner()}, @@ -109,8 +110,8 @@ func TestNew(t *testing.T) { // Double wrap should not cause an actual double wrap. So only 1 rbac call // should be made. - az := dbauthz.New(db, rec, slog.Make()) - az = dbauthz.New(az, rec, slog.Make()) + az := dbauthz.New(db, rec, slog.Make(), accessControlStorePointer()) + az = dbauthz.New(az, rec, slog.Make(), accessControlStorePointer()) w, err := az.GetWorkspaceByID(ctx, exp.ID) require.NoError(t, err, "must not error") @@ -127,7 +128,7 @@ func TestDBAuthzRecursive(t *testing.T) { t.Parallel() q := dbauthz.New(dbfake.New(), &coderdtest.RecordingAuthorizer{ Wrapped: &coderdtest.FakeAuthorizer{AlwaysReturn: nil}, - }, slog.Make()) + }, slog.Make(), accessControlStorePointer()) actor := rbac.Subject{ ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleOwner()}, @@ -1213,13 +1214,67 @@ func (s *MethodTestSuite) TestWorkspace() { }).Asserts(rbac.ResourceWorkspace.WithOwner(u.ID.String()).InOrg(o.ID), rbac.ActionCreate) })) s.Run("Start/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { - w := dbgen.Workspace(s.T(), db, database.Workspace{}) + t := dbgen.Template(s.T(), db, database.Template{}) + w := dbgen.Workspace(s.T(), db, database.Workspace{ + TemplateID: t.ID, + }) check.Args(database.InsertWorkspaceBuildParams{ WorkspaceID: w.ID, Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator, }).Asserts(w.WorkspaceBuildRBAC(database.WorkspaceTransitionStart), rbac.ActionUpdate) })) + s.Run("Start/RequireActiveVersion/VersionMismatch/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { + t := dbgen.Template(s.T(), db, database.Template{}) + ctx := testutil.Context(s.T(), testutil.WaitShort) + err := db.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{ + ID: t.ID, + RequireActiveVersion: true, + }) + require.NoError(s.T(), err) + v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{ + TemplateID: uuid.NullUUID{UUID: t.ID}, + }) + w := dbgen.Workspace(s.T(), db, database.Workspace{ + TemplateID: t.ID, + }) + check.Args(database.InsertWorkspaceBuildParams{ + WorkspaceID: w.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + TemplateVersionID: v.ID, + }).Asserts( + w.WorkspaceBuildRBAC(database.WorkspaceTransitionStart), rbac.ActionUpdate, + t, rbac.ActionUpdate, + ) + })) + s.Run("Start/RequireActiveVersion/VersionsMatch/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { + v := dbgen.TemplateVersion(s.T(), db, database.TemplateVersion{}) + t := dbgen.Template(s.T(), db, database.Template{ + ActiveVersionID: v.ID, + }) + + ctx := testutil.Context(s.T(), testutil.WaitShort) + err := db.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{ + ID: t.ID, + RequireActiveVersion: true, + }) + require.NoError(s.T(), err) + + w := dbgen.Workspace(s.T(), db, database.Workspace{ + TemplateID: t.ID, + }) + // Assert that we do not check for template update permissions + // if versions match. + check.Args(database.InsertWorkspaceBuildParams{ + WorkspaceID: w.ID, + Transition: database.WorkspaceTransitionStart, + Reason: database.BuildReasonInitiator, + TemplateVersionID: v.ID, + }).Asserts( + w.WorkspaceBuildRBAC(database.WorkspaceTransitionStart), rbac.ActionUpdate, + ) + })) s.Run("Delete/InsertWorkspaceBuild", s.Subtest(func(db database.Store, check *expects) { w := dbgen.Workspace(s.T(), db, database.Workspace{}) check.Args(database.InsertWorkspaceBuildParams{ diff --git a/coderd/database/dbauthz/setup_test.go b/coderd/database/dbauthz/setup_test.go index 9efcf5ef9418e..968b882f2d263 100644 --- a/coderd/database/dbauthz/setup_test.go +++ b/coderd/database/dbauthz/setup_test.go @@ -6,6 +6,7 @@ import ( "reflect" "sort" "strings" + "sync/atomic" "testing" "github.com/golang/mock/gomock" @@ -59,7 +60,7 @@ func (s *MethodTestSuite) SetupSuite() { mockStore := dbmock.NewMockStore(ctrl) // We intentionally set no expectations apart from this. mockStore.EXPECT().Wrappers().Return([]string{}).AnyTimes() - az := dbauthz.New(mockStore, nil, slog.Make()) + az := dbauthz.New(mockStore, nil, slog.Make(), accessControlStorePointer()) // Take the underlying type of the interface. azt := reflect.TypeOf(az).Elem() s.methodAccounting = make(map[string]int) @@ -110,7 +111,7 @@ func (s *MethodTestSuite) Subtest(testCaseF func(db database.Store, check *expec rec := &coderdtest.RecordingAuthorizer{ Wrapped: fakeAuthorizer, } - az := dbauthz.New(db, rec, slog.Make()) + az := dbauthz.New(db, rec, slog.Make(), accessControlStorePointer()) actor := rbac.Subject{ ID: uuid.NewString(), Roles: rbac.RoleNames{rbac.RoleOwner()}, @@ -398,3 +399,22 @@ func (emptyPreparedAuthorized) Authorize(_ context.Context, _ rbac.Object) error func (emptyPreparedAuthorized) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) { return "", nil } + +func accessControlStorePointer() *atomic.Pointer[dbauthz.AccessControlStore] { + acs := &atomic.Pointer[dbauthz.AccessControlStore]{} + var tacs dbauthz.AccessControlStore = fakeAccessControlStore{} + acs.Store(&tacs) + return acs +} + +type fakeAccessControlStore struct{} + +func (fakeAccessControlStore) GetTemplateAccessControl(t database.Template) dbauthz.TemplateAccessControl { + return dbauthz.TemplateAccessControl{ + RequireActiveVersion: t.RequireActiveVersion, + } +} + +func (fakeAccessControlStore) SetTemplateAccessControl(context.Context, database.Store, uuid.UUID, dbauthz.TemplateAccessControl) error { + panic("not implemented") +} diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index bffd855da6b6b..e2ddde14e2d00 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -1709,7 +1709,7 @@ func (q *FakeQuerier) GetGroupMembers(_ context.Context, id uuid.UUID) ([]databa for _, member := range members { for _, user := range q.users { - if user.ID == member.UserID && user.Status == database.UserStatusActive && !user.Deleted { + if user.ID == member.UserID && !user.Deleted { users = append(users, user) break } @@ -2500,6 +2500,10 @@ func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTem templateIDSet := make(map[uuid.UUID]struct{}) appUsageIntervalsByUser := make(map[uuid.UUID]map[time.Time]*database.GetTemplateInsightsRow) + + q.mutex.RLock() + defer q.mutex.RUnlock() + for _, s := range q.workspaceAgentStats { if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) { continue @@ -2648,6 +2652,101 @@ func (q *FakeQuerier) GetTemplateInsightsByInterval(ctx context.Context, arg dat return result, nil } +func (q *FakeQuerier) GetTemplateInsightsByTemplate(_ context.Context, arg database.GetTemplateInsightsByTemplateParams) ([]database.GetTemplateInsightsByTemplateRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + // map time.Time x TemplateID x UserID x + appUsageByTemplateAndUser := map[time.Time]map[uuid.UUID]map[uuid.UUID]database.GetTemplateInsightsByTemplateRow{} + + // Review agent stats in terms of usage + templateIDSet := make(map[uuid.UUID]struct{}) + + for _, s := range q.workspaceAgentStats { + if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) { + continue + } + if s.ConnectionCount == 0 { + continue + } + + t := s.CreatedAt.Truncate(time.Minute) + templateIDSet[s.TemplateID] = struct{}{} + + if _, ok := appUsageByTemplateAndUser[t]; !ok { + appUsageByTemplateAndUser[t] = make(map[uuid.UUID]map[uuid.UUID]database.GetTemplateInsightsByTemplateRow) + } + + if _, ok := appUsageByTemplateAndUser[t][s.TemplateID]; !ok { + appUsageByTemplateAndUser[t][s.TemplateID] = make(map[uuid.UUID]database.GetTemplateInsightsByTemplateRow) + } + + if _, ok := appUsageByTemplateAndUser[t][s.TemplateID][s.UserID]; !ok { + appUsageByTemplateAndUser[t][s.TemplateID][s.UserID] = database.GetTemplateInsightsByTemplateRow{} + } + + u := appUsageByTemplateAndUser[t][s.TemplateID][s.UserID] + if s.SessionCountJetBrains > 0 { + u.UsageJetbrainsSeconds = 60 + } + if s.SessionCountVSCode > 0 { + u.UsageVscodeSeconds = 60 + } + if s.SessionCountReconnectingPTY > 0 { + u.UsageReconnectingPtySeconds = 60 + } + if s.SessionCountSSH > 0 { + u.UsageSshSeconds = 60 + } + appUsageByTemplateAndUser[t][s.TemplateID][s.UserID] = u + } + + // Sort used templates + templateIDs := make([]uuid.UUID, 0, len(templateIDSet)) + for templateID := range templateIDSet { + templateIDs = append(templateIDs, templateID) + } + slices.SortFunc(templateIDs, func(a, b uuid.UUID) int { + return slice.Ascending(a.String(), b.String()) + }) + + // Build result + var result []database.GetTemplateInsightsByTemplateRow + for _, templateID := range templateIDs { + r := database.GetTemplateInsightsByTemplateRow{ + TemplateID: templateID, + } + + uniqueUsers := map[uuid.UUID]struct{}{} + + for _, mTemplateUserUsage := range appUsageByTemplateAndUser { + mUserUsage, ok := mTemplateUserUsage[templateID] + if !ok { + continue // template was not used in this time window + } + + for userID, usage := range mUserUsage { + uniqueUsers[userID] = struct{}{} + + r.UsageJetbrainsSeconds += usage.UsageJetbrainsSeconds + r.UsageVscodeSeconds += usage.UsageVscodeSeconds + r.UsageReconnectingPtySeconds += usage.UsageReconnectingPtySeconds + r.UsageSshSeconds += usage.UsageSshSeconds + } + } + + r.ActiveUsers = int64(len(uniqueUsers)) + + result = append(result, r) + } + return result, nil +} + func (q *FakeQuerier) GetTemplateParameterInsights(ctx context.Context, arg database.GetTemplateParameterInsightsParams) ([]database.GetTemplateParameterInsightsRow, error) { err := validateDatabaseType(arg) if err != nil { @@ -4589,6 +4688,7 @@ func (q *FakeQuerier) InsertProvisionerJob(_ context.Context, arg database.Inser Type: arg.Type, Input: arg.Input, Tags: arg.Tags, + TraceMetadata: arg.TraceMetadata, } job.JobStatus = provisonerJobStatus(job) q.provisionerJobs = append(q.provisionerJobs, job) @@ -5642,6 +5742,25 @@ func (q *FakeQuerier) UpdateTemplateACLByID(_ context.Context, arg database.Upda return sql.ErrNoRows } +func (q *FakeQuerier) UpdateTemplateAccessControlByID(_ context.Context, arg database.UpdateTemplateAccessControlByIDParams) error { + if err := validateDatabaseType(arg); err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for idx, tpl := range q.templates { + if tpl.ID != arg.ID { + continue + } + q.templates[idx].RequireActiveVersion = arg.RequireActiveVersion + return nil + } + + return sql.ErrNoRows +} + func (q *FakeQuerier) UpdateTemplateActiveVersionByID(_ context.Context, arg database.UpdateTemplateActiveVersionByIDParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index ece7020139b0f..3a89ddd379790 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -704,6 +704,13 @@ func (m metricsStore) GetTemplateInsightsByInterval(ctx context.Context, arg dat return r0, r1 } +func (m metricsStore) GetTemplateInsightsByTemplate(ctx context.Context, arg database.GetTemplateInsightsByTemplateParams) ([]database.GetTemplateInsightsByTemplateRow, error) { + start := time.Now() + r0, r1 := m.s.GetTemplateInsightsByTemplate(ctx, arg) + m.queryLatencies.WithLabelValues("GetTemplateInsightsByTemplate").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetTemplateParameterInsights(ctx context.Context, arg database.GetTemplateParameterInsightsParams) ([]database.GetTemplateParameterInsightsRow, error) { start := time.Now() r0, r1 := m.s.GetTemplateParameterInsights(ctx, arg) @@ -1523,6 +1530,13 @@ func (m metricsStore) UpdateTemplateACLByID(ctx context.Context, arg database.Up return err } +func (m metricsStore) UpdateTemplateAccessControlByID(ctx context.Context, arg database.UpdateTemplateAccessControlByIDParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateAccessControlByID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateAccessControlByID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpdateTemplateActiveVersionByID(ctx context.Context, arg database.UpdateTemplateActiveVersionByIDParams) error { start := time.Now() err := m.s.UpdateTemplateActiveVersionByID(ctx, arg) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 31614be3ae919..080c9630e7bc4 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1433,6 +1433,21 @@ func (mr *MockStoreMockRecorder) GetTemplateInsightsByInterval(arg0, arg1 interf return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateInsightsByInterval", reflect.TypeOf((*MockStore)(nil).GetTemplateInsightsByInterval), arg0, arg1) } +// GetTemplateInsightsByTemplate mocks base method. +func (m *MockStore) GetTemplateInsightsByTemplate(arg0 context.Context, arg1 database.GetTemplateInsightsByTemplateParams) ([]database.GetTemplateInsightsByTemplateRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTemplateInsightsByTemplate", arg0, arg1) + ret0, _ := ret[0].([]database.GetTemplateInsightsByTemplateRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTemplateInsightsByTemplate indicates an expected call of GetTemplateInsightsByTemplate. +func (mr *MockStoreMockRecorder) GetTemplateInsightsByTemplate(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateInsightsByTemplate", reflect.TypeOf((*MockStore)(nil).GetTemplateInsightsByTemplate), arg0, arg1) +} + // GetTemplateParameterInsights mocks base method. func (m *MockStore) GetTemplateParameterInsights(arg0 context.Context, arg1 database.GetTemplateParameterInsightsParams) ([]database.GetTemplateParameterInsightsRow, error) { m.ctrl.T.Helper() @@ -3213,6 +3228,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateACLByID(arg0, arg1 interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateACLByID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateACLByID), arg0, arg1) } +// UpdateTemplateAccessControlByID mocks base method. +func (m *MockStore) UpdateTemplateAccessControlByID(arg0 context.Context, arg1 database.UpdateTemplateAccessControlByIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateAccessControlByID", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateAccessControlByID indicates an expected call of UpdateTemplateAccessControlByID. +func (mr *MockStoreMockRecorder) UpdateTemplateAccessControlByID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateAccessControlByID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateAccessControlByID), arg0, arg1) +} + // UpdateTemplateActiveVersionByID mocks base method. func (m *MockStore) UpdateTemplateActiveVersionByID(arg0 context.Context, arg1 database.UpdateTemplateActiveVersionByIDParams) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ab6eb95252f2d..0e79c875f93bc 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -755,7 +755,8 @@ CREATE TABLE templates ( time_til_dormant_autodelete bigint DEFAULT 0 NOT NULL, autostop_requirement_days_of_week smallint DEFAULT 0 NOT NULL, autostop_requirement_weeks bigint DEFAULT 0 NOT NULL, - autostart_block_days_of_week smallint DEFAULT 0 NOT NULL + autostart_block_days_of_week smallint DEFAULT 0 NOT NULL, + require_active_version boolean DEFAULT false NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; @@ -800,6 +801,7 @@ CREATE VIEW template_with_users AS templates.autostop_requirement_days_of_week, templates.autostop_requirement_weeks, templates.autostart_block_days_of_week, + templates.require_active_version, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username FROM (public.templates diff --git a/coderd/database/migrations/000166_template_active_version.down.sql b/coderd/database/migrations/000166_template_active_version.down.sql new file mode 100644 index 0000000000000..d3b4bba305e02 --- /dev/null +++ b/coderd/database/migrations/000166_template_active_version.down.sql @@ -0,0 +1,25 @@ +BEGIN; + +-- Update the template_with_users view; +DROP VIEW template_with_users; + +ALTER TABLE templates DROP COLUMN require_active_version; + +-- If you need to update this view, put 'DROP VIEW template_with_users;' before this. +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/migrations/000166_template_active_version.up.sql b/coderd/database/migrations/000166_template_active_version.up.sql new file mode 100644 index 0000000000000..a9255505eede7 --- /dev/null +++ b/coderd/database/migrations/000166_template_active_version.up.sql @@ -0,0 +1,23 @@ +BEGIN; + +DROP VIEW template_with_users; + +ALTER TABLE templates ADD COLUMN require_active_version boolean NOT NULL DEFAULT 'f'; + +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; + +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 66e1618bf570f..ed5bf76b784d7 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -179,7 +179,7 @@ func (w Workspace) ApplicationConnectRBAC() rbac.Object { } func (w Workspace) WorkspaceBuildRBAC(transition WorkspaceTransition) rbac.Object { - // If a workspace is locked it cannot be built. + // If a workspace is dormant it cannot be built. // However we need to allow stopping a workspace by a caller once a workspace // is locked (e.g. for autobuild). Additionally, if a user wants to delete // a locked workspace, they shouldn't have to have it unlocked first. diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index bb380a8293bdc..5c78600237e1d 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -86,6 +86,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { diff --git a/coderd/database/models.go b/coderd/database/models.go index 1684e4fe5ebeb..c67afa74faa9a 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1892,6 +1892,7 @@ type Template struct { AutostopRequirementDaysOfWeek int16 `db:"autostop_requirement_days_of_week" json:"autostop_requirement_days_of_week"` AutostopRequirementWeeks int64 `db:"autostop_requirement_weeks" json:"autostop_requirement_weeks"` AutostartBlockDaysOfWeek int16 `db:"autostart_block_days_of_week" json:"autostart_block_days_of_week"` + RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"` CreatedByAvatarURL sql.NullString `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` } @@ -1930,6 +1931,7 @@ type TemplateTable struct { AutostopRequirementWeeks int64 `db:"autostop_requirement_weeks" json:"autostop_requirement_weeks"` // A bitmap of days of week that autostart of a workspace is not allowed. Default allows all days. This is intended as a cost savings measure to prevent auto start on weekends (for example). AutostartBlockDaysOfWeek int16 `db:"autostart_block_days_of_week" json:"autostart_block_days_of_week"` + RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"` } // Joins in the username + avatar url of the created by user. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 99503ba40e3d6..2d278ba933e67 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -145,6 +145,7 @@ type sqlcQuerier interface { // that interval will be shorter than a full one. If there is no data for a selected // interval/template, it will be included in the results with 0 active users. GetTemplateInsightsByInterval(ctx context.Context, arg GetTemplateInsightsByIntervalParams) ([]GetTemplateInsightsByIntervalRow, error) + GetTemplateInsightsByTemplate(ctx context.Context, arg GetTemplateInsightsByTemplateParams) ([]GetTemplateInsightsByTemplateRow, error) // GetTemplateParameterInsights does for each template in a given timeframe, // look for the latest workspace build (for every workspace) that has been // created in the timeframe and return the aggregate usage counts of parameter @@ -301,6 +302,7 @@ type sqlcQuerier interface { UpdateProvisionerJobWithCompleteByID(ctx context.Context, arg UpdateProvisionerJobWithCompleteByIDParams) error UpdateReplica(ctx context.Context, arg UpdateReplicaParams) (Replica, error) UpdateTemplateACLByID(ctx context.Context, arg UpdateTemplateACLByIDParams) error + UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error UpdateTemplateActiveVersionByID(ctx context.Context, arg UpdateTemplateActiveVersionByIDParams) error UpdateTemplateDeletedByID(ctx context.Context, arg UpdateTemplateDeletedByIDParams) error UpdateTemplateMetaByID(ctx context.Context, arg UpdateTemplateMetaByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fc301d427fa8d..50da65e9c74a4 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1276,8 +1276,6 @@ WHERE (group_members.group_id = $1 OR organization_members.organization_id = $1) -AND - users.status = 'active' AND users.deleted = 'false' ` @@ -1937,6 +1935,79 @@ func (q *sqlQuerier) GetTemplateInsightsByInterval(ctx context.Context, arg GetT return items, nil } +const getTemplateInsightsByTemplate = `-- name: GetTemplateInsightsByTemplate :many +WITH agent_stats_by_interval_and_user AS ( + SELECT + date_trunc('minute', was.created_at) AS created_at_trunc, + was.template_id, + was.user_id, + CASE WHEN SUM(was.session_count_vscode) > 0 THEN 60 ELSE 0 END AS usage_vscode_seconds, + CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN 60 ELSE 0 END AS usage_jetbrains_seconds, + CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN 60 ELSE 0 END AS usage_reconnecting_pty_seconds, + CASE WHEN SUM(was.session_count_ssh) > 0 THEN 60 ELSE 0 END AS usage_ssh_seconds + FROM workspace_agent_stats was + WHERE + was.created_at >= $1::timestamptz + AND was.created_at < $2::timestamptz + AND was.connection_count > 0 + GROUP BY created_at_trunc, was.template_id, was.user_id +) + +SELECT + template_id, + COALESCE(COUNT(DISTINCT user_id))::bigint AS active_users, + COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds, + COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds, + COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds, + COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds +FROM agent_stats_by_interval_and_user +GROUP BY template_id +` + +type GetTemplateInsightsByTemplateParams struct { + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime time.Time `db:"end_time" json:"end_time"` +} + +type GetTemplateInsightsByTemplateRow struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + ActiveUsers int64 `db:"active_users" json:"active_users"` + UsageVscodeSeconds int64 `db:"usage_vscode_seconds" json:"usage_vscode_seconds"` + UsageJetbrainsSeconds int64 `db:"usage_jetbrains_seconds" json:"usage_jetbrains_seconds"` + UsageReconnectingPtySeconds int64 `db:"usage_reconnecting_pty_seconds" json:"usage_reconnecting_pty_seconds"` + UsageSshSeconds int64 `db:"usage_ssh_seconds" json:"usage_ssh_seconds"` +} + +func (q *sqlQuerier) GetTemplateInsightsByTemplate(ctx context.Context, arg GetTemplateInsightsByTemplateParams) ([]GetTemplateInsightsByTemplateRow, error) { + rows, err := q.db.QueryContext(ctx, getTemplateInsightsByTemplate, arg.StartTime, arg.EndTime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTemplateInsightsByTemplateRow + for rows.Next() { + var i GetTemplateInsightsByTemplateRow + if err := rows.Scan( + &i.TemplateID, + &i.ActiveUsers, + &i.UsageVscodeSeconds, + &i.UsageJetbrainsSeconds, + &i.UsageReconnectingPtySeconds, + &i.UsageSshSeconds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTemplateParameterInsights = `-- name: GetTemplateParameterInsights :many WITH latest_workspace_builds AS ( SELECT @@ -4726,7 +4797,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username FROM template_with_users WHERE @@ -4764,6 +4835,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -4772,7 +4844,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -4818,6 +4890,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -4825,7 +4898,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, created_by_avatar_url, created_by_username FROM template_with_users AS templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username FROM template_with_users AS templates ORDER BY (name, id) ASC ` @@ -4864,6 +4937,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -4882,7 +4956,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -4958,6 +5032,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.AutostopRequirementDaysOfWeek, &i.AutostopRequirementWeeks, &i.AutostartBlockDaysOfWeek, + &i.RequireActiveVersion, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -5054,6 +5129,25 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla return err } +const updateTemplateAccessControlByID = `-- name: UpdateTemplateAccessControlByID :exec +UPDATE + templates +SET + require_active_version = $2 +WHERE + id = $1 +` + +type UpdateTemplateAccessControlByIDParams struct { + ID uuid.UUID `db:"id" json:"id"` + RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"` +} + +func (q *sqlQuerier) UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateAccessControlByID, arg.ID, arg.RequireActiveVersion) + return err +} + const updateTemplateActiveVersionByID = `-- name: UpdateTemplateActiveVersionByID :exec UPDATE templates diff --git a/coderd/database/queries/groupmembers.sql b/coderd/database/queries/groupmembers.sql index 0b3d0a33f4d54..4999df7930044 100644 --- a/coderd/database/queries/groupmembers.sql +++ b/coderd/database/queries/groupmembers.sql @@ -20,8 +20,6 @@ WHERE (group_members.group_id = @group_id OR organization_members.organization_id = @group_id) -AND - users.status = 'active' AND users.deleted = 'false'; diff --git a/coderd/database/queries/insights.sql b/coderd/database/queries/insights.sql index 7fb48100d5d8a..01863fede1aed 100644 --- a/coderd/database/queries/insights.sql +++ b/coderd/database/queries/insights.sql @@ -134,6 +134,34 @@ SELECT COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds FROM agent_stats_by_interval_and_user; +-- name: GetTemplateInsightsByTemplate :many +WITH agent_stats_by_interval_and_user AS ( + SELECT + date_trunc('minute', was.created_at) AS created_at_trunc, + was.template_id, + was.user_id, + CASE WHEN SUM(was.session_count_vscode) > 0 THEN 60 ELSE 0 END AS usage_vscode_seconds, + CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN 60 ELSE 0 END AS usage_jetbrains_seconds, + CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN 60 ELSE 0 END AS usage_reconnecting_pty_seconds, + CASE WHEN SUM(was.session_count_ssh) > 0 THEN 60 ELSE 0 END AS usage_ssh_seconds + FROM workspace_agent_stats was + WHERE + was.created_at >= @start_time::timestamptz + AND was.created_at < @end_time::timestamptz + AND was.connection_count > 0 + GROUP BY created_at_trunc, was.template_id, was.user_id +) + +SELECT + template_id, + COALESCE(COUNT(DISTINCT user_id))::bigint AS active_users, + COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds, + COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds, + COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds, + COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds +FROM agent_stats_by_interval_and_user +GROUP BY template_id; + -- name: GetTemplateAppInsights :many -- GetTemplateAppInsights returns the aggregate usage of each app in a given -- timeframe. The result can be filtered on template_ids, meaning only user data diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index e89c245b0391d..c5bc72d7911d6 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -169,3 +169,12 @@ SELECT coalesce((PERCENTILE_DISC(0.95) WITHIN GROUP(ORDER BY exec_time_sec) FILTER (WHERE transition = 'delete')), -1)::FLOAT AS delete_95 FROM build_times ; + +-- name: UpdateTemplateAccessControlByID :exec +UPDATE + templates +SET + require_active_version = $2 +WHERE + id = $1 +; diff --git a/coderd/externalauth.go b/coderd/externalauth.go index 31dff667c28e7..774a5f860397d 100644 --- a/coderd/externalauth.go +++ b/coderd/externalauth.go @@ -6,9 +6,8 @@ import ( "fmt" "net/http" - "golang.org/x/sync/errgroup" - "github.com/sqlc-dev/pqtype" + "golang.org/x/sync/errgroup" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" diff --git a/coderd/prometheusmetrics/insights/metricscollector.go b/coderd/prometheusmetrics/insights/metricscollector.go new file mode 100644 index 0000000000000..d19785e8e6131 --- /dev/null +++ b/coderd/prometheusmetrics/insights/metricscollector.go @@ -0,0 +1,174 @@ +package insights + +import ( + "context" + "sync/atomic" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "golang.org/x/sync/errgroup" + "golang.org/x/xerrors" + + "cdr.dev/slog" + + "github.com/coder/coder/v2/coderd/database" +) + +var templatesActiveUsersDesc = prometheus.NewDesc("coderd_insights_templates_active_users", "The number of active users of the template.", []string{"template_name"}, nil) + +type MetricsCollector struct { + database database.Store + logger slog.Logger + timeWindow time.Duration + tickInterval time.Duration + + data atomic.Pointer[insightsData] +} + +type insightsData struct { + templates []database.GetTemplateInsightsByTemplateRow + + templateNames map[uuid.UUID]string +} + +var _ prometheus.Collector = new(MetricsCollector) + +func NewMetricsCollector(db database.Store, logger slog.Logger, timeWindow time.Duration, tickInterval time.Duration) (*MetricsCollector, error) { + if timeWindow == 0 { + timeWindow = 5 * time.Minute + } + if timeWindow < 5*time.Minute { + return nil, xerrors.Errorf("time window must be at least 5 mins") + } + if tickInterval == 0 { + tickInterval = timeWindow + } + + return &MetricsCollector{ + database: db, + logger: logger.Named("insights_metrics_collector"), + timeWindow: timeWindow, + tickInterval: tickInterval, + }, nil +} + +func (mc *MetricsCollector) Run(ctx context.Context) (func(), error) { + ctx, closeFunc := context.WithCancel(ctx) + done := make(chan struct{}) + + // Use time.Nanosecond to force an initial tick. It will be reset to the + // correct duration after executing once. + ticker := time.NewTicker(time.Nanosecond) + doTick := func() { + defer ticker.Reset(mc.tickInterval) + + now := time.Now() + startTime := now.Add(-mc.timeWindow) + endTime := now + + // Phase 1: Fetch insights from database + // FIXME errorGroup will be used to fetch insights for apps and parameters + eg, egCtx := errgroup.WithContext(ctx) + eg.SetLimit(1) + + var templateInsights []database.GetTemplateInsightsByTemplateRow + + eg.Go(func() error { + var err error + templateInsights, err = mc.database.GetTemplateInsightsByTemplate(egCtx, database.GetTemplateInsightsByTemplateParams{ + StartTime: startTime, + EndTime: endTime, + }) + if err != nil { + mc.logger.Error(ctx, "unable to fetch template insights from database", slog.Error(err)) + } + return err + }) + err := eg.Wait() + if err != nil { + return + } + + // Phase 2: Collect template IDs, and fetch relevant details + templateIDs := uniqueTemplateIDs(templateInsights) + + templateNames := make(map[uuid.UUID]string, len(templateIDs)) + if len(templateIDs) > 0 { + templates, err := mc.database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ + IDs: templateIDs, + }) + if err != nil { + mc.logger.Error(ctx, "unable to fetch template details from database", slog.Error(err)) + return + } + templateNames = onlyTemplateNames(templates) + } + + // Refresh the collector state + mc.data.Store(&insightsData{ + templates: templateInsights, + templateNames: templateNames, + }) + } + + go func() { + defer close(done) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + ticker.Stop() + doTick() + } + } + }() + return func() { + closeFunc() + <-done + }, nil +} + +func (*MetricsCollector) Describe(descCh chan<- *prometheus.Desc) { + descCh <- templatesActiveUsersDesc +} + +func (mc *MetricsCollector) Collect(metricsCh chan<- prometheus.Metric) { + // Phase 3: Collect metrics + + data := mc.data.Load() + if data == nil { + return // insights data not loaded yet + } + + for _, templateRow := range data.templates { + metricsCh <- prometheus.MustNewConstMetric(templatesActiveUsersDesc, prometheus.GaugeValue, float64(templateRow.ActiveUsers), data.templateNames[templateRow.TemplateID]) + } +} + +// Helper functions below. + +func uniqueTemplateIDs(templateInsights []database.GetTemplateInsightsByTemplateRow) []uuid.UUID { + tids := map[uuid.UUID]bool{} + for _, t := range templateInsights { + tids[t.TemplateID] = true + } + + uniqueUUIDs := make([]uuid.UUID, len(tids)) + var i int + for t := range tids { + uniqueUUIDs[i] = t + i++ + } + return uniqueUUIDs +} + +func onlyTemplateNames(templates []database.Template) map[uuid.UUID]string { + m := map[uuid.UUID]string{} + for _, t := range templates { + m[t.ID] = t.Name + } + return m +} diff --git a/coderd/prometheusmetrics/insights/metricscollector_test.go b/coderd/prometheusmetrics/insights/metricscollector_test.go new file mode 100644 index 0000000000000..0c1726a910e96 --- /dev/null +++ b/coderd/prometheusmetrics/insights/metricscollector_test.go @@ -0,0 +1,132 @@ +package insights_test + +import ( + "context" + "encoding/json" + "io" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agenttest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/prometheusmetrics/insights" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/provisioner/echo" + "github.com/coder/coder/v2/testutil" +) + +func TestCollect_TemplateInsights(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}) + db, ps := dbtestutil.NewDB(t) + + options := &coderdtest.Options{ + IncludeProvisionerDaemon: true, + AgentStatsRefreshInterval: time.Millisecond * 100, + Database: db, + Pubsub: ps, + } + client := coderdtest.New(t, options) + + // Given + // Initialize metrics collector + mc, err := insights.NewMetricsCollector(db, logger, 0, time.Second) + require.NoError(t, err) + + registry := prometheus.NewRegistry() + registry.Register(mc) + + // Create two users, one that will appear in the report and another that + // won't (due to not having/using a workspace). + user := coderdtest.CreateFirstUser(t, client) + _, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.PlanComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart]) + + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Start an agent so that we can generate stats. + _ = agenttest.New(t, client.URL, authToken) + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Run metrics collector + closeFunc, err := mc.Run(ctx) + require.NoError(t, err) + defer closeFunc() + + // Connect to the agent to generate usage/latency stats. + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{ + Logger: logger.Named("client"), + }) + require.NoError(t, err) + defer conn.Close() + + sshConn, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshConn.Close() + + sess, err := sshConn.NewSession() + require.NoError(t, err) + defer sess.Close() + + r, w := io.Pipe() + defer r.Close() + defer w.Close() + sess.Stdin = r + sess.Stdout = io.Discard + err = sess.Start("cat") + require.NoError(t, err) + + goldenFile, err := os.ReadFile("testdata/insights-metrics.json") + require.NoError(t, err) + golden := map[string]int{} + err = json.Unmarshal(goldenFile, &golden) + require.NoError(t, err) + + collected := map[string]int{} + assert.Eventuallyf(t, func() bool { + // When + metrics, err := registry.Gather() + require.NoError(t, err) + + // Then + for _, metric := range metrics { + switch metric.GetName() { + case "coderd_insights_templates_active_users": + for _, m := range metric.Metric { + collected[metric.GetName()] = int(m.Gauge.GetValue()) + } + default: + require.FailNowf(t, "unexpected metric collected", "metric: %s", metric.GetName()) + } + } + + return assert.ObjectsAreEqualValues(golden, collected) + }, testutil.WaitMedium, testutil.IntervalFast, "template insights are missing") + + // We got our latency metrics, close the connection. + _ = sess.Close() + _ = sshConn.Close() + + require.EqualValues(t, golden, collected) +} diff --git a/coderd/prometheusmetrics/insights/testdata/insights-metrics.json b/coderd/prometheusmetrics/insights/testdata/insights-metrics.json new file mode 100644 index 0000000000000..01c96a78b64a4 --- /dev/null +++ b/coderd/prometheusmetrics/insights/testdata/insights-metrics.json @@ -0,0 +1,3 @@ +{ + "coderd_insights_templates_active_users": 1 +} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 5afb85565c50b..38038df49dd90 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -912,12 +912,15 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto. s.Logger.Error(ctx, "marshal workspace resource info for failed job", slog.Error(err)) } + bag := audit.BaggageFromContext(ctx) + audit.WorkspaceBuildAudit(ctx, &audit.BuildAuditParams[database.WorkspaceBuild]{ Audit: *auditor, Log: s.Logger, UserID: job.InitiatorID, OrganizationID: workspace.OrganizationID, JobID: job.ID, + IP: bag.IP, Action: auditAction, Old: previousBuild, New: build, @@ -1259,12 +1262,15 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) s.Logger.Error(ctx, "marshal resource info for successful job", slog.Error(err)) } + bag := audit.BaggageFromContext(ctx) + audit.WorkspaceBuildAudit(ctx, &audit.BuildAuditParams[database.WorkspaceBuild]{ Audit: *auditor, Log: s.Logger, UserID: job.InitiatorID, OrganizationID: workspace.OrganizationID, JobID: job.ID, + IP: bag.IP, Action: auditAction, Old: previousBuild, New: workspaceBuild, diff --git a/coderd/telemetry/telemetry.go b/coderd/telemetry/telemetry.go index 6c2b3eb78b9ba..39f3b892c2150 100644 --- a/coderd/telemetry/telemetry.go +++ b/coderd/telemetry/telemetry.go @@ -698,6 +698,23 @@ func ConvertWorkspaceProxy(proxy database.WorkspaceProxy) WorkspaceProxy { } } +func ConvertExternalProvisioner(id uuid.UUID, tags map[string]string, provisioners []database.ProvisionerType) ExternalProvisioner { + tagsCopy := make(map[string]string, len(tags)) + for k, v := range tags { + tagsCopy[k] = v + } + strProvisioners := make([]string, 0, len(provisioners)) + for _, prov := range provisioners { + strProvisioners = append(strProvisioners, string(prov)) + } + return ExternalProvisioner{ + ID: id.String(), + Tags: tagsCopy, + Provisioners: strProvisioners, + StartedAt: time.Now(), + } +} + // Snapshot represents a point-in-time anonymized database dump. // Data is aggregated by latest on the server-side, so partial data // can be sent without issue. @@ -705,20 +722,21 @@ type Snapshot struct { DeploymentID string `json:"deployment_id"` APIKeys []APIKey `json:"api_keys"` - ProvisionerJobs []ProvisionerJob `json:"provisioner_jobs"` + CLIInvocations []clitelemetry.Invocation `json:"cli_invocations"` + ExternalProvisioners []ExternalProvisioner `json:"external_provisioners"` Licenses []License `json:"licenses"` - Templates []Template `json:"templates"` + ProvisionerJobs []ProvisionerJob `json:"provisioner_jobs"` TemplateVersions []TemplateVersion `json:"template_versions"` + Templates []Template `json:"templates"` Users []User `json:"users"` - Workspaces []Workspace `json:"workspaces"` - WorkspaceApps []WorkspaceApp `json:"workspace_apps"` - WorkspaceAgents []WorkspaceAgent `json:"workspace_agents"` WorkspaceAgentStats []WorkspaceAgentStat `json:"workspace_agent_stats"` + WorkspaceAgents []WorkspaceAgent `json:"workspace_agents"` + WorkspaceApps []WorkspaceApp `json:"workspace_apps"` WorkspaceBuilds []WorkspaceBuild `json:"workspace_build"` - WorkspaceResources []WorkspaceResource `json:"workspace_resources"` - WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"` WorkspaceProxies []WorkspaceProxy `json:"workspace_proxies"` - CLIInvocations []clitelemetry.Invocation `json:"cli_invocations"` + WorkspaceResourceMetadata []WorkspaceResourceMetadata `json:"workspace_resource_metadata"` + WorkspaceResources []WorkspaceResource `json:"workspace_resources"` + Workspaces []Workspace `json:"workspaces"` } // Deployment contains information about the host running Coder. @@ -900,6 +918,14 @@ type WorkspaceProxy struct { UpdatedAt time.Time `json:"updated_at"` } +type ExternalProvisioner struct { + ID string `json:"id"` + Tags map[string]string `json:"tags"` + Provisioners []string `json:"provisioners"` + StartedAt time.Time `json:"started_at"` + ShutdownAt *time.Time `json:"shutdown_at"` +} + type noopReporter struct{} func (*noopReporter) Report(_ *Snapshot) {} diff --git a/coderd/templates.go b/coderd/templates.go index 63fec1c63c9d6..c1f3bc97a01c3 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -347,6 +347,15 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return xerrors.Errorf("insert template: %s", err) } + if createTemplate.RequireActiveVersion { + err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, id, dbauthz.TemplateAccessControl{ + RequireActiveVersion: createTemplate.RequireActiveVersion, + }) + if err != nil { + return xerrors.Errorf("set template access control: %w", err) + } + } + dbTemplate, err = tx.GetTemplateByID(ctx, id) if err != nil { return xerrors.Errorf("get template by id: %s", err) @@ -614,7 +623,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.AutostopRequirement.Weeks == scheduleOpts.AutostopRequirement.Weeks && req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && req.TimeTilDormantMillis == time.Duration(template.TimeTilDormant).Milliseconds() && - req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() { + req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() && + req.RequireActiveVersion == template.RequireActiveVersion { return nil } @@ -638,6 +648,15 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("update template metadata: %w", err) } + if template.RequireActiveVersion != req.RequireActiveVersion { + err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, template.ID, dbauthz.TemplateAccessControl{ + RequireActiveVersion: req.RequireActiveVersion, + }) + if err != nil { + return xerrors.Errorf("set template access control: %w", err) + } + } + updated, err = tx.GetTemplateByID(ctx, template.ID) if err != nil { return xerrors.Errorf("fetch updated template metadata: %w", err) @@ -824,5 +843,6 @@ func (api *API) convertTemplate( AutostartRequirement: codersdk.TemplateAutostartRequirement{ DaysOfWeek: codersdk.BitmapToWeekdays(template.AutostartAllowedDays()), }, + RequireActiveVersion: template.RequireActiveVersion, } } diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 16326f9945fb2..5025d778d83ad 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -17,6 +17,7 @@ import ( "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" @@ -372,6 +373,7 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { func(action rbac.Action, object rbac.Objecter) bool { return api.Authorize(r, action, object) }, + audit.WorkspaceBuildBaggageFromRequest(r), ) var buildErr wsbuilder.BuildError if xerrors.As(err, &buildErr) { diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index c5c1d353d2b95..1f487e6915d40 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -12,6 +12,8 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" "golang.org/x/xerrors" "cdr.dev/slog" @@ -29,11 +31,22 @@ import ( func TestWorkspaceBuild(t *testing.T) { t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + otel.SetTextMapPropagator( + propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), + ) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + Auditor: auditor, + }) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + auditor.ResetLogs() workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -41,6 +54,8 @@ func TestWorkspaceBuild(t *testing.T) { _, err := client.WorkspaceBuild(ctx, workspace.LatestBuild.ID) require.NoError(t, err) + require.Len(t, auditor.AuditLogs(), 1) + require.Equal(t, auditor.AuditLogs()[0].Ip.IPNet.IP.String(), "127.0.0.1") } func TestWorkspaceBuildByBuildNumber(t *testing.T) { @@ -854,3 +869,185 @@ func TestWorkspaceBuildDebugMode(t *testing.T) { require.Equal(t, 2, logsProcessed) }) } + +func TestPostWorkspaceBuild(t *testing.T) { + t.Parallel() + t.Run("NoTemplateVersion", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: uuid.New(), + Transition: codersdk.WorkspaceTransitionStart, + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("TemplateVersionFailedImport", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + ProvisionApply: []*proto.Response{{}}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "workspace", + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + }) + + t.Run("AlreadyActive", func(t *testing.T) { + t.Parallel() + client, closer := coderdtest.NewWithProvisionerCloser(t, nil) + defer closer.Close() + + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + closer.Close() + // Close here so workspace build doesn't process! + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: template.ActiveVersionID, + Transition: codersdk.WorkspaceTransitionStart, + }) + require.Error(t, err) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode()) + }) + + t.Run("Audit", func(t *testing.T) { + t.Parallel() + + otel.SetTextMapPropagator( + propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), + ) + auditor := audit.NewMock() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + auditor.ResetLogs() + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: template.ActiveVersionID, + Transition: codersdk.WorkspaceTransitionStart, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + require.Len(t, auditor.AuditLogs(), 1) + require.Equal(t, auditor.AuditLogs()[0].Ip.IPNet.IP.String(), "127.0.0.1") + }) + + t.Run("IncrementBuildNumber", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: template.ActiveVersionID, + Transition: codersdk.WorkspaceTransitionStart, + }) + require.NoError(t, err) + require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) + }) + + t.Run("WithState", func(t *testing.T) { + t.Parallel() + client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + wantState := []byte("something") + _ = closeDaemon.Close() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: template.ActiveVersionID, + Transition: codersdk.WorkspaceTransitionStart, + ProvisionerState: wantState, + }) + require.NoError(t, err) + gotState, err := client.WorkspaceBuildState(ctx, build.ID) + require.NoError(t, err) + require.Equal(t, wantState, gotState) + }) + + t.Run("Delete", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionDelete, + }) + require.NoError(t, err) + require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + Owner: user.UserID.String(), + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 0) + }) +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 56cfc8e2436b4..bea87fb2f427a 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -512,7 +512,9 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req db, func(action rbac.Action, object rbac.Objecter) bool { return api.Authorize(r, action, object) - }) + }, + audit.WorkspaceBuildBaggageFromRequest(r), + ) return err }, nil) var bldErr wsbuilder.BuildError @@ -1336,6 +1338,7 @@ func convertWorkspace( TemplateDisplayName: template.DisplayName, TemplateAllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, TemplateActiveVersionID: template.ActiveVersionID, + TemplateRequireActiveVersion: template.RequireActiveVersion, Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(), Name: workspace.Name, AutostartSchedule: autostartSchedule, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 74a91c658ed7a..a12c262cb14f5 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1563,155 +1563,6 @@ func TestOffsetLimit(t *testing.T) { require.Len(t, ws.Workspaces, 0) } -func TestPostWorkspaceBuild(t *testing.T) { - t.Parallel() - t.Run("NoTemplateVersion", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - _, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: uuid.New(), - Transition: codersdk.WorkspaceTransitionStart, - }) - require.Error(t, err) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) - }) - - t.Run("TemplateVersionFailedImport", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ - ProvisionApply: []*proto.Response{{}}, - }) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - _, err := client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ - TemplateID: template.ID, - Name: "workspace", - }) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) - }) - - t.Run("AlreadyActive", func(t *testing.T) { - t.Parallel() - client, closer := coderdtest.NewWithProvisionerCloser(t, nil) - defer closer.Close() - - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - closer.Close() - // Close here so workspace build doesn't process! - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - _, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: template.ActiveVersionID, - Transition: codersdk.WorkspaceTransitionStart, - }) - require.Error(t, err) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusConflict, apiErr.StatusCode()) - }) - - t.Run("IncrementBuildNumber", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: template.ActiveVersionID, - Transition: codersdk.WorkspaceTransitionStart, - }) - require.NoError(t, err) - require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) - }) - - t.Run("WithState", func(t *testing.T) { - t.Parallel() - client, closeDaemon := coderdtest.NewWithProvisionerCloser(t, &coderdtest.Options{ - IncludeProvisionerDaemon: true, - }) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - wantState := []byte("something") - _ = closeDaemon.Close() - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - TemplateVersionID: template.ActiveVersionID, - Transition: codersdk.WorkspaceTransitionStart, - ProvisionerState: wantState, - }) - require.NoError(t, err) - gotState, err := client.WorkspaceBuildState(ctx, build.ID) - require.NoError(t, err) - require.Equal(t, wantState, gotState) - }) - - t.Run("Delete", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) - workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ - Transition: codersdk.WorkspaceTransitionDelete, - }) - require.NoError(t, err) - require.Equal(t, workspace.LatestBuild.BuildNumber+1, build.BuildNumber) - coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) - - res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ - Owner: user.UserID.String(), - }) - require.NoError(t, err) - require.Len(t, res.Workspaces, 0) - }) -} - func TestWorkspaceUpdateAutostart(t *testing.T) { t.Parallel() dublinLoc := mustLocation(t, "Europe/Dublin") diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 008bc88ab72ab..80d5cca80f313 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -11,10 +11,10 @@ import ( "time" "github.com/google/uuid" - "github.com/lib/pq" "github.com/sqlc-dev/pqtype" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -201,38 +201,32 @@ func (b *Builder) Build( ctx context.Context, store database.Store, authFunc func(action rbac.Action, object rbac.Objecter) bool, + auditBaggage audit.WorkspaceBuildBaggage, ) ( *database.WorkspaceBuild, *database.ProvisionerJob, error, ) { - b.ctx = ctx + var err error + b.ctx, err = audit.BaggageToContext(ctx, auditBaggage) + if err != nil { + return nil, nil, xerrors.Errorf("create audit baggage: %w", err) + } // Run the build in a transaction with RepeatableRead isolation, and retries. // RepeatableRead isolation ensures that we get a consistent view of the database while // computing the new build. This simplifies the logic so that we do not need to worry if // later reads are consistent with earlier ones. - var err error - for retries := 0; retries < 5; retries++ { - var workspaceBuild *database.WorkspaceBuild - var provisionerJob *database.ProvisionerJob - err := store.InTx(func(store database.Store) error { - b.store = store - workspaceBuild, provisionerJob, err = b.buildTx(authFunc) - return err - }, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}) - var pqe *pq.Error - if xerrors.As(err, &pqe) { - if pqe.Code == "40001" { - // serialization error, retry - continue - } - } - if err != nil { - // Other (hard) error - return nil, nil, err - } - return workspaceBuild, provisionerJob, nil + var workspaceBuild *database.WorkspaceBuild + var provisionerJob *database.ProvisionerJob + err = database.ReadModifyUpdate(store, func(tx database.Store) error { + var err error + b.store = tx + workspaceBuild, provisionerJob, err = b.buildTx(authFunc) + return err + }) + if err != nil { + return nil, nil, xerrors.Errorf("build tx: %w", err) } - return nil, nil, xerrors.Errorf("too many errors; last error: %w", err) + return workspaceBuild, provisionerJob, nil } // buildTx contains the business logic of computing a new build. Attributes of the new database objects are computed @@ -355,7 +349,11 @@ func (b *Builder) buildTx(authFunc func(action rbac.Action, object rbac.Objecter MaxDeadline: time.Time{}, // set by provisioner upon completion }) if err != nil { - return BuildError{http.StatusInternalServerError, "insert workspace build", err} + code := http.StatusInternalServerError + if rbac.IsUnauthorizedError(err) { + code = http.StatusUnauthorized + } + return BuildError{code, "insert workspace build", err} } names, values, err := b.getParameters() diff --git a/coderd/wsbuilder/wsbuilder_test.go b/coderd/wsbuilder/wsbuilder_test.go index 224a40cfb82d8..c55d440ac2c28 100644 --- a/coderd/wsbuilder/wsbuilder_test.go +++ b/coderd/wsbuilder/wsbuilder_test.go @@ -12,7 +12,10 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmock" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -88,7 +91,7 @@ func TestBuilder_NoOptions(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -123,7 +126,48 @@ func TestBuilder_Initiator(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) + req.NoError(err) +} + +func TestBuilder_Baggage(t *testing.T) { + t.Parallel() + req := require.New(t) + asrt := assert.New(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + otel.SetTextMapPropagator( + propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ), + ) + + mDB := expectDB(t, + // Inputs + withTemplate, + withInactiveVersion(nil), + withLastBuildFound, + withRichParameters(nil), + withParameterSchemas(inactiveJobID, nil), + + // Outputs + expectProvisionerJob(func(job database.InsertProvisionerJobParams) { + asrt.Contains(string(job.TraceMetadata.RawMessage), "ip=127.0.0.1") + }), + withInTx, + expectBuild(func(bld database.InsertWorkspaceBuildParams) { + }), + expectBuildParameters(func(params database.InsertWorkspaceBuildParametersParams) { + }), + withBuild, + ) + + ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} + uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Initiator(otherUserID) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{IP: "127.0.0.1"}) req.NoError(err) } @@ -157,7 +201,7 @@ func TestBuilder_Reason(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).Reason(database.BuildReasonAutostart) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -196,7 +240,7 @@ func TestBuilder_ActiveVersion(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).ActiveVersion() - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) } @@ -274,7 +318,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) t.Run("UsePreviousParameterValues", func(t *testing.T) { @@ -317,7 +361,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -357,7 +401,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -394,7 +438,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { ws := database.Workspace{ID: workspaceID, TemplateID: templateID, OwnerID: userID} uut := wsbuilder.New(ws, database.WorkspaceTransitionStart).RichParameterValues(nextBuildParameters) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) bldErr := wsbuilder.BuildError{} req.ErrorAs(err, &bldErr) asrt.Equal(http.StatusBadRequest, bldErr.Status) @@ -456,7 +500,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -516,7 +560,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) @@ -574,7 +618,7 @@ func TestWorkspaceBuildWithRichParameters(t *testing.T) { uut := wsbuilder.New(ws, database.WorkspaceTransitionStart). RichParameterValues(nextBuildParameters). VersionID(activeVersionID) - _, _, err := uut.Build(ctx, mDB, nil) + _, _, err := uut.Build(ctx, mDB, nil, audit.WorkspaceBuildBaggage{}) req.NoError(err) }) } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 4622808853aa5..c53ba8d055194 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -50,6 +50,7 @@ const ( FeatureExternalTokenEncryption FeatureName = "external_token_encryption" FeatureTemplateAutostopRequirement FeatureName = "template_autostop_requirement" FeatureWorkspaceBatchActions FeatureName = "workspace_batch_actions" + FeatureAccessControl FeatureName = "access_control" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -70,6 +71,7 @@ var FeatureNames = []FeatureName{ FeatureExternalTokenEncryption, FeatureTemplateAutostopRequirement, FeatureWorkspaceBatchActions, + FeatureAccessControl, } // Humanize returns the feature name in a human-readable format. @@ -1971,6 +1973,9 @@ const ( // feature is not yet complete in functionality. ExperimentMoons Experiment = "moons" + // https://github.com/coder/coder/milestone/19 + ExperimentWorkspaceActions Experiment = "workspace_actions" + // ExperimentTailnetPGCoordinator enables the PGCoord in favor of the pubsub- // only Coordinator ExperimentTailnetPGCoordinator Experiment = "tailnet_pg_coordinator" @@ -2000,6 +2005,7 @@ const ( // ExperimentDashboardTheme mutates the dashboard to use a new, dark color scheme. ExperimentDashboardTheme Experiment = "dashboard_theme" + ExperimentTemplateUpdatePolicies Experiment = "template_update_policies" // Add new experiments here! // ExperimentExample Experiment = "example" ) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 6c10bc2c91abe..cc206180f81ae 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -124,6 +124,10 @@ type CreateTemplateRequest struct { // and must be explicitly granted to users or groups in the permissions settings // of the template. DisableEveryoneGroupAccess bool `json:"disable_everyone_group_access"` + + // RequireActiveVersion mandates that workspaces are built with the active + // template version. + RequireActiveVersion bool `json:"require_active_version"` } // CreateWorkspaceRequest provides options for creating a new workspace. diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index ce2dd08758b8c..0b321e51662ab 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -174,6 +174,8 @@ func (c *Client) provisionerJobLogsAfter(ctx context.Context, path string, after // ServeProvisionerDaemonRequest are the parameters to call ServeProvisionerDaemon with // @typescript-ignore ServeProvisionerDaemonRequest type ServeProvisionerDaemonRequest struct { + // ID is a unique ID for a provisioner daemon. + ID uuid.UUID `json:"id" format:"uuid"` // Organization is the organization for the URL. At present provisioner daemons ARE NOT scoped to organizations // and so the organization ID is optional. Organization uuid.UUID `json:"organization" format:"uuid"` @@ -194,6 +196,7 @@ func (c *Client) ServeProvisionerDaemon(ctx context.Context, req ServeProvisione return nil, xerrors.Errorf("parse url: %w", err) } query := serverURL.Query() + query.Add("id", req.ID.String()) for _, provisioner := range req.Provisioners { query.Add("provisioner", string(provisioner)) } diff --git a/codersdk/templates.go b/codersdk/templates.go index d0ee400a29b20..3a3240ca711b2 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -52,6 +52,10 @@ type Template struct { FailureTTLMillis int64 `json:"failure_ttl_ms"` TimeTilDormantMillis int64 `json:"time_til_dormant_ms"` TimeTilDormantAutoDeleteMillis int64 `json:"time_til_dormant_autodelete_ms"` + + // RequireActiveVersion mandates that workspaces are built with the active + // template version. + RequireActiveVersion bool `json:"require_active_version"` } // WeekdaysToBitmap converts a list of weekdays to a bitmap in accordance with @@ -221,6 +225,10 @@ type UpdateTemplateMeta struct { // from the template. This is useful for preventing dormant workspaces being immediately // deleted when updating the dormant_ttl field to a new, shorter value. UpdateWorkspaceDormantAt bool `json:"update_workspace_dormant_at"` + // RequireActiveVersion mandates workspaces built using this template + // use the active version of the template. This option has no + // effect on template admins. + RequireActiveVersion bool `json:"require_active_version"` } type TemplateExample struct { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index ef7640417a5ca..54f79aa58725d 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -36,6 +36,7 @@ type Workspace struct { TemplateIcon string `json:"template_icon"` TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"` TemplateActiveVersionID uuid.UUID `json:"template_active_version_id" format:"uuid"` + TemplateRequireActiveVersion bool `json:"template_require_active_version"` LatestBuild WorkspaceBuild `json:"latest_build"` Outdated bool `json:"outdated"` Name string `json:"name"` diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index c7da38bec58d1..af7a5724458d7 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -8,19 +8,19 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/admin/external-auth.md b/docs/admin/external-auth.md index 5b005cfe6e28c..73852a052201a 100644 --- a/docs/admin/external-auth.md +++ b/docs/admin/external-auth.md @@ -158,8 +158,8 @@ The following example will require users authenticate via GitHub and auto-clone a repo into the `~/coder` directory. ```hcl -data "coder_git_auth" "github" { - # Matches the ID of the git auth provider in Coder. +data "coder_external_auth" "github" { + # Matches the ID of the external auth provider in Coder. id = "github" } @@ -168,7 +168,7 @@ resource "coder_agent" "dev" { arch = "amd64" dir = "~/coder" env = { - GITHUB_TOKEN : data.coder_git_auth.github.access_token + GITHUB_TOKEN : data.coder_external_auth.github.access_token } startup_script = < +TL;DR: disable telemetry by setting CODER_TELEMETRY=false. + -## What we collect +Coder collects telemetry from all installations by default. We believe our users +should have the right to know what we collect, why we collect it, and how we use +the data. -First of all, we do not collect any information that could threaten the security -of your installation. For example, we do not collect parameters, environment -variables, or passwords. +## What we collect -You can find a full list of the data we collect in the source code +You can find a full list of the data we collect in our source code [here](https://github.com/coder/coder/blob/main/coderd/telemetry/telemetry.go). +In particular, look at the struct types such as `Template` or `Workspace`. + +As a rule, we **do not collect** the following types of information: + +- Any data that could make your installation less secure +- Any data that could identify individual users -Telemetry can be configured with the `CODER_TELEMETRY=x` environment variable. +For example, we do not collect parameters, environment variables, or user email +addresses. -For example, telemetry can be disabled with `CODER_TELEMETRY=false`. +## Why we collect -`CODER_TELEMETRY=true` is our default level. It includes user email and IP -addresses. This information is used in aggregate to understand where our users -are and general demographic information. We may reach out to the deployment -admin, but will never use these emails for outbound marketing. +Telemetry helps us understand which features are most valuable, what use cases +to focus on, and which bugs to fix first. -`CODER_TELEMETRY=false` disables telemetry altogether. +Most cloud-based software products collect far more data than we do. They often +offer little transparency and configurability. It's hard to imagine our favorite +SaaS products existing without their creators having a detailed understanding of +user interactions. We want to wield some of that product development power to +build self-hosted, open-source software. -## How we use telemetry +## Security -We use telemetry to build product better and faster. Without telemetry, we don't -know which features are most useful, we don't know where users are dropping off -in our funnel, and we don't know if our roadmap is aligned with the demographics -that really use Coder. +In the event we discover a critical security issue with Coder, we will use +telemetry to identify affected installations and notify their administrators. -Typical SaaS companies collect far more than what we do with little transparency -and configurability. It's hard to imagine our favorite products today existing -without their backers having good intelligence. +## Toggling -We've decided the only way we can make our product open-source _and_ build at a -fast pace is by collecting usage data as well. +You can turn telemetry on or off using either the `CODER_TELEMETRY=[true|false]` +environment variable or the `--telemetry=[true|false]` command-line flag. diff --git a/docs/api/schemas.md b/docs/api/schemas.md index cd09260f59546..61e227b2b3f4d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1584,6 +1584,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "icon": "string", "max_ttl_ms": 0, "name": "string", + "require_active_version": true, "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" } ``` @@ -1607,6 +1608,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | | `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once autostop_requirement is matured | | `name` | string | true | | Name is the name of the template. | +| `require_active_version` | boolean | false | | Require active version mandates that workspaces are built with the active template version. | | `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. | | This is required on creation to enable a user-flow of validating a template works. There is no reason the data-model cannot support empty templates, but it doesn't make sense for users. | @@ -2843,11 +2845,13 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Value | | ------------------------------- | | `moons` | +| `workspace_actions` | | `tailnet_pg_coordinator` | | `single_tailnet` | | `template_autostop_requirement` | | `deployment_health_page` | | `dashboard_theme` | +| `template_update_policies` | ## codersdk.ExternalAuth @@ -4370,6 +4374,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -4401,6 +4406,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `name` | string | false | | | | `organization_id` | string | false | | | | `provisioner` | string | false | | | +| `require_active_version` | boolean | false | | Require active version mandates that workspaces are built with the active template version. | | `time_til_dormant_autodelete_ms` | integer | false | | | | `time_til_dormant_ms` | integer | false | | | | `updated_at` | string | false | | | @@ -5761,6 +5767,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -5790,6 +5797,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `template_icon` | string | false | | | | `template_id` | string | false | | | | `template_name` | string | false | | | +| `template_require_active_version` | boolean | false | | | | `ttl_ms` | integer | false | | | | `updated_at` | string | false | | | @@ -7009,6 +7017,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } diff --git a/docs/api/templates.md b/docs/api/templates.md index a6cc2d4b5a367..08de540e2a7f2 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -61,6 +61,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -109,6 +110,7 @@ Status Code **200** | `» name` | string | false | | | | `» organization_id` | string(uuid) | false | | | | `» provisioner` | string | false | | | +| `» require_active_version` | boolean | false | | Require active version mandates that workspaces are built with the active template version. | | `» time_til_dormant_autodelete_ms` | integer | false | | | | `» time_til_dormant_ms` | integer | false | | | | `» updated_at` | string(date-time) | false | | | @@ -159,6 +161,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "icon": "string", "max_ttl_ms": 0, "name": "string", + "require_active_version": true, "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" } ``` @@ -211,6 +214,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -346,6 +350,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -657,6 +662,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" @@ -775,6 +781,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "require_active_version": true, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z" diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index a7dafb266043b..3ade42b54e9c9 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -215,6 +215,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -423,6 +424,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -630,6 +632,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -839,6 +842,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -1163,6 +1167,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } diff --git a/docs/cli/templates_create.md b/docs/cli/templates_create.md index 2811e4a1ce021..83c3b3c5b9aff 100644 --- a/docs/cli/templates_create.md +++ b/docs/cli/templates_create.md @@ -89,6 +89,15 @@ Disable the default behavior of granting template access to the 'everyone' group Specify a set of tags to target provisioner daemons. +### --require-active-version + +| | | +| ------- | ------------------ | +| Type | bool | +| Default | false | + +Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature. + ### --var | | | diff --git a/docs/cli/templates_edit.md b/docs/cli/templates_edit.md index b58d1f61fc806..cd65ac99ef9d0 100644 --- a/docs/cli/templates_edit.md +++ b/docs/cli/templates_edit.md @@ -113,6 +113,15 @@ Edit the template maximum time before shutdown - workspaces created from this te Edit the template name. +### --require-active-version + +| | | +| ------- | ------------------ | +| Type | bool | +| Default | false | + +Requires workspace builds to use the active template version. This setting does not apply to template admins. This is an enterprise-only feature. + ### -y, --yes | | | diff --git a/docs/ides/web-ides.md b/docs/ides/web-ides.md index ca5463e5b91b6..aa466e54c432e 100644 --- a/docs/ides/web-ides.md +++ b/docs/ides/web-ides.md @@ -45,7 +45,7 @@ resource "coder_app" "pubslack" { display_name = "Coder Public Slack" slug = "pubslack" url = "https://coder-com.slack.com/" - icon = "https://cdn2.hubspot.net/hubfs/521324/slack-logo.png" + icon = "/icon/slack.svg" external = true } @@ -54,7 +54,7 @@ resource "coder_app" "discord" { display_name = "Coder Discord" slug = "discord" url = "https://discord.com/invite/coder" - icon = "https://logodix.com/logo/573024.png" + icon = "/icon/discord.svg" external = true } ``` @@ -133,43 +133,59 @@ resource "coder_app" "code-server" { ![code-server in a workspace](../images/code-server-ide.png) -## VS Code Server +## VS Code Web VS Code supports launching a local web client using the `code serve-web` -command. To add VS Code web as a web IDE, Install and start this in your -`startup_script` and create a corresponding `coder_app` - -```hcl -resource "coder_agent" "main" { - arch = "amd64" - os = "linux" - startup_script = </tmp/vscode-web.log 2>&1 & - EOF -} -``` - -> `code serve-web` was introduced in version 1.82.0 (August 2023). - -You also need to add a `coder_app` resource for this. - -```hcl -# VS Code Web -resource "coder_app" "vscode-web" { - agent_id = coder_agent.coder.id - slug = "vscode-web" - display_name = "VS Code Web" - icon = "/icon/code.svg" - url = "http://localhost:13338?folder=/home/coder" - subdomain = true # VS Code Web does currently does not work with a subpath https://github.com/microsoft/vscode/issues/192947 - share = "owner" -} -``` +command. To add VS Code web as a web IDE, you have two options. + +1. Install using the + [vscode-web module](https://registry.coder.com/modules/vscode-web) from the + coder registry. + + ```hcl + module "vscode-web" { + source = "https://registry.coder.com/modules/vscode-web" + agent_id = coder_agent.main.id + accept_license = true + } + ``` + +2. Install and start in your `startup_script` and create a corresponding + `coder_app` + + ```hcl + resource "coder_agent" "main" { + arch = "amd64" + os = "linux" + startup_script = </tmp/vscode-web.log 2>&1 & + EOF + } + ``` + + > `code serve-web` was introduced in version 1.82.0 (August 2023). + + You also need to add a `coder_app` resource for this. + + ```hcl + # VS Code Web + resource "coder_app" "vscode-web" { + agent_id = coder_agent.coder.id + slug = "vscode-web" + display_name = "VS Code Web" + icon = "/icon/code.svg" + url = "http://localhost:13338?folder=/home/coder" + subdomain = true # VS Code Web does currently does not work with a subpath https://github.com/microsoft/vscode/issues/192947 + share = "owner" + } + ``` ## JupyterLab diff --git a/docs/platforms/kubernetes/additional-clusters.md b/docs/platforms/kubernetes/additional-clusters.md index f7646f5b5c3e6..b8a25e5cc6cd7 100644 --- a/docs/platforms/kubernetes/additional-clusters.md +++ b/docs/platforms/kubernetes/additional-clusters.md @@ -109,7 +109,8 @@ kubectl apply -n coder-workspaces -f - < 0 diff --git a/enterprise/coderd/dbauthz/accesscontrol.go b/enterprise/coderd/dbauthz/accesscontrol.go new file mode 100644 index 0000000000000..454f416ab8736 --- /dev/null +++ b/enterprise/coderd/dbauthz/accesscontrol.go @@ -0,0 +1,30 @@ +package dbauthz + +import ( + "context" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" + agpldbz "github.com/coder/coder/v2/coderd/database/dbauthz" +) + +type EnterpriseTemplateAccessControlStore struct{} + +func (EnterpriseTemplateAccessControlStore) GetTemplateAccessControl(t database.Template) agpldbz.TemplateAccessControl { + return agpldbz.TemplateAccessControl{ + RequireActiveVersion: t.RequireActiveVersion, + } +} + +func (EnterpriseTemplateAccessControlStore) SetTemplateAccessControl(ctx context.Context, store database.Store, id uuid.UUID, opts agpldbz.TemplateAccessControl) error { + err := store.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{ + ID: id, + RequireActiveVersion: opts.RequireActiveVersion, + }) + if err != nil { + return xerrors.Errorf("update template access control: %w", err) + } + return nil +} diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 2f16aa7884934..bfbfffa5eecfb 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -648,7 +648,7 @@ func TestGroup(t *testing.T) { require.NotContains(t, group.Members, user1) }) - t.Run("FilterSuspendedUsers", func(t *testing.T) { + t.Run("IncludeSuspendedAndDormantUsers", func(t *testing.T) { t.Parallel() client, user := coderdenttest.New(t, &coderdenttest.Options{LicenseOptions: &coderdenttest.LicenseOptions{ @@ -679,8 +679,30 @@ func TestGroup(t *testing.T) { group, err = client.Group(ctx, group.ID) require.NoError(t, err) - require.Len(t, group.Members, 1) - require.NotContains(t, group.Members, user1) + require.Len(t, group.Members, 2) + require.Contains(t, group.Members, user1) + require.Contains(t, group.Members, user2) + + // cannot explicitly set a dormant user status so must create a new user + anotherUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{ + Email: "coder@coder.com", + Username: "coder", + Password: "SomeStrongPassword!", + OrganizationID: user.OrganizationID, + }) + require.NoError(t, err) + + // Ensure that new user has dormant account + require.Equal(t, codersdk.UserStatusDormant, anotherUser.Status) + + group, _ = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{anotherUser.ID.String()}, + }) + + group, err = client.Group(ctx, group.ID) + require.NoError(t, err) + require.Len(t, group.Members, 3) + require.Contains(t, group.Members, user1) require.Contains(t, group.Members, user2) }) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 70f59f40308f0..e10489f45a6c7 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -10,6 +10,7 @@ import ( "net" "net/http" "strings" + "time" "github.com/google/uuid" "github.com/hashicorp/yamux" @@ -27,6 +28,8 @@ import ( "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/telemetry" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionerd/proto" ) @@ -155,6 +158,11 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) return } + id, _ := uuid.Parse(r.URL.Query().Get("id")) + if id == uuid.Nil { + id = uuid.New() + } + provisionersMap := map[codersdk.ProvisionerType]struct{}{} for _, provisioner := range r.URL.Query()["provisioner"] { switch provisioner { @@ -210,6 +218,13 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) api.AGPL.WebsocketWaitMutex.Unlock() defer api.AGPL.WebsocketWaitGroup.Done() + tep := telemetry.ConvertExternalProvisioner(id, tags, provisioners) + api.Telemetry.Report(&telemetry.Snapshot{ExternalProvisioners: []telemetry.ExternalProvisioner{tep}}) + defer func() { + tep.ShutdownAt = ptr.Ref(time.Now()) + api.Telemetry.Report(&telemetry.Snapshot{ExternalProvisioners: []telemetry.ExternalProvisioner{tep}}) + }() + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ // Need to disable compression to avoid a data-race. CompressionMode: websocket.CompressionDisabled, @@ -245,7 +260,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) srv, err := provisionerdserver.NewServer( api.ctx, api.AccessURL, - uuid.New(), + id, logger, provisioners, tags, diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 50bd6ecf0798b..6b51e706e2d81 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/enterprise/coderd/schedule" "github.com/coder/coder/v2/provisioner/echo" "github.com/coder/coder/v2/testutil" ) @@ -571,6 +572,51 @@ func TestTemplates(t *testing.T) { require.Equal(t, updatedDormantWS.DormantAt, dormantWorkspace.DormantAt) require.True(t, updatedDormantWS.LastUsedAt.After(dormantWorkspace.LastUsedAt)) }) + + t.Run("RequireActiveVersion", func(t *testing.T) { + t.Parallel() + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAccessControl: 1, + }, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.RequireActiveVersion = true + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + require.True(t, template.RequireActiveVersion) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Update the field and assert it persists. + updatedTemplate, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + RequireActiveVersion: false, + }) + require.NoError(t, err) + require.False(t, updatedTemplate.RequireActiveVersion) + + // Flip it back to ensure we aren't hardcoding to a default value. + updatedTemplate, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + RequireActiveVersion: true, + }) + require.NoError(t, err) + require.True(t, updatedTemplate.RequireActiveVersion) + + // Assert that fetching a template is no different from the response + // when updating. + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Equal(t, updatedTemplate, template) + }) } func TestTemplateACL(t *testing.T) { diff --git a/enterprise/coderd/workspacebuilds_test.go b/enterprise/coderd/workspacebuilds_test.go new file mode 100644 index 0000000000000..615991a65dec9 --- /dev/null +++ b/enterprise/coderd/workspacebuilds_test.go @@ -0,0 +1,139 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" + "github.com/coder/coder/v2/enterprise/coderd/license" + "github.com/coder/coder/v2/testutil" +) + +func TestWorkspaceBuild(t *testing.T) { + t.Parallel() + t.Run("TemplateRequiresActiveVersion", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAccessControl: 1, + codersdk.FeatureTemplateRBAC: 1, + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + + // Create an initial version. + oldVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil) + // Create a template that mandates the promoted version. + // This should be enforced for everyone except template admins. + template := coderdtest.CreateTemplate(t, ownerClient, owner.OrganizationID, oldVersion.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, oldVersion.ID) + require.Equal(t, oldVersion.ID, template.ActiveVersionID) + template, err := ownerClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + RequireActiveVersion: true, + }) + require.NoError(t, err) + require.True(t, template.RequireActiveVersion) + + // Create a new version that we will promote. + activeVersion := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, activeVersion.ID) + err = ownerClient.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: activeVersion.ID, + }) + require.NoError(t, err) + + templateAdminClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleTemplateAdmin()) + templateACLAdminClient, templateACLAdmin := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + templateGroupACLAdminClient, templateGroupACLAdmin := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID) + + // Create a group so we can also test group template admin ownership. + group, err := ownerClient.CreateGroup(ctx, owner.OrganizationID, codersdk.CreateGroupRequest{ + Name: "test", + }) + require.NoError(t, err) + + // Add the user who gains template admin via group membership. + group, err = ownerClient.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{templateGroupACLAdmin.ID.String()}, + }) + require.NoError(t, err) + + // Update the template for both users and groups. + err = ownerClient.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + templateACLAdmin.ID.String(): codersdk.TemplateRoleAdmin, + }, + GroupPerms: map[string]codersdk.TemplateRole{ + group.ID.String(): codersdk.TemplateRoleAdmin, + }, + }) + require.NoError(t, err) + + type testcase struct { + Name string + Client *codersdk.Client + ExpectedStatusCode int + } + + cases := []testcase{ + { + Name: "OwnerOK", + Client: ownerClient, + ExpectedStatusCode: http.StatusOK, + }, + { + Name: "TemplateAdminOK", + Client: templateAdminClient, + ExpectedStatusCode: http.StatusOK, + }, + { + Name: "TemplateACLAdminOK", + Client: templateACLAdminClient, + ExpectedStatusCode: http.StatusOK, + }, + { + Name: "TemplateGroupACLAdminOK", + Client: templateGroupACLAdminClient, + ExpectedStatusCode: http.StatusOK, + }, + { + Name: "MemberFails", + Client: memberClient, + ExpectedStatusCode: http.StatusUnauthorized, + }, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + _, err = c.Client.CreateWorkspace(ctx, owner.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateVersionID: oldVersion.ID, + Name: "abc123", + AutomaticUpdates: codersdk.AutomaticUpdatesNever, + }) + if c.ExpectedStatusCode == http.StatusOK { + require.NoError(t, err) + } else { + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, c.ExpectedStatusCode, cerr.StatusCode()) + } + }) + } + }) +} diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index e38d1874e764d..a80107d379e74 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -734,6 +734,96 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Len(t, stats.Transitions, 1) require.Equal(t, database.WorkspaceTransitionDelete, stats.Transitions[ws.ID]) }) + + t.Run("RequireActiveVersion", func(t *testing.T) { + t.Parallel() + + var ( + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + ctx = testutil.Context(t, testutil.WaitMedium) + ) + + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(agplUserQuietHoursScheduleStore()), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAccessControl: 1}, + }, + }) + + sched, err := cron.Weekly("CRON_TZ=UTC 0 * * * *") + require.NoError(t, err) + + // Create a template version1 that passes to get a functioning workspace. + version1 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID) + + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version1.ID) + require.Equal(t, version1.ID, template.ActiveVersionID) + + ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.AutostartSchedule = ptr.Ref(sched.String()) + }) + + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, ws.LatestBuild.ID) + ws = coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + // Create a new version so that we can assert we don't update + // to the latest by default. + version2 := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID) + + // Make sure to promote it. + err = client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: version2.ID, + }) + require.NoError(t, err) + + // Kick of an autostart build. + tickCh <- sched.Next(ws.LatestBuild.CreatedAt) + stats := <-statsCh + require.NoError(t, stats.Error) + require.Len(t, stats.Transitions, 1) + require.Contains(t, stats.Transitions, ws.ID) + require.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[ws.ID]) + + // Validate that we didn't update to the promoted version. + started := coderdtest.MustWorkspace(t, client, ws.ID) + firstBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, started.LatestBuild.ID) + require.Equal(t, version1.ID, firstBuild.TemplateVersionID) + + // Update the template to require the promoted version. + _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + RequireActiveVersion: true, + AllowUserAutostart: true, + }) + require.NoError(t, err) + + // Reset the workspace to the stopped state so we can try + // to autostart again. + coderdtest.MustTransitionWorkspace(t, client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) { + req.TemplateVersionID = ws.LatestBuild.TemplateVersionID + }) + + // Force an autostart transition again. + tickCh <- sched.Next(firstBuild.CreatedAt) + stats = <-statsCh + require.NoError(t, stats.Error) + require.Len(t, stats.Transitions, 1) + require.Contains(t, stats.Transitions, ws.ID) + require.Equal(t, database.WorkspaceTransitionStart, stats.Transitions[ws.ID]) + + // Validate that we are using the promoted version. + ws = coderdtest.MustWorkspace(t, client, ws.ID) + require.Equal(t, version2.ID, ws.LatestBuild.TemplateVersionID) + }) } // Blocked by autostart requirements diff --git a/go.mod b/go.mod index ab60c12f53789..73e8179ff3b66 100644 --- a/go.mod +++ b/go.mod @@ -201,7 +201,7 @@ require ( ) require ( - github.com/aws/smithy-go v1.14.2 + github.com/aws/smithy-go v1.15.0 github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 github.com/chromedp/chromedp v0.9.2 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 diff --git a/go.sum b/go.sum index c128700886900..8fe52143c7a00 100644 --- a/go.sum +++ b/go.sum @@ -155,8 +155,9 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.15.1/go.mod h1:XO/VcyoQ8nKyKfFW/ github.com/aws/aws-sdk-go-v2/service/sts v1.21.1 h1:pAOJj+80tC8sPVgSDHzMYD6KLWsaLQ1kZw31PTeORbs= github.com/aws/aws-sdk-go-v2/service/sts v1.21.1/go.mod h1:G8SbvL0rFk4WOJroU8tKBczhsbhj2p/YY7qeJezJ3CI= github.com/aws/smithy-go v1.14.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= -github.com/aws/smithy-go v1.14.2 h1:MJU9hqBGbvWZdApzpvoF2WAIJDbtjK2NDJSiJP7HblQ= github.com/aws/smithy-go v1.14.2/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/aws/smithy-go v1.15.0 h1:PS/durmlzvAFpQHDs4wi4sNNP9ExsqZh6IlfdHXgKK8= +github.com/aws/smithy-go v1.15.0/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= diff --git a/site/.eslintrc.yaml b/site/.eslintrc.yaml index ac7c6936ff5a6..018bf9043d72e 100644 --- a/site/.eslintrc.yaml +++ b/site/.eslintrc.yaml @@ -91,6 +91,9 @@ rules: - allowSingleExtends: true "brace-style": "off" "curly": ["error", "all"] + "eslint-comments/disable-enable-pair": + - error + - allowWholeFile: true "eslint-comments/require-description": "error" eqeqeq: error import/default: "off" @@ -132,6 +135,10 @@ rules: message: "You should use the Alert component provided on components/Alert/Alert" + - name: "@mui/material/Popover" + message: + "You should use the Popover component provided on + components/Popover/Popover" no-unused-vars: "off" "object-curly-spacing": "off" react-hooks/exhaustive-deps: warn diff --git a/site/e2e/playwright.config.ts b/site/e2e/playwright.config.ts index 792944f26dde9..dcd82a6dfd999 100644 --- a/site/e2e/playwright.config.ts +++ b/site/e2e/playwright.config.ts @@ -6,7 +6,7 @@ export const port = process.env.CODER_E2E_PORT ? Number(process.env.CODER_E2E_PORT) : defaultPort; -const coderMain = path.join(__dirname, "../../enterprise/cmd/coder/main.go"); +const coderMain = path.join(__dirname, "../../enterprise/cmd/coder"); export const STORAGE_STATE = path.join(__dirname, ".auth.json"); diff --git a/site/e2e/reporter.ts b/site/e2e/reporter.ts index 1cb5e34c6619a..6be742b02bb55 100644 --- a/site/e2e/reporter.ts +++ b/site/e2e/reporter.ts @@ -1,4 +1,5 @@ -import fs from "fs"; +/* eslint-disable no-console -- Logging is sort of the whole point here */ +import * as fs from "fs/promises"; import type { FullConfig, Suite, @@ -6,80 +7,140 @@ import type { TestResult, FullResult, Reporter, + TestError, } from "@playwright/test/reporter"; import axios from "axios"; +import type { Writable } from "stream"; class CoderReporter implements Reporter { + config: FullConfig | null = null; + testOutput = new Map>(); + passedCount = 0; + failedTests: TestCase[] = []; + timedOutTests: TestCase[] = []; + onBegin(config: FullConfig, suite: Suite) { - // eslint-disable-next-line no-console -- Helpful for debugging - console.log(`Starting the run with ${suite.allTests().length} tests`); + this.config = config; + console.log(`==> Running ${suite.allTests().length} tests`); } onTestBegin(test: TestCase) { - // eslint-disable-next-line no-console -- Helpful for debugging - console.log(`Starting test ${test.title}`); + this.testOutput.set(test.id, []); + console.log(`==> Starting test ${test.title}`); } - onStdOut(chunk: string, test: TestCase, _: TestResult): void { - // eslint-disable-next-line no-console -- Helpful for debugging - console.log( - `[stdout] [${test ? test.title : "unknown"}]: ${chunk.replace( - /\n$/g, - "", - )}`, - ); + onStdOut(chunk: string, test?: TestCase, _?: TestResult): void { + if (!test) { + for (const line of filteredServerLogLines(chunk)) { + console.log(`[stdout] ${line}`); + } + return; + } + this.testOutput.get(test.id)!.push([process.stdout, chunk]); } - onStdErr(chunk: string, test: TestCase, _: TestResult): void { - // eslint-disable-next-line no-console -- Helpful for debugging - console.log( - `[stderr] [${test ? test.title : "unknown"}]: ${chunk.replace( - /\n$/g, - "", - )}`, - ); + onStdErr(chunk: string, test?: TestCase, _?: TestResult): void { + if (!test) { + for (const line of filteredServerLogLines(chunk)) { + console.error(`[stderr] ${line}`); + } + return; + } + this.testOutput.get(test.id)!.push([process.stderr, chunk]); } async onTestEnd(test: TestCase, result: TestResult) { - // eslint-disable-next-line no-console -- Helpful for debugging - console.log(`Finished test ${test.title}: ${result.status}`); + console.log(`==> Finished test ${test.title}: ${result.status}`); + + if (result.status === "passed") { + this.passedCount++; + } + + if (result.status === "failed") { + this.failedTests.push(test); + } + + if (result.status === "timedOut") { + this.timedOutTests.push(test); + } + + const fsTestTitle = test.title.replaceAll(" ", "-"); + const outputFile = `test-results/debug-pprof-goroutine-${fsTestTitle}.txt`; + await exportDebugPprof(outputFile); if (result.status !== "passed") { - // eslint-disable-next-line no-console -- Helpful for debugging - console.log("errors", result.errors, "attachments", result.attachments); + console.log(`Data from pprof has been saved to ${outputFile}`); + console.log("==> Output"); + const output = this.testOutput.get(test.id)!; + for (const [target, chunk] of output) { + target.write(`${chunk.replace(/\n$/g, "")}\n`); + } + + if (result.errors.length > 0) { + console.log("==> Errors"); + for (const error of result.errors) { + reportError(error); + } + } + + if (result.attachments.length > 0) { + console.log("==> Attachments"); + for (const attachment of result.attachments) { + console.log(attachment); + } + } } - await exportDebugPprof(test.title); + this.testOutput.delete(test.id); } onEnd(result: FullResult) { - // eslint-disable-next-line no-console -- Helpful for debugging - console.log(`Finished the run: ${result.status}`); + console.log(`==> Tests ${result.status}`); + console.log(`${this.passedCount} passed`); + if (this.failedTests.length > 0) { + console.log(`${this.failedTests.length} failed`); + for (const test of this.failedTests) { + console.log(` ${test.location.file} › ${test.title}`); + } + } + if (this.timedOutTests.length > 0) { + console.log(`${this.timedOutTests.length} timed out`); + for (const test of this.timedOutTests) { + console.log(` ${test.location.file} › ${test.title}`); + } + } } } -const exportDebugPprof = async (testName: string) => { - const url = "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1"; - const outputFile = `test-results/debug-pprof-goroutine-${testName}.txt`; +const shouldPrintLine = (line: string) => + [" error=EOF", "coderd: audit_log"].every((noise) => !line.includes(noise)); - await axios - .get(url) - .then((response) => { - if (response.status !== 200) { - throw new Error(`Error: Received status code ${response.status}`); - } +const filteredServerLogLines = (chunk: string): string[] => + chunk.trimEnd().split("\n").filter(shouldPrintLine); - fs.writeFile(outputFile, response.data, (err) => { - if (err) { - throw new Error(`Error writing to ${outputFile}: ${err.message}`); - } else { - // eslint-disable-next-line no-console -- Helpful for debugging - console.log(`Data from ${url} has been saved to ${outputFile}`); - } - }); - }) - .catch((error) => { - throw new Error(`Error: ${error.message}`); - }); +const exportDebugPprof = async (outputFile: string) => { + const response = await axios.get( + "http://127.0.0.1:6060/debug/pprof/goroutine?debug=1", + ); + if (response.status !== 200) { + throw new Error(`Error: Received status code ${response.status}`); + } + + await fs.writeFile(outputFile, response.data); +}; + +const reportError = (error: TestError) => { + if (error.location) { + console.log(`${error.location.file}:${error.location.line}:`); + } + if (error.snippet) { + console.log(error.snippet); + } + + if (error.message) { + console.log(error.message); + } else { + console.log(error); + } }; // eslint-disable-next-line no-unused-vars -- Playwright config uses it diff --git a/site/src/api/api.ts b/site/src/api/api.ts index dccdc383ccbf9..c602a727e389e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -947,7 +947,7 @@ export const getTemplateACL = async ( export const updateTemplateACL = async ( templateId: string, data: TypesGen.UpdateTemplateACL, -): Promise => { +): Promise<{ message: string }> => { const response = await axios.patch( `/api/v2/templates/${templateId}/acl`, data, diff --git a/site/src/api/queries/groups.ts b/site/src/api/queries/groups.ts index d34ad24a6abd0..3dc6759c12484 100644 --- a/site/src/api/queries/groups.ts +++ b/site/src/api/queries/groups.ts @@ -1,4 +1,4 @@ -import { QueryClient } from "react-query"; +import { QueryClient, UseQueryOptions } from "react-query"; import * as API from "api/api"; import { checkAuthorization } from "api/api"; import { @@ -25,6 +25,43 @@ export const group = (groupId: string) => { }; }; +export type GroupsByUserId = Readonly>; + +export function groupsByUserId(organizationId: string) { + return { + ...groups(organizationId), + select: (allGroups) => { + // Sorting here means that nothing has to be sorted for the individual + // user arrays later + const sorted = [...allGroups].sort((g1, g2) => { + const key = + g1.display_name && g2.display_name ? "display_name" : "name"; + + if (g1[key] === g2[key]) { + return 0; + } + + return g1[key] < g2[key] ? -1 : 1; + }); + + const userIdMapper = new Map(); + for (const group of sorted) { + for (const user of group.members) { + let groupsForUser = userIdMapper.get(user.id); + if (groupsForUser === undefined) { + groupsForUser = []; + userIdMapper.set(user.id, groupsForUser); + } + + groupsForUser.push(group); + } + } + + return userIdMapper as GroupsByUserId; + }, + } satisfies UseQueryOptions; +} + export const groupPermissions = (groupId: string) => { return { queryKey: [...getGroupQueryKey(groupId), "permissions"], diff --git a/site/src/api/queries/templateVersions.ts b/site/src/api/queries/templateVersions.ts deleted file mode 100644 index e90e762e810a0..0000000000000 --- a/site/src/api/queries/templateVersions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as API from "api/api"; - -export const templateVersionLogs = (versionId: string) => { - return { - queryKey: ["templateVersion", versionId, "logs"], - queryFn: () => API.getTemplateVersionLogs(versionId), - }; -}; - -export const richParameters = (versionId: string) => { - return { - queryKey: ["templateVersion", versionId, "richParameters"], - queryFn: () => API.getTemplateVersionRichParameters(versionId), - }; -}; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index f4082e362fd29..337925a6583a4 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -6,8 +6,14 @@ import { type TemplateVersion, CreateTemplateRequest, ProvisionerJob, + UsersRequest, + TemplateRole, } from "api/typesGenerated"; -import { type QueryClient, type QueryOptions } from "react-query"; +import { + MutationOptions, + type QueryClient, + type QueryOptions, +} from "react-query"; import { delay } from "utils/delay"; export const templateByNameKey = (orgId: string, name: string) => [ @@ -36,6 +42,53 @@ export const templates = (orgId: string) => { }; }; +export const templateACL = (templateId: string) => { + return { + queryKey: ["templateAcl", templateId], + queryFn: () => API.getTemplateACL(templateId), + }; +}; + +export const setUserRole = ( + queryClient: QueryClient, +): MutationOptions< + Awaited>, + unknown, + { templateId: string; userId: string; role: TemplateRole } +> => { + return { + mutationFn: ({ templateId, userId, role }) => + API.updateTemplateACL(templateId, { + user_perms: { + [userId]: role, + }, + }), + onSuccess: async (_res, { templateId }) => { + await queryClient.invalidateQueries(["templateAcl", templateId]); + }, + }; +}; + +export const setGroupRole = ( + queryClient: QueryClient, +): MutationOptions< + Awaited>, + unknown, + { templateId: string; groupId: string; role: TemplateRole } +> => { + return { + mutationFn: ({ templateId, groupId, role }) => + API.updateTemplateACL(templateId, { + group_perms: { + [groupId]: role, + }, + }), + onSuccess: async (_res, { templateId }) => { + await queryClient.invalidateQueries(["templateAcl", templateId]); + }, + }; +}; + export const templateExamples = (orgId: string) => { return { queryKey: [...getTemplatesQueryKey(orgId), "examples"], @@ -50,6 +103,18 @@ export const templateVersion = (versionId: string) => { }; }; +export const templateVersionByName = ( + orgId: string, + templateName: string, + versionName: string, +) => { + return { + queryKey: ["templateVersion", orgId, templateName, versionName], + queryFn: () => + API.getTemplateVersionByName(orgId, templateName, versionName), + }; +}; + export const templateVersions = (templateId: string) => { return { queryKey: ["templateVersions", templateId], @@ -92,6 +157,16 @@ export const updateActiveTemplateVersion = ( }; }; +export const templaceACLAvailable = ( + templateId: string, + options: UsersRequest, +) => { + return { + queryKey: ["template", templateId, "aclAvailable", options], + queryFn: () => API.getTemplateACLAvailable(templateId, options), + }; +}; + export const templateVersionExternalAuthKey = (versionId: string) => [ "templateVersion", versionId, @@ -127,6 +202,20 @@ const createTemplateFn = async (options: { }); }; +export const templateVersionLogs = (versionId: string) => { + return { + queryKey: ["templateVersion", versionId, "logs"], + queryFn: () => API.getTemplateVersionLogs(versionId), + }; +}; + +export const richParameters = (versionId: string) => { + return { + queryKey: ["templateVersion", versionId, "richParameters"], + queryFn: () => API.getTemplateVersionRichParameters(versionId), + }; +}; + const waitBuildToBeFinished = async (version: TemplateVersion) => { let data: TemplateVersion; let jobStatus: ProvisionerJobStatus; diff --git a/site/src/api/queries/updateCheck.ts b/site/src/api/queries/updateCheck.ts new file mode 100644 index 0000000000000..40fcc6a3cfdde --- /dev/null +++ b/site/src/api/queries/updateCheck.ts @@ -0,0 +1,8 @@ +import * as API from "api/api"; + +export const updateCheck = () => { + return { + queryKey: ["updateCheck"], + queryFn: () => API.getUpdateCheck(), + }; +}; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 2d3f1dbc58c2e..8f335c6525f7b 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -5,11 +5,11 @@ import { GetUsersResponse, UpdateUserPasswordRequest, UpdateUserProfileRequest, - User, UsersRequest, + User, } from "api/typesGenerated"; -import { getMetadataAsJSON } from "utils/metadata"; import { getAuthorizationKey } from "./authCheck"; +import { getMetadataAsJSON } from "utils/metadata"; export const users = (req: UsersRequest): UseQueryOptions => { return { @@ -89,21 +89,14 @@ export const authMethods = () => { }; }; -const initialMeData = getMetadataAsJSON("user"); -const meKey = ["me"] as const; +const initialUserData = getMetadataAsJSON("user"); -export const me = (queryClient: QueryClient) => { +export const me = () => { return { - queryKey: meKey, - queryFn: async () => { - const cachedData = queryClient.getQueryData(meKey); - if (cachedData === undefined && initialMeData !== undefined) { - return initialMeData; - } - - return API.getAuthenticatedUser(); - }, - } satisfies UseQueryOptions; + queryKey: ["me"], + initialData: initialUserData, + queryFn: API.getAuthenticatedUser, + }; }; export const hasFirstUser = () => { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 29fbd56f5e316..975e2b8897197 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -221,6 +221,7 @@ export interface CreateTemplateRequest { readonly dormant_ttl_ms?: number; readonly delete_ttl_ms?: number; readonly disable_everyone_group_access: boolean; + readonly require_active_version: boolean; } // From codersdk/templateversions.go @@ -916,6 +917,7 @@ export interface Template { readonly failure_ttl_ms: number; readonly time_til_dormant_ms: number; readonly time_til_dormant_autodelete_ms: number; + readonly require_active_version: boolean; } // From codersdk/templates.go @@ -1166,6 +1168,7 @@ export interface UpdateTemplateMeta { readonly time_til_dormant_autodelete_ms?: number; readonly update_workspace_last_used_at: boolean; readonly update_workspace_dormant_at: boolean; + readonly require_active_version: boolean; } // From codersdk/users.go @@ -1348,6 +1351,7 @@ export interface Workspace { readonly template_icon: string; readonly template_allow_user_cancel_workspace_jobs: boolean; readonly template_active_version_id: string; + readonly template_require_active_version: boolean; readonly latest_build: WorkspaceBuild; readonly outdated: boolean; readonly name: string; @@ -1703,7 +1707,9 @@ export type Experiment = | "moons" | "single_tailnet" | "tailnet_pg_coordinator" - | "template_autostop_requirement"; + | "template_autostop_requirement" + | "template_update_policies" + | "workspace_actions"; export const Experiments: Experiment[] = [ "dashboard_theme", "deployment_health_page", @@ -1711,10 +1717,13 @@ export const Experiments: Experiment[] = [ "single_tailnet", "tailnet_pg_coordinator", "template_autostop_requirement", + "template_update_policies", + "workspace_actions", ]; // From codersdk/deployment.go export type FeatureName = + | "access_control" | "advanced_template_scheduling" | "appearance" | "audit_log" @@ -1731,6 +1740,7 @@ export type FeatureName = | "workspace_batch_actions" | "workspace_proxy"; export const FeatureNames: FeatureName[] = [ + "access_control", "advanced_template_scheduling", "appearance", "audit_log", diff --git a/site/src/components/AuthProvider/AuthProvider.tsx b/site/src/components/AuthProvider/AuthProvider.tsx index c62bd72d27a0e..98327b0a7caf1 100644 --- a/site/src/components/AuthProvider/AuthProvider.tsx +++ b/site/src/components/AuthProvider/AuthProvider.tsx @@ -46,7 +46,7 @@ const AuthContext = createContext(undefined); export const AuthProvider: FC = ({ children }) => { const queryClient = useQueryClient(); - const meOptions = me(queryClient); + const meOptions = me(); const userQuery = useQuery(meOptions); const authMethodsQuery = useQuery(authMethods()); diff --git a/site/src/components/Avatar/Avatar.tsx b/site/src/components/Avatar/Avatar.tsx index 02dbb2b141480..89de1f174ba4b 100644 --- a/site/src/components/Avatar/Avatar.tsx +++ b/site/src/components/Avatar/Avatar.tsx @@ -5,16 +5,23 @@ import { FC } from "react"; import { css, type Interpolation, type Theme } from "@emotion/react"; export type AvatarProps = MuiAvatarProps & { - size?: "sm" | "md" | "xl"; + size?: "xs" | "sm" | "md" | "xl"; colorScheme?: "light" | "darken"; fitImage?: boolean; }; const sizeStyles = { + xs: (theme) => ({ + width: theme.spacing(2), + height: theme.spacing(2), + fontSize: theme.spacing(1), + fontWeight: 700, + }), sm: (theme) => ({ width: theme.spacing(3), height: theme.spacing(3), fontSize: theme.spacing(1.5), + fontWeight: 600, }), md: {}, xl: (theme) => ({ diff --git a/site/src/components/Dashboard/DashboardLayout.test.tsx b/site/src/components/Dashboard/DashboardLayout.test.tsx index 0020a014fd26c..b580225491207 100644 --- a/site/src/components/Dashboard/DashboardLayout.test.tsx +++ b/site/src/components/Dashboard/DashboardLayout.test.tsx @@ -1,14 +1,22 @@ import { renderWithAuth } from "testHelpers/renderHelpers"; import { DashboardLayout } from "./DashboardLayout"; -import * as API from "api/api"; import { screen } from "@testing-library/react"; +import { rest } from "msw"; +import { server } from "testHelpers/server"; test("Show the new Coder version notification", async () => { - jest.spyOn(API, "getUpdateCheck").mockResolvedValue({ - current: false, - version: "v0.12.9", - url: "https://github.com/coder/coder/releases/tag/v0.12.9", - }); + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + current: false, + version: "v0.12.9", + url: "https://github.com/coder/coder/releases/tag/v0.12.9", + }), + ); + }), + ); renderWithAuth(, { children: [{ element:

Test page

}], }); diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx index 8aa96360d2cdc..1947e3ef9cbda 100644 --- a/site/src/components/Dashboard/DashboardLayout.tsx +++ b/site/src/components/Dashboard/DashboardLayout.tsx @@ -1,4 +1,3 @@ -import { useMachine } from "@xstate/react"; import { DeploymentBanner } from "./DeploymentBanner/DeploymentBanner"; import { LicenseBanner } from "components/Dashboard/LicenseBanner/LicenseBanner"; import { Loader } from "components/Loader/Loader"; @@ -7,7 +6,6 @@ import { usePermissions } from "hooks/usePermissions"; import { FC, Suspense } from "react"; import { Outlet } from "react-router-dom"; import { dashboardContentBottomPadding } from "theme/constants"; -import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService"; import { Navbar } from "./Navbar/Navbar"; import Snackbar from "@mui/material/Snackbar"; import Link from "@mui/material/Link"; @@ -15,15 +13,11 @@ import Box, { BoxProps } from "@mui/material/Box"; import InfoOutlined from "@mui/icons-material/InfoOutlined"; import Button from "@mui/material/Button"; import { docs } from "utils/docs"; +import { useUpdateCheck } from "./useUpdateCheck"; export const DashboardLayout: FC = () => { const permissions = usePermissions(); - const [updateCheckState, updateCheckSend] = useMachine(updateCheckMachine, { - context: { - permissions, - }, - }); - const { updateCheck } = updateCheckState.context; + const updateCheck = useUpdateCheck(permissions.viewUpdateCheck); const canViewDeployment = Boolean(permissions.viewDeploymentValues); return ( @@ -57,7 +51,7 @@ export const DashboardLayout: FC = () => { { })} /> - Coder {updateCheck?.version} is now available. View the{" "} - release notes and{" "} + Coder {updateCheck.data?.version} is now available. View the{" "} + release notes and{" "} upgrade instructions{" "} for more information. } action={ - } diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index a0bd176178a1a..7e06b4a656620 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -112,3 +112,13 @@ export const useDashboard = (): DashboardProviderValue => { return context; }; + +export const useIsWorkspaceActionsEnabled = (): boolean => { + const { entitlements, experiments } = useDashboard(); + const allowAdvancedScheduling = + entitlements.features["advanced_template_scheduling"].enabled; + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions"); + return allowWorkspaceActions && allowAdvancedScheduling; +}; diff --git a/site/src/components/Dashboard/Navbar/UserDropdown/BorderedMenu.tsx b/site/src/components/Dashboard/Navbar/UserDropdown/BorderedMenu.tsx deleted file mode 100644 index b57984aa6dab7..0000000000000 --- a/site/src/components/Dashboard/Navbar/UserDropdown/BorderedMenu.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { css } from "@emotion/css"; -import { useTheme } from "@emotion/react"; -import Popover, { type PopoverProps } from "@mui/material/Popover"; -import type { FC, PropsWithChildren } from "react"; - -type BorderedMenuVariant = "user-dropdown"; - -export type BorderedMenuProps = Omit & { - variant?: BorderedMenuVariant; -}; - -export const BorderedMenu: FC> = ({ - children, - variant, - ...rest -}) => { - const theme = useTheme(); - - const paper = css` - width: 260px; - border-radius: ${theme.shape.borderRadius}px; - box-shadow: ${theme.shadows[6]}; - `; - - return ( - - {children} - - ); -}; diff --git a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx index 7ebffa89a0461..394d4846e2a2f 100644 --- a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx +++ b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdown.stories.tsx @@ -1,4 +1,4 @@ -import { MockUser } from "testHelpers/entities"; +import { MockBuildInfo, MockUser } from "testHelpers/entities"; import { UserDropdown } from "./UserDropdown"; import type { Meta, StoryObj } from "@storybook/react"; @@ -7,6 +7,13 @@ const meta: Meta = { component: UserDropdown, args: { user: MockUser, + isDefaultOpen: true, + buildInfo: MockBuildInfo, + supportLinks: [ + { icon: "docs", name: "Documentation", target: "" }, + { icon: "bug", name: "Report a bug", target: "" }, + { icon: "chat", name: "Join the Coder Discord", target: "" }, + ], }, }; diff --git a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdown.tsx b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdown.tsx index 50143eeecf217..d8194ac8c567e 100644 --- a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdown.tsx +++ b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdown.tsx @@ -1,21 +1,25 @@ import Badge from "@mui/material/Badge"; -import MenuItem from "@mui/material/MenuItem"; -import { useState, FC, PropsWithChildren, MouseEvent } from "react"; +import { FC, PropsWithChildren } from "react"; import { colors } from "theme/colors"; import * as TypesGen from "api/typesGenerated"; import { navHeight } from "theme/constants"; -import { BorderedMenu } from "./BorderedMenu"; import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; import { UserAvatar } from "components/UserAvatar/UserAvatar"; import { UserDropdownContent } from "./UserDropdownContent"; import { BUTTON_SM_HEIGHT } from "theme/theme"; import { css } from "@emotion/react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; export interface UserDropdownProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; supportLinks?: TypesGen.LinkConfig[]; onSignOut: () => void; + isDefaultOpen?: boolean; } export const UserDropdown: FC> = ({ @@ -23,76 +27,69 @@ export const UserDropdown: FC> = ({ user, supportLinks, onSignOut, + isDefaultOpen, }: UserDropdownProps) => { - const [anchorEl, setAnchorEl] = useState(); - - const handleDropdownClick = (ev: MouseEvent): void => { - setAnchorEl(ev.currentTarget); - }; - const onPopoverClose = () => { - setAnchorEl(undefined); - }; - return ( - <> - css` - height: ${navHeight}px; - padding: ${theme.spacing(1.5, 0)}; + + {(popover) => ( + <> + + + - - - - + ({ + ".MuiPaper-root": { + width: 260, + boxShadow: theme.shadows[6], + }, + })} + > + + + + )} + ); }; diff --git a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.stories.tsx b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.stories.tsx deleted file mode 100644 index f9f8c5a8a5a03..0000000000000 --- a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.stories.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { MockUser } from "testHelpers/entities"; -import { UserDropdownContent } from "./UserDropdownContent"; -import type { Meta, StoryObj } from "@storybook/react"; - -const meta: Meta = { - title: "components/UserDropdownContent", - component: UserDropdownContent, -}; - -export default meta; -type Story = StoryObj; - -export const ExampleNoRoles: Story = { - args: { - user: { - ...MockUser, - roles: [], - }, - }, -}; - -export const ExampleOneRole: Story = { - args: { - user: { - ...MockUser, - roles: [{ name: "member", display_name: "Member" }], - }, - }, -}; - -export const ExampleThreeRoles: Story = { - args: { - user: { - ...MockUser, - roles: [ - { name: "admin", display_name: "Admin" }, - { name: "member", display_name: "Member" }, - { name: "auditor", display_name: "Auditor" }, - ], - }, - }, -}; diff --git a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx index 4e488f04d0b3d..f9650a60d0443 100644 --- a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx +++ b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx @@ -2,15 +2,14 @@ import { screen } from "@testing-library/react"; import { MockUser } from "testHelpers/entities"; import { render, waitForLoaderToBeRemoved } from "testHelpers/renderHelpers"; import { Language, UserDropdownContent } from "./UserDropdownContent"; +import { Popover } from "components/Popover/Popover"; describe("UserDropdownContent", () => { it("has the correct link for the account item", async () => { render( - , + + + , ); await waitForLoaderToBeRemoved(); @@ -25,11 +24,9 @@ describe("UserDropdownContent", () => { it("calls the onSignOut function", async () => { const onSignOut = jest.fn(); render( - , + + + , ); await waitForLoaderToBeRemoved(); screen.getByText(Language.signOutLabel).click(); diff --git a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.tsx b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.tsx index ebc103da57887..99d2d65a48f9a 100644 --- a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.tsx +++ b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.tsx @@ -16,6 +16,7 @@ import { type Interpolation, type Theme, } from "@emotion/react"; +import { usePopover } from "components/Popover/Popover"; export const Language = { accountLabel: "Account", @@ -82,7 +83,6 @@ export interface UserDropdownContentProps { user: TypesGen.User; buildInfo?: TypesGen.BuildInfoResponse; supportLinks?: TypesGen.LinkConfig[]; - onPopoverClose: () => void; onSignOut: () => void; } @@ -90,9 +90,14 @@ export const UserDropdownContent: FC = ({ buildInfo, user, supportLinks, - onPopoverClose, onSignOut, }) => { + const popover = usePopover(); + + const onPopoverClose = () => { + popover.setIsOpen(false); + }; + return (
diff --git a/site/src/components/Dashboard/useUpdateCheck.test.tsx b/site/src/components/Dashboard/useUpdateCheck.test.tsx new file mode 100644 index 0000000000000..e5e307c205d79 --- /dev/null +++ b/site/src/components/Dashboard/useUpdateCheck.test.tsx @@ -0,0 +1,121 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { useUpdateCheck } from "./useUpdateCheck"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { ReactNode } from "react"; +import { rest } from "msw"; +import { MockUpdateCheck } from "testHelpers/entities"; +import { server } from "testHelpers/server"; + +const createWrapper = () => { + const queryClient = new QueryClient(); + return ({ children }: { children: ReactNode }) => ( + {children} + ); +}; + +beforeEach(() => { + window.localStorage.clear(); +}); + +it("is dismissed when does not have permission to see it", () => { + const { result } = renderHook(() => useUpdateCheck(false), { + wrapper: createWrapper(), + }); + expect(result.current.isVisible).toBeFalsy(); +}); + +it("is dismissed when it is already using current version", async () => { + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockUpdateCheck, + current: true, + }), + ); + }), + ); + const { result } = renderHook(() => useUpdateCheck(true), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeFalsy(); + }); +}); + +it("is dismissed when it was dismissed previously", async () => { + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockUpdateCheck, + current: false, + }), + ); + }), + ); + window.localStorage.setItem("dismissedVersion", MockUpdateCheck.version); + const { result } = renderHook(() => useUpdateCheck(true), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeFalsy(); + }); +}); + +it("shows when has permission and is outdated", async () => { + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockUpdateCheck, + current: false, + }), + ); + }), + ); + const { result } = renderHook(() => useUpdateCheck(true), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeTruthy(); + }); +}); + +it("shows when has permission and is outdated", async () => { + server.use( + rest.get("/api/v2/updatecheck", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...MockUpdateCheck, + current: false, + }), + ); + }), + ); + const { result } = renderHook(() => useUpdateCheck(true), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeTruthy(); + }); + + act(() => { + result.current.dismiss(); + }); + + await waitFor(() => { + expect(result.current.isVisible).toBeFalsy(); + }); + expect(localStorage.getItem("dismissedVersion")).toEqual( + MockUpdateCheck.version, + ); +}); diff --git a/site/src/components/Dashboard/useUpdateCheck.ts b/site/src/components/Dashboard/useUpdateCheck.ts new file mode 100644 index 0000000000000..7089d87914591 --- /dev/null +++ b/site/src/components/Dashboard/useUpdateCheck.ts @@ -0,0 +1,45 @@ +import { updateCheck } from "api/queries/updateCheck"; +import { useMemo, useState } from "react"; +import { useQuery } from "react-query"; + +export const useUpdateCheck = (enabled: boolean) => { + const [dismissedVersion, setDismissedVersion] = useState(() => + getDismissedVersionOnLocal(), + ); + const updateCheckQuery = useQuery({ + ...updateCheck(), + enabled, + }); + + const isVisible: boolean = useMemo(() => { + if (!updateCheckQuery.data) { + return false; + } + + const isNotDismissed = dismissedVersion !== updateCheckQuery.data.version; + const isOutdated = !updateCheckQuery.data.current; + return isNotDismissed && isOutdated ? true : false; + }, [dismissedVersion, updateCheckQuery.data]); + + const dismiss = () => { + if (!updateCheckQuery.data) { + return; + } + setDismissedVersion(updateCheckQuery.data.version); + saveDismissedVersionOnLocal(updateCheckQuery.data.version); + }; + + return { + isVisible, + dismiss, + data: updateCheckQuery.data, + }; +}; + +const saveDismissedVersionOnLocal = (version: string): void => { + window.localStorage.setItem("dismissedVersion", version); +}; + +const getDismissedVersionOnLocal = (): string | undefined => { + return localStorage.getItem("dismissedVersion") ?? undefined; +}; diff --git a/site/src/components/Dialogs/Dialog.tsx b/site/src/components/Dialogs/Dialog.tsx index 0c81598354db5..eba3df2903350 100644 --- a/site/src/components/Dialogs/Dialog.tsx +++ b/site/src/components/Dialogs/Dialog.tsx @@ -1,12 +1,9 @@ import MuiDialog, { DialogProps as MuiDialogProps } from "@mui/material/Dialog"; import { type ReactNode } from "react"; import { colors } from "theme/colors"; -import { - LoadingButton, - LoadingButtonProps, -} from "../LoadingButton/LoadingButton"; import { ConfirmDialogType } from "./types"; import { type Interpolation, type Theme } from "@emotion/react"; +import LoadingButton, { LoadingButtonProps } from "@mui/lab/LoadingButton"; export interface DialogActionButtonsProps { /** Text to display in the cancel button */ diff --git a/site/src/components/FormFooter/FormFooter.tsx b/site/src/components/FormFooter/FormFooter.tsx index 7221aaf6a217d..17d1a81d51230 100644 --- a/site/src/components/FormFooter/FormFooter.tsx +++ b/site/src/components/FormFooter/FormFooter.tsx @@ -1,7 +1,7 @@ import Button from "@mui/material/Button"; import { type FC } from "react"; -import { LoadingButton } from "../LoadingButton/LoadingButton"; import { Interpolation, Theme } from "@emotion/react"; +import LoadingButton from "@mui/lab/LoadingButton"; export const Language = { cancelLabel: "Cancel", diff --git a/site/src/components/HelpTooltip/HelpTooltip.tsx b/site/src/components/HelpTooltip/HelpTooltip.tsx index 92990c318f9b9..b79babde64700 100644 --- a/site/src/components/HelpTooltip/HelpTooltip.tsx +++ b/site/src/components/HelpTooltip/HelpTooltip.tsx @@ -1,4 +1,6 @@ import Link from "@mui/material/Link"; +// This is used as base for the main HelpTooltip component +// eslint-disable-next-line no-restricted-imports -- Read above import Popover, { PopoverProps } from "@mui/material/Popover"; import HelpIcon from "@mui/icons-material/HelpOutline"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; @@ -86,7 +88,7 @@ export const HelpPopover: FC< export const HelpTooltip: FC> = ({ children, - open, + open = false, size = "medium", icon: Icon = HelpIcon, iconClassName, @@ -94,7 +96,7 @@ export const HelpTooltip: FC> = ({ }) => { const theme = useTheme(); const anchorRef = useRef(null); - const [isOpen, setIsOpen] = useState(Boolean(open)); + const [isOpen, setIsOpen] = useState(open); const id = isOpen ? "help-popover" : undefined; const onClose = () => { diff --git a/site/src/components/IconField/IconField.tsx b/site/src/components/IconField/IconField.tsx index ab6fd044a888d..6e8bf11506cfa 100644 --- a/site/src/components/IconField/IconField.tsx +++ b/site/src/components/IconField/IconField.tsx @@ -1,15 +1,19 @@ import Button from "@mui/material/Button"; import InputAdornment from "@mui/material/InputAdornment"; -import Popover from "@mui/material/Popover"; import TextField, { TextFieldProps } from "@mui/material/TextField"; import { makeStyles } from "@mui/styles"; import Picker from "@emoji-mart/react"; -import { useRef, FC, useState } from "react"; +import { FC } from "react"; import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; import { Stack } from "components/Stack/Stack"; import { colors } from "theme/colors"; import data from "@emoji-mart/data/sets/14/twitter.json"; import icons from "theme/icons.json"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; // See: https://github.com/missive/emoji-mart/issues/51#issuecomment-287353222 const urlFromUnifiedCode = (unified: string) => @@ -45,8 +49,6 @@ const IconField: FC = ({ onPickEmoji, ...textFieldProps }) => { } const styles = useStyles(); - const emojiButtonRef = useRef(null); - const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false); const hasIcon = textFieldProps.value && textFieldProps.value !== ""; return ( @@ -71,36 +73,32 @@ const IconField: FC = ({ onPickEmoji, ...textFieldProps }) => { }} /> - - - { - setIsEmojiPickerOpen(false); - }} - > - { - const value = emoji.src ?? urlFromUnifiedCode(emoji.unified); - onPickEmoji(value); - setIsEmojiPickerOpen(false); - }} - /> + + {(popover) => ( + <> + + + + + { + const value = emoji.src ?? urlFromUnifiedCode(emoji.unified); + onPickEmoji(value); + popover.setIsOpen(false); + }} + /> + + + )} ); diff --git a/site/src/components/LastSeen/LastSeen.tsx b/site/src/components/LastSeen/LastSeen.tsx new file mode 100644 index 0000000000000..df9c05210e5ea --- /dev/null +++ b/site/src/components/LastSeen/LastSeen.tsx @@ -0,0 +1,41 @@ +import Box, { type BoxProps } from "@mui/material/Box"; +import { useTheme } from "@emotion/react"; +import dayjs from "dayjs"; + +export const LastSeen = ({ + value, + ...boxProps +}: { value: string } & BoxProps) => { + const theme = useTheme(); + const t = dayjs(value); + const now = dayjs(); + + let message = t.fromNow(); + let color = theme.palette.text.secondary; + + if (t.isAfter(now.subtract(1, "hour"))) { + color = theme.palette.success.light; + // Since the agent reports on a 10m interval, + // the last_used_at can be inaccurate when recent. + message = "Now"; + } else if (t.isAfter(now.subtract(3, "day"))) { + color = theme.palette.text.secondary; + } else if (t.isAfter(now.subtract(1, "month"))) { + color = theme.palette.warning.light; + } else if (t.isAfter(now.subtract(100, "year"))) { + color = theme.palette.error.light; + } else { + message = "Never"; + } + + return ( + + {message} + + ); +}; diff --git a/site/src/components/LoadingButton/LoadingButton.stories.tsx b/site/src/components/LoadingButton/LoadingButton.stories.tsx deleted file mode 100644 index 618bc7d42a3a3..0000000000000 --- a/site/src/components/LoadingButton/LoadingButton.stories.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { LoadingButton } from "./LoadingButton"; -import type { Meta, StoryObj } from "@storybook/react"; - -const meta: Meta = { - title: "components/LoadingButton", - component: LoadingButton, - args: { - children: "Create workspace", - }, -}; - -export default meta; -type Story = StoryObj; - -export const Loading: Story = { - args: { - loading: true, - }, -}; - -export const NotLoading: Story = { - args: { - loading: false, - }, -}; diff --git a/site/src/components/LoadingButton/LoadingButton.tsx b/site/src/components/LoadingButton/LoadingButton.tsx deleted file mode 100644 index 080e357b14c5f..0000000000000 --- a/site/src/components/LoadingButton/LoadingButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { forwardRef } from "react"; -import MuiLoadingButton, { - LoadingButtonProps as MuiLoadingButtonProps, -} from "@mui/lab/LoadingButton"; - -export type LoadingButtonProps = MuiLoadingButtonProps; - -export const LoadingButton = forwardRef< - HTMLButtonElement, - MuiLoadingButtonProps ->(({ children, loadingIndicator, ...buttonProps }, ref) => { - return ( - - {/* known issue: https://github.com/mui/material-ui/issues/27853 */} - - {buttonProps.loading && loadingIndicator ? loadingIndicator : children} - - - ); -}); diff --git a/site/src/components/Popover/Popover.tsx b/site/src/components/Popover/Popover.tsx new file mode 100644 index 0000000000000..5a936ac21a29e --- /dev/null +++ b/site/src/components/Popover/Popover.tsx @@ -0,0 +1,190 @@ +import { + ReactElement, + ReactNode, + cloneElement, + createContext, + useContext, + useEffect, + useId, + useRef, + useState, +} from "react"; +// This is used as base for the main Popover component +// eslint-disable-next-line no-restricted-imports -- Read above +import MuiPopover, { + type PopoverProps as MuiPopoverProps, +} from "@mui/material/Popover"; + +type TriggerMode = "hover" | "click"; + +type TriggerRef = React.RefObject; + +type TriggerElement = ReactElement<{ + ref: TriggerRef; + onClick?: () => void; + "aria-haspopup"?: boolean; + "aria-owns"?: string | undefined; +}>; + +type PopoverContextValue = { + id: string; + isOpen: boolean; + setIsOpen: React.Dispatch>; + triggerRef: TriggerRef; + mode: TriggerMode; +}; + +const PopoverContext = createContext( + undefined, +); + +export const Popover = (props: { + children: ReactNode | ((popover: PopoverContextValue) => ReactNode); // Allows inline usage + mode?: TriggerMode; + isDefaultOpen?: boolean; +}) => { + const hookId = useId(); + const [isOpen, setIsOpen] = useState(props.isDefaultOpen ?? false); + const triggerRef = useRef(null); + + const value: PopoverContextValue = { + isOpen, + setIsOpen, + triggerRef, + id: `${hookId}-popover`, + mode: props.mode ?? "click", + }; + + return ( + + {typeof props.children === "function" + ? props.children(value) + : props.children} + + ); +}; + +export const usePopover = () => { + const context = useContext(PopoverContext); + if (!context) { + throw new Error( + "Popover compound components cannot be rendered outside the Popover component", + ); + } + return context; +}; + +export const PopoverTrigger = (props: { children: TriggerElement }) => { + const popover = usePopover(); + + const clickProps = { + onClick: () => { + popover.setIsOpen((isOpen) => !isOpen); + }, + }; + + const hoverProps = { + onPointerEnter: () => { + popover.setIsOpen(true); + }, + onPointerLeave: () => { + popover.setIsOpen(false); + }, + }; + + return cloneElement(props.children, { + ...(popover.mode === "click" ? clickProps : hoverProps), + "aria-haspopup": true, + "aria-owns": popover.isOpen ? popover.id : undefined, + ref: popover.triggerRef, + }); +}; + +type Horizontal = "left" | "right"; + +export const PopoverContent = ( + props: Omit & { + horizontal?: Horizontal; + }, +) => { + const popover = usePopover(); + const [isReady, setIsReady] = useState(false); + const horizontal = props.horizontal ?? "left"; + const hoverMode = popover.mode === "hover"; + + // This is a hack to make sure the popover is not rendered until the trigger + // is ready. This is a limitation on MUI that does not support defaultIsOpen + // on Popover but we need it to storybook the component. + useEffect(() => { + if (!isReady && popover.triggerRef.current !== null) { + setIsReady(true); + } + }, [isReady, popover.triggerRef]); + + if (!popover.triggerRef.current) { + return null; + } + + return ( + ({ + // When it is on hover mode, and the mode is moving from the trigger to + // the popover, if there is any space, the popover will be closed. I + // found this is a limitation on how MUI structured the component. It is + // not a big issue for now but we can re-evaluate it in the future. + marginTop: hoverMode ? undefined : theme.spacing(1), + pointerEvents: hoverMode ? "none" : undefined, + "& .MuiPaper-root": { + minWidth: theme.spacing(40), + fontSize: 14, + pointerEvents: hoverMode ? "auto" : undefined, + }, + })} + {...horizontalProps(horizontal)} + {...modeProps(popover)} + {...props} + id={popover.id} + open={popover.isOpen} + onClose={() => popover.setIsOpen(false)} + anchorEl={popover.triggerRef.current} + /> + ); +}; + +const modeProps = (popover: PopoverContextValue) => { + if (popover.mode === "hover") { + return { + onPointerEnter: () => { + popover.setIsOpen(true); + }, + onPointerLeave: () => { + popover.setIsOpen(false); + }, + }; + } + + return {}; +}; + +const horizontalProps = (horizontal: Horizontal) => { + if (horizontal === "right") { + return { + anchorOrigin: { + vertical: "bottom", + horizontal: "right", + }, + transformOrigin: { + vertical: "top", + horizontal: "right", + }, + } as const; + } + + return { + anchorOrigin: { + vertical: "bottom", + horizontal: "left", + }, + } as const; +}; diff --git a/site/src/components/PopoverContainer/PopoverContainer.stories.tsx b/site/src/components/PopoverContainer/PopoverContainer.stories.tsx deleted file mode 100644 index 879faf7d389c9..0000000000000 --- a/site/src/components/PopoverContainer/PopoverContainer.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Meta, StoryObj } from "@storybook/react"; -import { PopoverContainer } from "./PopoverContainer"; -import Button from "@mui/material/Button"; - -const meta: Meta = { - title: "components/PopoverContainer", - component: PopoverContainer, - args: { - anchorButton: , - children:

Hiya!

, - originY: "bottom", - }, -}; - -export default meta; - -type Story = StoryObj; -const Example: Story = {}; - -export { Example as PopoverContainer }; diff --git a/site/src/components/PopoverContainer/PopoverContainer.tsx b/site/src/components/PopoverContainer/PopoverContainer.tsx deleted file mode 100644 index 833d8b267d5fe..0000000000000 --- a/site/src/components/PopoverContainer/PopoverContainer.tsx +++ /dev/null @@ -1,244 +0,0 @@ -/** - * @file Abstracts over MUI's Popover component to simplify using it (and hide - * some of the wonkier parts of the API). - * - * Just place a button and some content in the component, and things just work. - * No setup needed with hooks or refs. - */ -import { - type KeyboardEvent, - type MouseEvent, - type PropsWithChildren, - type ReactElement, - createContext, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from "react"; - -import { type Theme, type SystemStyleObject, Box } from "@mui/system"; -import Popover, { type PopoverOrigin } from "@mui/material/Popover"; -import { useNavigate, type LinkProps } from "react-router-dom"; -import { useTheme } from "@emotion/react"; - -function getButton(container: HTMLElement) { - return ( - container.querySelector("button") ?? - container.querySelector('[aria-role="button"]') - ); -} - -const ClosePopoverContext = createContext<(() => void) | null>(null); - -type PopoverLinkProps = LinkProps & { - to: string; - sx?: SystemStyleObject; -}; - -/** - * A custom version of a React Router Link that makes sure to close the popover - * before starting a navigation. - * - * This is necessary because React Router's navigation logic doesn't work well - * with modals (including MUI's base Popover component). - * - * --- - * If the page being navigated to has lazy loading and isn't available yet, the - * previous components are supposed to be hidden during the transition, but - * because most React modals use React.createPortal to put content outside of - * the main DOM tree, React Router has no way of knowing about them. So open - * modals have a high risk of not disappearing until the page transition - * finishes and the previous components fully unmount. - */ -export function PopoverLink({ - children, - to, - sx, - ...linkProps -}: PopoverLinkProps) { - const closePopover = useContext(ClosePopoverContext); - if (closePopover === null) { - throw new Error("PopoverLink is not located inside of a PopoverContainer"); - } - - // Luckily, useNavigate and Link are designed to be imperative/declarative - // mirrors of each other, so their inputs should never get out of sync - const navigate = useNavigate(); - const theme = useTheme(); - - const onClick = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - closePopover(); - - // Hacky, but by using a promise to push the navigation to resolve via the - // micro-task queue, there's guaranteed to be a period for the popover to - // close. Tried React DOM's flushSync function, but it was unreliable. - void Promise.resolve().then(() => { - navigate(to, linkProps); - }); - }; - - return ( - - {children} - - ); -} - -type PopoverContainerProps = PropsWithChildren<{ - /** - * Does not require any hooks or refs to work. Also does not override any refs - * or event handlers attached to the button. - */ - anchorButton: ReactElement; - - width?: number; - originX?: PopoverOrigin["horizontal"]; - originY?: PopoverOrigin["vertical"]; - sx?: SystemStyleObject; -}>; - -export function PopoverContainer({ - children, - anchorButton, - originX = 0, - originY = 0, - width = 320, - sx = {}, -}: PopoverContainerProps) { - const parentClosePopover = useContext(ClosePopoverContext); - if (parentClosePopover !== null) { - throw new Error( - "Popover detected inside of Popover - this will always be a bad user experience", - ); - } - - const buttonContainerRef = useRef(null); - - // Ref value is for effects and event listeners; state value is for React - // renders. Have to duplicate state because after the initial render, it's - // never safe to reference ref contents inside a render path, especially with - // React 18 concurrency. Duplication is a necessary evil because of MUI's - // weird, clunky APIs - const anchorButtonRef = useRef(null); - const [loadedButton, setLoadedButton] = useState(); - - // Makes container listen to changes in button. If this approach becomes - // untenable in the future, it can be replaced with React.cloneElement, but - // the trade-off there is that every single anchorButton will need to be - // wrapped inside React.forwardRef, making the abstraction leak a little more - useEffect(() => { - const buttonContainer = buttonContainerRef.current; - if (buttonContainer === null) { - throw new Error("Please attach container ref to button container"); - } - - const initialButton = getButton(buttonContainer); - if (initialButton === null) { - throw new Error("Initial ref query failed"); - } - anchorButtonRef.current = initialButton; - - const onContainerMutation: MutationCallback = () => { - const newButton = getButton(buttonContainer); - if (newButton === null) { - throw new Error("Semantic button removed after DOM update"); - } - - anchorButtonRef.current = newButton; - setLoadedButton((current) => { - return current === undefined ? undefined : newButton; - }); - }; - - const observer = new MutationObserver(onContainerMutation); - observer.observe(buttonContainer, { - childList: true, - subtree: true, - }); - - return () => observer.disconnect(); - }, []); - - // Not using useInteractive because the container element is just meant to - // catch events from the inner button, not act as a button itself - const onInnerButtonInteraction = () => { - if (anchorButtonRef.current === null) { - throw new Error("Usable ref value is unavailable"); - } - - setLoadedButton(anchorButtonRef.current); - }; - - const onInnerButtonKeydown = (event: KeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") { - onInnerButtonInteraction(); - } - }; - - const closePopover = useCallback(() => { - setLoadedButton(undefined); - }, []); - - return ( - <> - {/* Cannot switch with Box component; breaks implementation */} -
- {anchorButton} -
- - - - {children} - - - - ); -} diff --git a/site/src/components/Resources/PortForwardButton.tsx b/site/src/components/Resources/PortForwardButton.tsx index f22e0e2082a80..83f54aeebb73b 100644 --- a/site/src/components/Resources/PortForwardButton.tsx +++ b/site/src/components/Resources/PortForwardButton.tsx @@ -1,11 +1,9 @@ import Box from "@mui/material/Box"; import Link from "@mui/material/Link"; -import Popover from "@mui/material/Popover"; import CircularProgress from "@mui/material/CircularProgress"; import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"; import { css } from "@emotion/css"; import { useTheme } from "@emotion/react"; -import { useRef, useState } from "react"; import { useQuery } from "react-query"; import { colors } from "theme/colors"; import { @@ -22,6 +20,11 @@ import type { WorkspaceAgentListeningPort, } from "api/typesGenerated"; import { portForwardURL } from "utils/portForward"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; export interface PortForwardButtonProps { host: string; @@ -32,9 +35,6 @@ export interface PortForwardButtonProps { export const PortForwardButton: React.FC = (props) => { const theme = useTheme(); - const anchorRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const id = isOpen ? "schedule-popover" : undefined; const portsQuery = useQuery({ queryKey: ["portForward", props.agent.id], queryFn: () => getAgentListeningPorts(props.agent.id), @@ -42,43 +42,36 @@ export const PortForwardButton: React.FC = (props) => { refetchInterval: 5_000, }); - const onClose = () => { - setIsOpen(false); - }; - return ( - <> - { - setIsOpen(true); - }} - > - Ports - {portsQuery.data ? ( - theme.spacing(0, 0.5), - borderRadius: "50%", - display: "flex", - alignItems: "center", - justifyContent: "center", - backgroundColor: colors.gray[11], - ml: 1, - }} - > - {portsQuery.data.ports.length} - - ) : ( - - )} - - + + + Ports + {portsQuery.data ? ( + theme.spacing(0, 0.5), + borderRadius: "50%", + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: colors.gray[11], + ml: 1, + }} + > + {portsQuery.data.ports.length} + + ) : ( + + )} + + + = (props) => { margin-top: ${theme.spacing(0.5)}; `, }} - id={id} - open={isOpen} - anchorEl={anchorRef.current} - onClose={onClose} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", - }} > - - + + ); }; diff --git a/site/src/components/Resources/SSHButton/SSHButton.stories.tsx b/site/src/components/Resources/SSHButton/SSHButton.stories.tsx index 5829af000d81c..b5e451160fc60 100644 --- a/site/src/components/Resources/SSHButton/SSHButton.stories.tsx +++ b/site/src/components/Resources/SSHButton/SSHButton.stories.tsx @@ -22,7 +22,7 @@ export const Opened: Story = { args: { workspaceName: MockWorkspace.name, agentName: MockWorkspaceAgent.name, - defaultIsOpen: true, + isDefaultOpen: true, sshPrefix: "coder.", }, }; diff --git a/site/src/components/Resources/SSHButton/SSHButton.tsx b/site/src/components/Resources/SSHButton/SSHButton.tsx index 846ba5baaa1ea..d9827d07956c0 100644 --- a/site/src/components/Resources/SSHButton/SSHButton.tsx +++ b/site/src/components/Resources/SSHButton/SSHButton.tsx @@ -1,7 +1,6 @@ -import Popover from "@mui/material/Popover"; import { css } from "@emotion/css"; import { type Interpolation, type Theme, useTheme } from "@emotion/react"; -import { type FC, type PropsWithChildren, useRef, useState } from "react"; +import { type FC, type PropsWithChildren } from "react"; import { HelpTooltipLink, HelpTooltipLinksGroup, @@ -11,41 +10,35 @@ import { docs } from "utils/docs"; import { CodeExample } from "../../CodeExample/CodeExample"; import { Stack } from "../../Stack/Stack"; import { SecondaryAgentButton } from "../AgentButton"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; export interface SSHButtonProps { workspaceName: string; agentName: string; - defaultIsOpen?: boolean; + isDefaultOpen?: boolean; sshPrefix?: string; } export const SSHButton: FC> = ({ workspaceName, agentName, - defaultIsOpen = false, + isDefaultOpen = false, sshPrefix, }) => { const theme = useTheme(); - const anchorRef = useRef(null); - const [isOpen, setIsOpen] = useState(defaultIsOpen); - const id = isOpen ? "schedule-popover" : undefined; - - const onClose = () => { - setIsOpen(false); - }; return ( - <> - { - setIsOpen(true); - }} - > - SSH - + + + SSH + - > = ({ margin-top: ${theme.spacing(0.25)}; `, }} - id={id} - open={isOpen} - anchorEl={anchorRef.current} - onClose={onClose} - anchorOrigin={{ - vertical: "bottom", - horizontal: "left", - }} - transformOrigin={{ - vertical: "top", - horizontal: "left", - }} > Run the following commands to connect with SSH: @@ -107,8 +88,8 @@ export const SSHButton: FC> = ({ SSH configuration - - + + ); }; diff --git a/site/src/components/Tabs/Tabs.tsx b/site/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000000000..b7e6c6de61eb7 --- /dev/null +++ b/site/src/components/Tabs/Tabs.tsx @@ -0,0 +1,71 @@ +import { ReactNode } from "react"; +import { NavLink, NavLinkProps } from "react-router-dom"; +import { combineClasses } from "utils/combineClasses"; +import { Margins } from "components/Margins/Margins"; +import { css } from "@emotion/css"; +import { useTheme } from "@mui/material/styles"; + +export const Tabs = ({ children }: { children: ReactNode }) => { + return ( +
({ + borderBottom: `1px solid ${theme.palette.divider}`, + marginBottom: theme.spacing(5), + })} + > + ({ + display: "flex", + alignItems: "center", + gap: theme.spacing(0.25), + })} + > + {children} + +
+ ); +}; + +export const TabLink = (props: NavLinkProps) => { + const theme = useTheme(); + + const baseTabLink = css` + text-decoration: none; + color: ${theme.palette.text.secondary}; + font-size: 14px; + display: block; + padding: ${theme.spacing(0, 2, 2)}; + + &:hover { + color: ${theme.palette.text.primary}; + } + `; + + const activeTabLink = css` + color: ${theme.palette.text.primary}; + position: relative; + + &:before { + content: ""; + left: 0; + bottom: 0; + height: 2px; + width: 100%; + background: ${theme.palette.secondary.dark}; + position: absolute; + } + `; + + return ( + + combineClasses([ + baseTabLink, + isActive ? activeTabLink : undefined, + props.className as string, + ]) + } + {...props} + /> + ); +}; diff --git a/site/src/components/TemplateFiles/hooks.ts b/site/src/components/TemplateFiles/hooks.ts new file mode 100644 index 0000000000000..6d21538f8c3db --- /dev/null +++ b/site/src/components/TemplateFiles/hooks.ts @@ -0,0 +1,68 @@ +import { TemplateVersion } from "api/typesGenerated"; +import { useTab } from "hooks/useTab"; +import { useEffect } from "react"; +import { useQuery } from "react-query"; +import { + TemplateVersionFiles, + getTemplateVersionFiles, +} from "utils/templateVersion"; +import * as API from "api/api"; + +export const useFileTab = (templateFiles: TemplateVersionFiles | undefined) => { + // Tabs The default tab is the tab that has main.tf but until we loads the + // files and check if main.tf exists we don't know which tab is the default + // one so we just use empty string + const tab = useTab("file", ""); + const isLoaded = tab.value !== ""; + useEffect(() => { + if (templateFiles && !isLoaded) { + const terraformFileIndex = Object.keys(templateFiles).indexOf("main.tf"); + // If main.tf exists use the index if not just use the first tab + tab.set(terraformFileIndex !== -1 ? terraformFileIndex.toString() : "0"); + } + }, [isLoaded, tab, templateFiles]); + + return { + ...tab, + isLoaded, + }; +}; + +export const useTemplateFiles = ( + templateName: string, + version: TemplateVersion | undefined, +) => { + return useQuery({ + queryKey: ["templateFiles", templateName, version], + queryFn: () => { + if (!version) { + return; + } + return getTemplateFilesWithDiff(templateName, version); + }, + enabled: version !== undefined, + }); +}; + +const getTemplateFilesWithDiff = async ( + templateName: string, + version: TemplateVersion, +) => { + const previousVersion = await API.getPreviousTemplateVersionByName( + version.organization_id!, + templateName, + version.name, + ); + const loadFilesPromises: ReturnType[] = []; + loadFilesPromises.push(getTemplateVersionFiles(version.job.file_id)); + if (previousVersion) { + loadFilesPromises.push( + getTemplateVersionFiles(previousVersion.job.file_id), + ); + } + const [currentFiles, previousFiles] = await Promise.all(loadFilesPromises); + return { + currentFiles, + previousFiles, + }; +}; diff --git a/site/src/components/UsersLayout/UsersLayout.tsx b/site/src/components/UsersLayout/UsersLayout.tsx index 7a4d380b3e8cd..397cadc25bb60 100644 --- a/site/src/components/UsersLayout/UsersLayout.tsx +++ b/site/src/components/UsersLayout/UsersLayout.tsx @@ -1,6 +1,5 @@ import Button from "@mui/material/Button"; import Link from "@mui/material/Link"; -import { makeStyles } from "@mui/styles"; import GroupAdd from "@mui/icons-material/GroupAddOutlined"; import PersonAdd from "@mui/icons-material/PersonAddOutlined"; import { USERS_LINK } from "components/Dashboard/Navbar/NavbarView"; @@ -8,18 +7,11 @@ import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; import { useFeatureVisibility } from "hooks/useFeatureVisibility"; import { usePermissions } from "hooks/usePermissions"; import { FC } from "react"; -import { - Link as RouterLink, - NavLink, - Outlet, - useNavigate, -} from "react-router-dom"; -import { combineClasses } from "utils/combineClasses"; +import { Link as RouterLink, Outlet, useNavigate } from "react-router-dom"; import { Margins } from "components/Margins/Margins"; -import { Stack } from "components/Stack/Stack"; +import { TabLink, Tabs } from "components/Tabs/Tabs"; export const UsersLayout: FC = () => { - const styles = useStyles(); const { createUser: canCreateUser, createGroup: canCreateGroup } = usePermissions(); const navigate = useNavigate(); @@ -53,35 +45,10 @@ export const UsersLayout: FC = () => { -
- - - - combineClasses([ - styles.tabItem, - isActive ? styles.tabItemActive : undefined, - ]) - } - > - Users - - - combineClasses([ - styles.tabItem, - isActive ? styles.tabItemActive : undefined, - ]) - } - > - Groups - - - -
+ + Users + Groups + @@ -89,39 +56,3 @@ export const UsersLayout: FC = () => { ); }; - -export const useStyles = makeStyles((theme) => { - return { - tabs: { - borderBottom: `1px solid ${theme.palette.divider}`, - marginBottom: theme.spacing(5), - }, - - tabItem: { - textDecoration: "none", - color: theme.palette.text.secondary, - fontSize: 14, - display: "block", - padding: theme.spacing(0, 2, 2), - - "&:hover": { - color: theme.palette.text.primary, - }, - }, - - tabItemActive: { - color: theme.palette.text.primary, - position: "relative", - - "&:before": { - content: `""`, - left: 0, - bottom: 0, - height: 2, - width: "100%", - background: theme.palette.secondary.dark, - position: "absolute", - }, - }, - }; -}); diff --git a/site/src/components/WorkspaceDeletion/DormantDeletionStat.tsx b/site/src/components/WorkspaceDeletion/DormantDeletionStat.tsx index b8749cdde9cad..8dc7a289d21f9 100644 --- a/site/src/components/WorkspaceDeletion/DormantDeletionStat.tsx +++ b/site/src/components/WorkspaceDeletion/DormantDeletionStat.tsx @@ -14,11 +14,20 @@ interface DormantDeletionStatProps { export const DormantDeletionStat: FC = ({ workspace, }) => { - const { entitlements } = useDashboard(); + const { entitlements, experiments } = useDashboard(); const allowAdvancedScheduling = entitlements.features["advanced_template_scheduling"].enabled; + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions"); - if (!displayDormantDeletion(workspace, allowAdvancedScheduling)) { + if ( + !displayDormantDeletion( + workspace, + allowAdvancedScheduling, + allowWorkspaceActions, + ) + ) { return null; } diff --git a/site/src/components/WorkspaceDeletion/DormantDeletionText.tsx b/site/src/components/WorkspaceDeletion/DormantDeletionText.tsx index 5e7ed87962954..0d1b8692674fa 100644 --- a/site/src/components/WorkspaceDeletion/DormantDeletionText.tsx +++ b/site/src/components/WorkspaceDeletion/DormantDeletionText.tsx @@ -9,11 +9,20 @@ export const DormantDeletionText = ({ }: { workspace: Workspace; }): JSX.Element | null => { - const { entitlements } = useDashboard(); + const { entitlements, experiments } = useDashboard(); const allowAdvancedScheduling = entitlements.features["advanced_template_scheduling"].enabled; + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions"); - if (!displayDormantDeletion(workspace, allowAdvancedScheduling)) { + if ( + !displayDormantDeletion( + workspace, + allowAdvancedScheduling, + allowWorkspaceActions, + ) + ) { return null; } return Impending deletion; diff --git a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx index 9315ecc9638b6..66bfc989ae89a 100644 --- a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx +++ b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx @@ -1,5 +1,5 @@ import { Workspace } from "api/typesGenerated"; -import { useDashboard } from "components/Dashboard/DashboardProvider"; +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"; import { Alert } from "components/Alert/Alert"; import { formatDistanceToNow } from "date-fns"; import Link from "@mui/material/Link"; @@ -21,9 +21,7 @@ export const DormantWorkspaceBanner = ({ shouldRedisplayBanner: boolean; count?: Count; }): JSX.Element | null => { - const { entitlements } = useDashboard(); - const schedulingEnabled = - entitlements.features["advanced_template_scheduling"].enabled; + const experimentEnabled = useIsWorkspaceActionsEnabled(); if (!workspaces) { return null; @@ -39,7 +37,7 @@ export const DormantWorkspaceBanner = ({ if ( // Only show this if the experiment is included. - !schedulingEnabled || + !experimentEnabled || !hasDormantWorkspaces || // Banners should be redisplayed after dismissal when additional workspaces are newly scheduled for deletion !shouldRedisplayBanner diff --git a/site/src/components/WorkspaceDeletion/utils.test.ts b/site/src/components/WorkspaceDeletion/utils.test.ts index 87d32de1f832e..caca6c5661993 100644 --- a/site/src/components/WorkspaceDeletion/utils.test.ts +++ b/site/src/components/WorkspaceDeletion/utils.test.ts @@ -4,39 +4,53 @@ import { displayDormantDeletion } from "./utils"; describe("displayDormantDeletion", () => { const today = new Date(); - it.each<[string, boolean, boolean]>([ + it.each<[string, boolean, boolean, boolean]>([ [ new Date(new Date().setDate(today.getDate() + 15)).toISOString(), true, + true, false, ], // today + 15 days out [ new Date(new Date().setDate(today.getDate() + 14)).toISOString(), true, true, + true, ], // today + 14 [ new Date(new Date().setDate(today.getDate() + 13)).toISOString(), true, true, + true, ], // today + 13 [ new Date(new Date().setDate(today.getDate() + 1)).toISOString(), true, true, + true, ], // today + 1 - [new Date().toISOString(), true, true], // today + 0 - [new Date().toISOString(), false, false], // Advanced Scheduling off + [new Date().toISOString(), true, true, true], // today + 0 + [new Date().toISOString(), false, true, false], // Advanced Scheduling off + [new Date().toISOString(), true, false, false], // Workspace Actions off ])( - `deleting_at=%p, allowAdvancedScheduling=%p, shouldDisplay=%p`, - (deleting_at, allowAdvancedScheduling, shouldDisplay) => { + `deleting_at=%p, allowAdvancedScheduling=%p, AllowWorkspaceActions=%p, shouldDisplay=%p`, + ( + deleting_at, + allowAdvancedScheduling, + allowWorkspaceActions, + shouldDisplay, + ) => { const workspace: TypesGen.Workspace = { ...Mocks.MockWorkspace, deleting_at, }; - expect(displayDormantDeletion(workspace, allowAdvancedScheduling)).toBe( - shouldDisplay, - ); + expect( + displayDormantDeletion( + workspace, + allowAdvancedScheduling, + allowWorkspaceActions, + ), + ).toBe(shouldDisplay); }, ); }); diff --git a/site/src/components/WorkspaceDeletion/utils.ts b/site/src/components/WorkspaceDeletion/utils.ts index 1265647878a82..14ac74f4a00bd 100644 --- a/site/src/components/WorkspaceDeletion/utils.ts +++ b/site/src/components/WorkspaceDeletion/utils.ts @@ -14,9 +14,14 @@ const IMPENDING_DELETION_DISPLAY_THRESHOLD = 14; // 14 days export const displayDormantDeletion = ( workspace: Workspace, allowAdvancedScheduling: boolean, + allowWorkspaceActions: boolean, ) => { const today = new Date(); - if (!workspace.deleting_at || !allowAdvancedScheduling) { + if ( + !workspace.deleting_at || + !allowAdvancedScheduling || + !allowWorkspaceActions + ) { return false; } return ( diff --git a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx index 96567948d97ca..3fcdb594989fb 100644 --- a/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/DuplicateTemplateView.tsx @@ -1,6 +1,6 @@ import { useQuery, useMutation } from "react-query"; -import { templateVersionLogs } from "api/queries/templateVersions"; import { + templateVersionLogs, templateByName, templateVersion, templateVersionVariables, diff --git a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx index 9854a62cae2d0..38e596b9081e1 100644 --- a/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/ImportStarterTemplateView.tsx @@ -1,6 +1,6 @@ import { useQuery, useMutation } from "react-query"; -import { templateVersionLogs } from "api/queries/templateVersions"; import { + templateVersionLogs, JobError, createTemplate, templateExamples, diff --git a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx index a337b82a12404..b3eb11a7c1c3c 100644 --- a/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx +++ b/site/src/pages/CreateTemplatePage/UploadTemplateView.tsx @@ -1,6 +1,6 @@ import { useQuery, useMutation } from "react-query"; -import { templateVersionLogs } from "api/queries/templateVersions"; import { + templateVersionLogs, JobError, createTemplate, templateVersionVariables, diff --git a/site/src/pages/CreateTemplatePage/utils.ts b/site/src/pages/CreateTemplatePage/utils.ts index 2e525eae7a29e..fdeb1046d3869 100644 --- a/site/src/pages/CreateTemplatePage/utils.ts +++ b/site/src/pages/CreateTemplatePage/utils.ts @@ -37,6 +37,7 @@ export const newTemplate = (formData: CreateTemplateData) => { autostart_requirement: { days_of_week: autostart_requirement_days_of_week, }, + require_active_version: false, }; }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 94e8a55f16800..2c6cff4807e81 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -22,11 +22,11 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { templateByName, templateVersionExternalAuth, + richParameters, } from "api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; import { checkAuthorization } from "api/queries/authCheck"; import { CreateWSPermissions, createWorkspaceChecks } from "./permissions"; -import { richParameters } from "api/queries/templateVersions"; import { paramsUsedToCreateWorkspace } from "utils/workspace"; import { useEffectEvent } from "hooks/hookPolyfills"; diff --git a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx index 41dde1b8420ab..c1e61ac74832b 100644 --- a/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx +++ b/site/src/pages/CreateWorkspacePage/ExternalAuth.tsx @@ -3,9 +3,9 @@ import Button from "@mui/material/Button"; import FormHelperText from "@mui/material/FormHelperText"; import Tooltip from "@mui/material/Tooltip"; import { type FC } from "react"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; import { Stack } from "components/Stack/Stack"; import { type ExternalAuthPollingState } from "./CreateWorkspacePage"; +import LoadingButton from "@mui/lab/LoadingButton"; export interface ExternalAuthProps { displayName: string; @@ -34,6 +34,7 @@ export const ExternalAuth: FC = (props) => { > = (props) => { {`${displayName} } disabled={authenticated} diff --git a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx index d5aabf85d77ed..12d47d738ef5c 100644 --- a/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/LicensesSettingsPage/LicensesSettingsPageView.tsx @@ -13,7 +13,7 @@ import { Link } from "react-router-dom"; import useWindowSize from "react-use/lib/useWindowSize"; import MuiLink from "@mui/material/Link"; import Tooltip from "@mui/material/Tooltip"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; +import LoadingButton from "@mui/lab/LoadingButton"; type Props = { showConfetti: boolean; @@ -72,6 +72,7 @@ const LicensesSettingsPageView: FC = ({ } diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index cc759d36b3afc..e4113dc0d4b68 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -13,7 +13,6 @@ import { AvatarData } from "components/AvatarData/AvatarData"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; import { EmptyState } from "components/EmptyState/EmptyState"; import { Loader } from "components/Loader/Loader"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; import { Margins } from "components/Margins/Margins"; import { PageHeader, @@ -44,6 +43,10 @@ import { } from "api/queries/groups"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { getErrorMessage } from "api/errors"; +import Box from "@mui/material/Box"; +import { LastSeen } from "components/LastSeen/LastSeen"; +import { type Interpolation, type Theme } from "@emotion/react"; +import LoadingButton from "@mui/lab/LoadingButton"; export const GroupPage: FC = () => { const { groupId } = useParams() as { groupId: string }; @@ -150,7 +153,8 @@ export const GroupPage: FC = () => { - User + User + Status @@ -235,6 +239,7 @@ const AddGroupMember: React.FC<{ /> } @@ -258,7 +263,7 @@ const GroupMemberRow = (props: { return ( - + + + {member.status} + + {canUpdate && ( ({ }, })); +const styles = { + status: { + textTransform: "capitalize", + }, + suspended: (theme) => ({ + color: theme.palette.text.secondary, + }), +} satisfies Record>; + export default GroupPage; diff --git a/site/src/pages/LoginPage/PasswordSignInForm.tsx b/site/src/pages/LoginPage/PasswordSignInForm.tsx index 3703e78044109..fe3252bfa1ccc 100644 --- a/site/src/pages/LoginPage/PasswordSignInForm.tsx +++ b/site/src/pages/LoginPage/PasswordSignInForm.tsx @@ -1,12 +1,12 @@ import { Stack } from "components/Stack/Stack"; import TextField from "@mui/material/TextField"; import { getFormHelpers, onChangeTrimmed } from "utils/formUtils"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; import { Language } from "./SignInForm"; import { FormikContextType, FormikTouched, useFormik } from "formik"; import * as Yup from "yup"; import { FC } from "react"; import { BuiltInAuthFormValues } from "./SignInForm.types"; +import LoadingButton from "@mui/lab/LoadingButton"; type PasswordSignInFormProps = { onSubmit: (credentials: { email: string; password: string }) => void; @@ -66,7 +66,7 @@ export const PasswordSignInForm: FC = ({ fullWidth type="submit" > - {isSigningIn ? "" : Language.passwordSignIn} + {Language.passwordSignIn} diff --git a/site/src/pages/SetupPage/SetupPageView.tsx b/site/src/pages/SetupPage/SetupPageView.tsx index e07a0185dbe2f..43830ee701d77 100644 --- a/site/src/pages/SetupPage/SetupPageView.tsx +++ b/site/src/pages/SetupPage/SetupPageView.tsx @@ -2,7 +2,6 @@ import Box from "@mui/material/Box"; import Checkbox from "@mui/material/Checkbox"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; import { SignInLayout } from "components/SignInLayout/SignInLayout"; import { Stack } from "components/Stack/Stack"; import { Welcome } from "components/Welcome/Welcome"; @@ -14,6 +13,7 @@ import { } from "utils/formUtils"; import * as Yup from "yup"; import type * as TypesGen from "api/typesGenerated"; +import LoadingButton from "@mui/lab/LoadingButton"; export const Language = { emailLabel: "Email", diff --git a/site/src/pages/TemplatePage/TemplateFilesPage/TemplateFilesPage.tsx b/site/src/pages/TemplatePage/TemplateFilesPage/TemplateFilesPage.tsx index d5398d7bc3b03..1a1cd9b244b10 100644 --- a/site/src/pages/TemplatePage/TemplateFilesPage/TemplateFilesPage.tsx +++ b/site/src/pages/TemplatePage/TemplateFilesPage/TemplateFilesPage.tsx @@ -1,77 +1,14 @@ -import { useQuery } from "react-query"; -import { getPreviousTemplateVersionByName } from "api/api"; -import { TemplateVersion } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; import { TemplateFiles } from "components/TemplateFiles/TemplateFiles"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; -import { useOrganizationId } from "hooks/useOrganizationId"; -import { useTab } from "hooks/useTab"; -import { FC, useEffect } from "react"; +import { FC } from "react"; import { Helmet } from "react-helmet-async"; -import { - getTemplateVersionFiles, - TemplateVersionFiles, -} from "utils/templateVersion"; import { getTemplatePageTitle } from "../utils"; - -const fetchTemplateFiles = async ( - organizationId: string, - templateName: string, - activeVersion: TemplateVersion, -) => { - const previousVersion = await getPreviousTemplateVersionByName( - organizationId, - templateName, - activeVersion.name, - ); - const loadFilesPromises: ReturnType[] = []; - loadFilesPromises.push(getTemplateVersionFiles(activeVersion)); - if (previousVersion) { - loadFilesPromises.push(getTemplateVersionFiles(previousVersion)); - } - const [currentFiles, previousFiles] = await Promise.all(loadFilesPromises); - return { - currentFiles, - previousFiles, - }; -}; - -const useTemplateFiles = ( - organizationId: string, - templateName: string, - activeVersion: TemplateVersion, -) => - useQuery({ - queryKey: ["templateFiles", templateName], - queryFn: () => - fetchTemplateFiles(organizationId, templateName, activeVersion), - }); - -const useFileTab = (templateFiles: TemplateVersionFiles | undefined) => { - // Tabs The default tab is the tab that has main.tf but until we loads the - // files and check if main.tf exists we don't know which tab is the default - // one so we just use empty string - const tab = useTab("file", ""); - const isLoaded = tab.value !== ""; - useEffect(() => { - if (templateFiles && !isLoaded) { - const terraformFileIndex = Object.keys(templateFiles).indexOf("main.tf"); - // If main.tf exists use the index if not just use the first tab - tab.set(terraformFileIndex !== -1 ? terraformFileIndex.toString() : "0"); - } - }, [isLoaded, tab, templateFiles]); - - return { - ...tab, - isLoaded, - }; -}; +import { useFileTab, useTemplateFiles } from "components/TemplateFiles/hooks"; const TemplateFilesPage: FC = () => { const { template, activeVersion } = useTemplateLayoutContext(); - const orgId = useOrganizationId(); const { data: templateFiles } = useTemplateFiles( - orgId, template.name, activeVersion, ); diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx index 9e0194e98d8df..aa6d43bc9a298 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx @@ -5,7 +5,6 @@ import "react-date-range/dist/styles.css"; import "react-date-range/dist/theme/default.css"; import Button from "@mui/material/Button"; import ArrowRightAltOutlined from "@mui/icons-material/ArrowRightAltOutlined"; -import Popover from "@mui/material/Popover"; import { DateRangePicker, createStaticRanges } from "react-date-range"; import { addDays, @@ -16,6 +15,11 @@ import { startOfHour, subDays, } from "date-fns"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; // The type definition from @types is wrong declare module "react-date-range" { @@ -41,8 +45,6 @@ export const DateRange = ({ onChange: (value: DateRangeValue) => void; }) => { const selectionStatusRef = useRef<"idle" | "selecting">("idle"); - const anchorRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); const [ranges, setRanges] = useState([ { ...value, @@ -53,105 +55,91 @@ export const DateRange = ({ startDate: ranges[0].startDate as Date, endDate: ranges[0].endDate as Date, }; - const handleClose = () => { - const now = new Date(); - onChange({ - startDate: startOfDay(currentRange.startDate), - endDate: isToday(currentRange.endDate) - ? startOfHour(addHours(now, 1)) - : startOfDay(addDays(currentRange.endDate, 1)), - }); - setIsOpen(false); - }; return ( - <> - - - { - const range = item.selection; - setRanges([range]); - - // When it is the first selection, we don't want to close the popover - // We have to do that ourselves because the library doesn't provide a way to do it - if (selectionStatusRef.current === "idle") { - selectionStatusRef.current = "selecting"; - return; - } - - selectionStatusRef.current = "idle"; - const startDate = range.startDate as Date; - const endDate = range.endDate as Date; - onChange({ - startDate, - endDate, - }); - setIsOpen(false); - }} - moveRangeOnFirstSelection={false} - months={2} - ranges={ranges} - maxDate={new Date()} - direction="horizontal" - staticRanges={createStaticRanges([ - { - label: "Today", - range: () => ({ - startDate: new Date(), - endDate: new Date(), - }), - }, - { - label: "Yesterday", - range: () => ({ - startDate: subDays(new Date(), 1), - endDate: subDays(new Date(), 1), - }), - }, - { - label: "Last 7 days", - range: () => ({ - startDate: subDays(new Date(), 6), - endDate: new Date(), - }), - }, - { - label: "Last 14 days", - range: () => ({ - startDate: subDays(new Date(), 13), - endDate: new Date(), - }), - }, - { - label: "Last 30 days", - range: () => ({ - startDate: subDays(new Date(), 29), - endDate: new Date(), - }), - }, - ])} - /> - - + + {(popover) => ( + <> + + + + + { + const range = item.selection; + setRanges([range]); + + // When it is the first selection, we don't want to close the popover + // We have to do that ourselves because the library doesn't provide a way to do it + if (selectionStatusRef.current === "idle") { + selectionStatusRef.current = "selecting"; + return; + } + + selectionStatusRef.current = "idle"; + const startDate = range.startDate as Date; + const endDate = range.endDate as Date; + const now = new Date(); + onChange({ + startDate: startOfDay(startDate), + endDate: isToday(endDate) + ? startOfHour(addHours(now, 1)) + : startOfDay(addDays(endDate, 1)), + }); + popover.setIsOpen(false); + }} + moveRangeOnFirstSelection={false} + months={2} + ranges={ranges} + maxDate={new Date()} + direction="horizontal" + staticRanges={createStaticRanges([ + { + label: "Today", + range: () => ({ + startDate: new Date(), + endDate: new Date(), + }), + }, + { + label: "Yesterday", + range: () => ({ + startDate: subDays(new Date(), 1), + endDate: subDays(new Date(), 1), + }), + }, + { + label: "Last 7 days", + range: () => ({ + startDate: subDays(new Date(), 6), + endDate: new Date(), + }), + }, + { + label: "Last 14 days", + range: () => ({ + startDate: subDays(new Date(), 13), + endDate: new Date(), + }), + }, + { + label: "Last 30 days", + range: () => ({ + startDate: subDays(new Date(), 29), + endDate: new Date(), + }), + }, + ])} + /> + + + )} + ); }; diff --git a/site/src/pages/TemplatePage/TemplateLayout.tsx b/site/src/pages/TemplatePage/TemplateLayout.tsx index 24f4487048320..41a15264c9a7b 100644 --- a/site/src/pages/TemplatePage/TemplateLayout.tsx +++ b/site/src/pages/TemplatePage/TemplateLayout.tsx @@ -1,8 +1,7 @@ -import { css } from "@emotion/css"; import { useTheme } from "@emotion/react"; import { createContext, type FC, Suspense, useContext } from "react"; import { useQuery } from "react-query"; -import { NavLink, Outlet, useNavigate, useParams } from "react-router-dom"; +import { Outlet, useNavigate, useParams } from "react-router-dom"; import type { AuthorizationRequest } from "api/typesGenerated"; import { checkAuthorization, @@ -11,10 +10,10 @@ import { } from "api/api"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Margins } from "components/Margins/Margins"; -import { Stack } from "components/Stack/Stack"; import { Loader } from "components/Loader/Loader"; import { useOrganizationId } from "hooks/useOrganizationId"; import { TemplatePageHeader } from "./TemplatePageHeader"; +import { TabLink, Tabs } from "components/Tabs/Tabs"; const templatePermissions = ( templateId: string, @@ -85,34 +84,6 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({ return ; } - const itemStyles = css` - text-decoration: none; - color: ${theme.palette.text.secondary}; - font-size: 14; - display: block; - padding: ${theme.spacing(0, 2, 2)}; - - &:hover { - color: ${theme.palette.text.primary}; - } - `; - - const activeItemStyles = css` - ${itemStyles} - color: ${theme.palette.text.primary}; - position: relative; - - &:before { - content: ""; - left: 0; - bottom: 0; - height: 2; - width: 100%; - background: ${theme.palette.secondary.dark}; - position: absolute; - } - `; - return ( <> = ({ }} /> -
- - - - isActive ? activeItemStyles : itemStyles - } - > - Summary - - - isActive ? activeItemStyles : itemStyles - } - > - Docs - - {data.permissions.canUpdateTemplate && ( - - isActive ? activeItemStyles : itemStyles - } - > - Source Code - - )} - - isActive ? activeItemStyles : itemStyles - } - > - Versions - - - isActive ? activeItemStyles : itemStyles - } - > - Embed - - {shouldShowInsights && ( - - isActive ? activeItemStyles : itemStyles - } - > - Insights - - )} - - -
+ + + Summary + + Docs + {data.permissions.canUpdateTemplate && ( + Source Code + )} + Versions + Embed + {shouldShowInsights && ( + Insights + )} + diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 09f376a83420e..ea94ef0982ff6 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -37,6 +37,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => ), allow_user_cancel_workspace_jobs: Yup.boolean(), icon: iconValidator, + require_active_version: Yup.boolean(), }); export interface TemplateSettingsForm { @@ -47,6 +48,7 @@ export interface TemplateSettingsForm { error?: unknown; // Helpful to show field errors on Storybook initialTouched?: FormikTouched; + accessControlEnabled: boolean; } export const TemplateSettingsForm: FC = ({ @@ -56,6 +58,7 @@ export const TemplateSettingsForm: FC = ({ error, isSubmitting, initialTouched, + accessControlEnabled, }) => { const validationSchema = getValidationSchema(); const form: FormikContextType = @@ -69,6 +72,7 @@ export const TemplateSettingsForm: FC = ({ template.allow_user_cancel_workspace_jobs, update_workspace_last_used_at: false, update_workspace_dormant_at: false, + require_active_version: template.require_active_version, }, validationSchema, onSubmit, @@ -134,38 +138,72 @@ export const TemplateSettingsForm: FC = ({ title="Operations" description="Regulate actions allowed on workspaces created from this template." > - + {accessControlEnabled && ( + + )} + diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 8df20b8c3d399..c9f2059672fce 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -43,6 +43,7 @@ const validFormValues: FormValues = { time_til_dormant_autodelete_ms: 0, update_workspace_last_used_at: false, update_workspace_dormant_at: false, + require_active_version: false, }; const renderTemplateSettingsPage = async () => { diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx index aaef6bddaf659..d483fb7ad053f 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx @@ -10,6 +10,7 @@ import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplateSettingsPageView } from "./TemplateSettingsPageView"; import { templateByNameKey } from "api/queries/templates"; import { useOrganizationId } from "hooks"; +import { useDashboard } from "components/Dashboard/DashboardProvider"; export const TemplateSettingsPage: FC = () => { const { template: templateName } = useParams() as { template: string }; @@ -17,6 +18,11 @@ export const TemplateSettingsPage: FC = () => { const orgId = useOrganizationId(); const { template } = useTemplateSettings(); const queryClient = useQueryClient(); + const { entitlements, experiments } = useDashboard(); + const accessControlEnabled = + entitlements.features["advanced_template_scheduling"].enabled && + experiments.includes("template_update_policies"); + const { mutate: updateTemplate, isLoading: isSubmitting, @@ -51,6 +57,7 @@ export const TemplateSettingsPage: FC = () => { ...templateSettings, }); }} + accessControlEnabled={accessControlEnabled} /> ); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx index 2112b25fb979e..5eac1759d5ca2 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx @@ -12,6 +12,7 @@ export interface TemplateSettingsPageViewProps { initialTouched?: ComponentProps< typeof TemplateSettingsForm >["initialTouched"]; + accessControlEnabled: boolean; } export const TemplateSettingsPageView: FC = ({ @@ -21,6 +22,7 @@ export const TemplateSettingsPageView: FC = ({ isSubmitting, submitError, initialTouched, + accessControlEnabled, }) => { return ( <> @@ -35,6 +37,7 @@ export const TemplateSettingsPageView: FC = ({ onSubmit={onSubmit} onCancel={onCancel} error={submitError} + accessControlEnabled={accessControlEnabled} /> ); diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index 898202bd51b7c..6cf11ec1faf62 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -1,7 +1,6 @@ import Button from "@mui/material/Button"; import Link from "@mui/material/Link"; import ArrowRightAltOutlined from "@mui/icons-material/ArrowRightAltOutlined"; -import { useMachine } from "@xstate/react"; import { Paywall } from "components/Paywall/Paywall"; import { Stack } from "components/Stack/Stack"; import { useFeatureVisibility } from "hooks/useFeatureVisibility"; @@ -9,10 +8,12 @@ import { useOrganizationId } from "hooks/useOrganizationId"; import { FC } from "react"; import { Helmet } from "react-helmet-async"; import { pageTitle } from "utils/page"; -import { templateACLMachine } from "xServices/template/templateACLXService"; import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView"; import { docs } from "utils/docs"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { setGroupRole, setUserRole, templateACL } from "api/queries/templates"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; export const TemplatePermissionsPage: FC< React.PropsWithChildren @@ -20,10 +21,16 @@ export const TemplatePermissionsPage: FC< const organizationId = useOrganizationId(); const { template, permissions } = useTemplateSettings(); const { template_rbac: isTemplateRBACEnabled } = useFeatureVisibility(); - const [state, send] = useMachine(templateACLMachine, { - context: { templateId: template.id }, - }); - const { templateACL, userToBeUpdated, groupToBeUpdated } = state.context; + const templateACLQuery = useQuery(templateACL(template.id)); + const queryClient = useQueryClient(); + + const addUserMutation = useMutation(setUserRole(queryClient)); + const updateUserMutation = useMutation(setUserRole(queryClient)); + const removeUserMutation = useMutation(setUserRole(queryClient)); + + const addGroupMutation = useMutation(setGroupRole(queryClient)); + const updateGroupMutation = useMutation(setGroupRole(queryClient)); + const removeGroupMutation = useMutation(setGroupRole(queryClient)); return ( <> @@ -58,29 +65,67 @@ export const TemplatePermissionsPage: FC< { - send("ADD_USER", { user, role, onDone: reset }); + onAddUser={async (user, role, reset) => { + await addUserMutation.mutateAsync({ + templateId: template.id, + userId: user.id, + role, + }); + reset(); }} - isAddingUser={state.matches("addingUser")} - onUpdateUser={(user, role) => { - send("UPDATE_USER_ROLE", { user, role }); + isAddingUser={addUserMutation.isLoading} + onUpdateUser={async (user, role) => { + await updateUserMutation.mutateAsync({ + templateId: template.id, + userId: user.id, + role, + }); + displaySuccess("User role updated successfully!"); }} - updatingUser={userToBeUpdated} - onRemoveUser={(user) => { - send("REMOVE_USER", { user }); + updatingUserId={ + updateUserMutation.isLoading + ? updateUserMutation.variables?.userId + : undefined + } + onRemoveUser={async (user) => { + await removeUserMutation.mutateAsync({ + templateId: template.id, + userId: user.id, + role: "", + }); + displaySuccess("User removed successfully!"); }} - onAddGroup={(group, role, reset) => { - send("ADD_GROUP", { group, role, onDone: reset }); + onAddGroup={async (group, role, reset) => { + await addGroupMutation.mutateAsync({ + templateId: template.id, + groupId: group.id, + role, + }); + reset(); }} - isAddingGroup={state.matches("addingGroup")} - onUpdateGroup={(group, role) => { - send("UPDATE_GROUP_ROLE", { group, role }); + isAddingGroup={addGroupMutation.isLoading} + onUpdateGroup={async (group, role) => { + await updateGroupMutation.mutateAsync({ + templateId: template.id, + groupId: group.id, + role, + }); + displaySuccess("Group role updated successfully!"); }} - updatingGroup={groupToBeUpdated} - onRemoveGroup={(group) => { - send("REMOVE_GROUP", { group }); + updatingGroupId={ + updateGroupMutation.isLoading + ? updateGroupMutation.variables?.groupId + : undefined + } + onRemoveGroup={async (group) => { + await removeGroupMutation.mutateAsync({ + groupId: group.id, + templateId: template.id, + role: "", + }); + displaySuccess("Group removed successfully!"); }} /> )} diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx index e106242ccc443..1ed7cc69c9138 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -18,18 +18,18 @@ import type { import { AvatarData } from "components/AvatarData/AvatarData"; import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"; import { EmptyState } from "components/EmptyState/EmptyState"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; import { Stack } from "components/Stack/Stack"; import { TableLoader } from "components/TableLoader/TableLoader"; import { TableRowMenu } from "components/TableRowMenu/TableRowMenu"; import { UserOrGroupAutocomplete, UserOrGroupAutocompleteValue, -} from "components/UserOrGroupAutocomplete/UserOrGroupAutocomplete"; +} from "./UserOrGroupAutocomplete"; import { type FC, useState } from "react"; import { GroupAvatar } from "components/GroupAvatar/GroupAvatar"; import { getGroupSubtitle } from "utils/groups"; import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"; +import LoadingButton from "@mui/lab/LoadingButton"; type AddTemplateUserOrGroupProps = { organizationId: string; @@ -46,7 +46,6 @@ type AddTemplateUserOrGroupProps = { const AddTemplateUserOrGroup: React.FC = ({ isLoading, onSubmit, - organizationId, templateID, templateACL, }) => { @@ -82,7 +81,6 @@ const AddTemplateUserOrGroup: React.FC = ({ { @@ -108,6 +106,7 @@ const AddTemplateUserOrGroup: React.FC = ({ } @@ -161,7 +160,7 @@ export interface TemplatePermissionsPageViewProps { ) => void; isAddingUser: boolean; onUpdateUser: (user: TemplateUser, role: TemplateRole) => void; - updatingUser: TemplateUser | undefined; + updatingUserId: TemplateUser["id"] | undefined; onRemoveUser: (user: TemplateUser) => void; // Group onAddGroup: ( @@ -171,7 +170,7 @@ export interface TemplatePermissionsPageViewProps { ) => void; isAddingGroup: boolean; onUpdateGroup: (group: TemplateGroup, role: TemplateRole) => void; - updatingGroup: TemplateGroup | undefined; + updatingGroupId?: TemplateGroup["id"] | undefined; onRemoveGroup: (group: Group) => void; } @@ -185,13 +184,13 @@ export const TemplatePermissionsPageView: FC< // User onAddUser, isAddingUser, - updatingUser, + updatingUserId, onUpdateUser, onRemoveUser, // Group onAddGroup, isAddingGroup, - updatingGroup, + updatingGroupId, onUpdateGroup, onRemoveGroup, }) => { @@ -265,9 +264,7 @@ export const TemplatePermissionsPageView: FC< { onUpdateGroup( group, @@ -313,9 +310,7 @@ export const TemplatePermissionsPageView: FC< { onUpdateUser( user, diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx similarity index 67% rename from site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx rename to site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx index 207a3366f7ea5..70a3001d73b5f 100644 --- a/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -1,83 +1,85 @@ import CircularProgress from "@mui/material/CircularProgress"; import TextField from "@mui/material/TextField"; import Autocomplete from "@mui/material/Autocomplete"; -import { useMachine } from "@xstate/react"; import Box from "@mui/material/Box"; import { type ChangeEvent, useState } from "react"; import { css } from "@emotion/react"; import type { Group, User } from "api/typesGenerated"; import { AvatarData } from "components/AvatarData/AvatarData"; import { getGroupSubtitle } from "utils/groups"; -import { searchUsersAndGroupsMachine } from "xServices/template/searchUsersAndGroupsXService"; import { useDebouncedFunction } from "hooks/debounce"; +import { useQuery } from "react-query"; +import { templaceACLAvailable } from "api/queries/templates"; +import { prepareQuery } from "utils/filters"; export type UserOrGroupAutocompleteValue = User | Group | null; -const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => { - return value !== null && "members" in value; -}; - export type UserOrGroupAutocompleteProps = { value: UserOrGroupAutocompleteValue; onChange: (value: UserOrGroupAutocompleteValue) => void; - organizationId: string; - templateID?: string; + templateID: string; exclude: UserOrGroupAutocompleteValue[]; }; -const autoCompleteStyles = css` - width: 300px; - - & .MuiFormControl-root { - width: 100%; - } - - & .MuiInputBase-root { - width: 100%; - } -`; - export const UserOrGroupAutocomplete: React.FC< UserOrGroupAutocompleteProps -> = ({ value, onChange, organizationId, templateID, exclude }) => { - const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false); - const [searchState, sendSearch] = useMachine(searchUsersAndGroupsMachine, { - context: { - userResults: [], - groupResults: [], - organizationId, - templateID, - }, +> = ({ value, onChange, templateID, exclude }) => { + const [autoComplete, setAutoComplete] = useState<{ + value: string; + open: boolean; + }>({ + value: "", + open: false, }); - const { userResults, groupResults } = searchState.context; - const options = [...groupResults, ...userResults].filter((result) => { - const excludeIds = exclude.map((optionToExclude) => optionToExclude?.id); - return !excludeIds.includes(result.id); + const aclAvailableQuery = useQuery({ + ...templaceACLAvailable(templateID, { + q: prepareQuery(encodeURI(autoComplete.value)), + limit: 25, + }), + enabled: autoComplete.open, + keepPreviousData: true, }); + const options = aclAvailableQuery.data + ? [ + ...aclAvailableQuery.data.groups, + ...aclAvailableQuery.data.users, + ].filter((result) => { + const excludeIds = exclude.map( + (optionToExclude) => optionToExclude?.id, + ); + return !excludeIds.includes(result.id); + }) + : []; const { debounced: handleFilterChange } = useDebouncedFunction( (event: ChangeEvent) => { - sendSearch("SEARCH", { query: event.target.value }); + setAutoComplete((state) => ({ + ...state, + value: event.target.value, + })); }, 500, ); return ( { - setIsAutocompleteOpen(true); + setAutoComplete((state) => ({ + ...state, + open: true, + })); }} onClose={() => { - setIsAutocompleteOpen(false); + setAutoComplete({ + value: isGroup(value) ? value.display_name : value?.email ?? "", + open: false, + }); }} onChange={(_, newValue) => { - if (newValue === null) { - sendSearch("CLEAR_RESULTS"); - } - onChange(newValue); }} isOptionEqualToValue={(option, value) => option.id === value.id} @@ -102,7 +104,7 @@ export const UserOrGroupAutocomplete: React.FC< ); }} options={options} - loading={searchState.matches("searching")} + loading={aclAvailableQuery.isFetching} css={autoCompleteStyles} renderInput={(params) => ( <> @@ -116,7 +118,7 @@ export const UserOrGroupAutocomplete: React.FC< onChange: handleFilterChange, endAdornment: ( <> - {searchState.matches("searching") ? ( + {aclAvailableQuery.isFetching ? ( ) : null} {params.InputProps.endAdornment} @@ -129,3 +131,19 @@ export const UserOrGroupAutocomplete: React.FC< /> ); }; + +const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => { + return value !== null && "members" in value; +}; + +const autoCompleteStyles = css` + width: 300px; + + & .MuiFormControl-root { + width: 100%; + } + + & .MuiInputBase-root { + width: 100%; + } +`; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 54d558467753d..912cc070ccb79 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -54,6 +54,7 @@ export interface TemplateScheduleForm { isSubmitting: boolean; error?: unknown; allowAdvancedScheduling: boolean; + allowWorkspaceActions: boolean; allowAutostopRequirement: boolean; // Helpful to show field errors on Storybook initialTouched?: FormikTouched; @@ -65,6 +66,7 @@ export const TemplateScheduleForm: FC = ({ onCancel, error, allowAdvancedScheduling, + allowWorkspaceActions, allowAutostopRequirement, isSubmitting, initialTouched, @@ -113,6 +115,7 @@ export const TemplateScheduleForm: FC = ({ Boolean(template.time_til_dormant_autodelete_ms), update_workspace_last_used_at: false, update_workspace_dormant_at: false, + require_active_version: false, }, validationSchema, onSubmit: () => { @@ -229,6 +232,7 @@ export const TemplateScheduleForm: FC = ({ allow_user_autostop: form.values.allow_user_autostop, update_workspace_last_used_at: form.values.update_workspace_last_used_at, update_workspace_dormant_at: form.values.update_workspace_dormant_at, + require_active_version: false, }); }; @@ -489,7 +493,7 @@ export const TemplateScheduleForm: FC = ({ - {allowAdvancedScheduling && ( + {allowAdvancedScheduling && allowWorkspaceActions && ( <> { jest .spyOn(API, "getEntitlements") .mockResolvedValue(MockEntitlementsWithScheduling); + + // remove when https://github.com/coder/coder/milestone/19 is completed. + jest.spyOn(API, "getExperiments").mockResolvedValue(["workspace_actions"]); }); it("Calls the API when user fills in and submits a form", async () => { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx index 4792e8a86a1b1..ce8db54909ed9 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx @@ -18,11 +18,12 @@ const TemplateSchedulePage: FC = () => { const queryClient = useQueryClient(); const orgId = useOrganizationId(); const { template } = useTemplateSettings(); - const { entitlements } = useDashboard(); + const { entitlements, experiments } = useDashboard(); const allowAdvancedScheduling = entitlements.features["advanced_template_scheduling"].enabled; // This check can be removed when https://github.com/coder/coder/milestone/19 // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions"); const allowAutostopRequirement = entitlements.features["template_autostop_requirement"].enabled; const { clearLocal } = useLocalStorage(); @@ -53,6 +54,7 @@ const TemplateSchedulePage: FC = () => { ; const defaultArgs = { allowAdvancedScheduling: true, + allowWorkspaceActions: true, template: MockTemplate, onSubmit: action("onSubmit"), onCancel: action("cancel"), diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx index 3e32ae0c20bf3..c75dab222ad78 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx @@ -13,6 +13,7 @@ export interface TemplateSchedulePageViewProps { typeof TemplateScheduleForm >["initialTouched"]; allowAdvancedScheduling: boolean; + allowWorkspaceActions: boolean; allowAutostopRequirement: boolean; } @@ -22,6 +23,7 @@ export const TemplateSchedulePageView: FC = ({ onSubmit, isSubmitting, allowAdvancedScheduling, + allowWorkspaceActions, allowAutostopRequirement, submitError, initialTouched, @@ -34,6 +36,7 @@ export const TemplateSchedulePageView: FC = ({ { const { version: versionName, template: templateName } = useParams() as Params; const orgId = useOrganizationId(); - const [state] = useMachine(templateVersionMachine, { - context: { templateName, versionName, orgId }, - }); - const tab = useTab("file", "0"); + const templateVersionQuery = useQuery( + templateVersionByName(orgId, templateName, versionName), + ); + const { data: templateFiles, error: templateFilesError } = useTemplateFiles( + templateName, + templateVersionQuery.data, + ); + const tab = useFileTab(templateFiles?.currentFiles); const permissions = usePermissions(); - - const versionId = state.context.currentVersion?.id; + const versionId = templateVersionQuery.data?.id; const createWorkspaceUrl = useMemo(() => { const params = new URLSearchParams(); if (versionId) { @@ -41,7 +44,10 @@ export const TemplateVersionPage: FC = () => { = { @@ -59,13 +55,11 @@ export const Default: Story = {}; export const Error: Story = { args: { - context: { - ...defaultArgs.context, - currentVersion: undefined, - currentFiles: undefined, - error: mockApiError({ - message: "Error on loading the template version", - }), - }, + ...defaultArgs, + currentVersion: undefined, + currentFiles: undefined, + error: mockApiError({ + message: "Error on loading the template version", + }), }, }; diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx index 2238f9ccddfec..44107caffeef6 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionPageView.tsx @@ -16,29 +16,31 @@ import { UseTabResult } from "hooks/useTab"; import { type FC } from "react"; import { Link as RouterLink } from "react-router-dom"; import { createDayString } from "utils/createDayString"; -import { TemplateVersionMachineContext } from "xServices/templateVersion/templateVersionXService"; import { ErrorAlert } from "components/Alert/ErrorAlert"; +import { TemplateVersion } from "api/typesGenerated"; +import { TemplateVersionFiles } from "utils/templateVersion"; export interface TemplateVersionPageViewProps { - /** - * Used to display the version name before loading the version in the API - */ versionName: string; templateName: string; tab: UseTabResult; - context: TemplateVersionMachineContext; createWorkspaceUrl?: string; + error: unknown; + currentVersion: TemplateVersion | undefined; + currentFiles: TemplateVersionFiles | undefined; + previousFiles: TemplateVersionFiles | undefined; } export const TemplateVersionPageView: FC = ({ - context, tab, versionName, templateName, createWorkspaceUrl, + currentVersion, + currentFiles, + previousFiles, + error, }) => { - const { currentFiles, error, currentVersion, previousFiles } = context; - return ( { const { container } = await renderTerminal(); // Then - await ws.connected; + // Ideally we could use ws.connected but that seems to pause React updates. + // For now, wait for the initial resize message instead. + await ws.nextMessage; ws.send(text); await expectTerminalText(container, text); ws.close(); }); + // Ideally we could just pass the correct size in the web socket URL without + // having to resize separately afterward (and then we could delete also this + // test), but we need the initial resize message to have something to wait for + // in the other tests since ws.connected appears to pause React updates. So + // for now the initial resize message (and this test) are here to stay. it("resizes on connect", async () => { // Given const ws = new WS( @@ -143,7 +150,6 @@ describe("TerminalPage", () => { await renderTerminal(); // Then - await ws.connected; const msg = await ws.nextMessage; const req = JSON.parse(new TextDecoder().decode(msg as Uint8Array)); expect(req.height).toBeGreaterThan(0); @@ -164,7 +170,9 @@ describe("TerminalPage", () => { ); // Then - await ws.connected; + // Ideally we could use ws.connected but that seems to pause React updates. + // For now, wait for the initial resize message instead. + await ws.nextMessage; ws.send(text); await expectTerminalText(container, text); ws.close(); diff --git a/site/src/pages/TerminalPage/TerminalPage.tsx b/site/src/pages/TerminalPage/TerminalPage.tsx index dfc52c8a91e24..c3844fe051cd6 100644 --- a/site/src/pages/TerminalPage/TerminalPage.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.tsx @@ -1,5 +1,4 @@ import { makeStyles, useTheme } from "@mui/styles"; -import { useMachine } from "@xstate/react"; import { FC, useCallback, useEffect, useRef, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useNavigate, useParams, useSearchParams } from "react-router-dom"; @@ -14,15 +13,15 @@ import { Unicode11Addon } from "xterm-addon-unicode11"; import "xterm/css/xterm.css"; import { MONOSPACE_FONT_FAMILY } from "theme/constants"; import { pageTitle } from "utils/page"; -import { terminalMachine } from "xServices/terminal/terminalXService"; import { useProxy } from "contexts/ProxyContext"; import Box from "@mui/material/Box"; import { useDashboard } from "components/Dashboard/DashboardProvider"; import { Region } from "api/typesGenerated"; import { getLatencyColor } from "utils/latency"; -import Popover from "@mui/material/Popover"; import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency"; -import { portForwardURL } from "utils/portForward"; +import { openMaybePortForwardedURL } from "utils/portForward"; +import { terminalWebsocketUrl } from "utils/terminal"; +import { getMatchingAgentOrFirst } from "utils/workspace"; import { DisconnectedAlert, ErrorScriptAlert, @@ -31,6 +30,12 @@ import { } from "./TerminalAlerts"; import { useQuery } from "react-query"; import { deploymentConfig } from "api/queries/deployment"; +import { workspaceByOwnerAndName } from "api/queries/workspaces"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; export const Language = { workspaceErrorMessagePrefix: "Unable to fetch workspace: ", @@ -44,9 +49,11 @@ const TerminalPage: FC = () => { const { proxy } = useProxy(); const params = useParams() as { username: string; workspace: string }; const username = params.username.replace("@", ""); - const workspaceName = params.workspace; const xtermRef = useRef(null); const [terminal, setTerminal] = useState(null); + const [terminalState, setTerminalState] = useState< + "connected" | "disconnected" | "initializing" + >("initializing"); const [fitAddon, setFitAddon] = useState(null); const [searchParams] = useSearchParams(); // The reconnection token is a unique token that identifies @@ -56,37 +63,13 @@ const TerminalPage: FC = () => { const command = searchParams.get("command") || undefined; // The workspace name is in the format: // [.] - const workspaceNameParts = workspaceName?.split("."); - const [terminalState, sendEvent] = useMachine(terminalMachine, { - context: { - agentName: workspaceNameParts?.[1], - reconnection: reconnectionToken, - workspaceName: workspaceNameParts?.[0], - username: username, - command: command, - baseURL: proxy.preferredPathAppURL, - }, - actions: { - readMessage: (_, event) => { - if (typeof event.data === "string") { - // This exclusively occurs when testing. - // "jest-websocket-mock" doesn't support ArrayBuffer. - terminal?.write(event.data); - } else { - terminal?.write(new Uint8Array(event.data)); - } - }, - }, - }); - const isConnected = terminalState.matches("connected"); - const isDisconnected = terminalState.matches("disconnected"); - const { - workspaceError, - workspace, - workspaceAgentError, - workspaceAgent, - websocketError, - } = terminalState.context; + const workspaceNameParts = params.workspace?.split("."); + const workspace = useQuery( + workspaceByOwnerAndName(username, workspaceNameParts?.[0]), + ); + const workspaceAgent = workspace.data + ? getMatchingAgentOrFirst(workspace.data, workspaceNameParts?.[1]) + : undefined; const dashboard = useDashboard(); const proxyContext = useProxy(); const selectedProxy = proxyContext.proxy.proxy; @@ -101,56 +84,25 @@ const TerminalPage: FC = () => { }, [lifecycleState]); const config = useQuery(deploymentConfig()); + const renderer = config.data?.config.web_terminal_renderer; // handleWebLink handles opening of URLs in the terminal! const handleWebLink = useCallback( (uri: string) => { - if ( - !workspaceAgent || - !workspace || - !username || - !proxy.preferredWildcardHostname - ) { - return; - } - - const open = (uri: string) => { - // Copied from: https://github.com/xtermjs/xterm.js/blob/master/addons/xterm-addon-web-links/src/WebLinksAddon.ts#L23 - const newWindow = window.open(); - if (newWindow) { - try { - newWindow.opener = null; - } catch { - // no-op, Electron can throw - } - newWindow.location.href = uri; - } else { - console.warn("Opening link blocked as opener could not be cleared"); - } - }; - - try { - const url = new URL(uri); - const localHosts = ["0.0.0.0", "127.0.0.1", "localhost"]; - if (!localHosts.includes(url.hostname)) { - open(uri); - return; - } - open( - portForwardURL( - proxy.preferredWildcardHostname, - parseInt(url.port), - workspaceAgent.name, - workspace.name, - username, - ) + url.pathname, - ); - } catch (ex) { - open(uri); - } + openMaybePortForwardedURL( + uri, + proxy.preferredWildcardHostname, + workspaceAgent?.name, + workspace.data?.name, + username, + ); }, - [workspaceAgent, workspace, username, proxy.preferredWildcardHostname], + [workspaceAgent, workspace.data, username, proxy.preferredWildcardHostname], ); + const handleWebLinkRef = useRef(handleWebLink); + useEffect(() => { + handleWebLinkRef.current = handleWebLink; + }, [handleWebLink]); // Create the terminal! useEffect(() => { @@ -167,9 +119,9 @@ const TerminalPage: FC = () => { background: colors.gray[16], }, }); - if (config.data?.config.web_terminal_renderer === "webgl") { + if (renderer === "webgl") { terminal.loadAddon(new WebglAddon()); - } else if (config.data?.config.web_terminal_renderer === "canvas") { + } else if (renderer === "canvas") { terminal.loadAddon(new CanvasAddon()); } const fitAddon = new FitAddon(); @@ -179,26 +131,9 @@ const TerminalPage: FC = () => { terminal.unicode.activeVersion = "11"; terminal.loadAddon( new WebLinksAddon((_, uri) => { - handleWebLink(uri); + handleWebLinkRef.current(uri); }), ); - terminal.onData((data) => { - sendEvent({ - type: "WRITE", - request: { - data: data, - }, - }); - }); - terminal.onResize((event) => { - sendEvent({ - type: "WRITE", - request: { - height: event.rows, - width: event.cols, - }, - }); - }); setTerminal(terminal); terminal.open(xtermRef.current); const listener = () => { @@ -210,11 +145,9 @@ const TerminalPage: FC = () => { window.removeEventListener("resize", listener); terminal.dispose(); }; - }, [config.data, config.isLoading, sendEvent, xtermRef, handleWebLink]); + }, [renderer, config.isLoading, xtermRef, handleWebLinkRef]); - // Triggers the initial terminal connection using - // the reconnection token and workspace name found - // from the router. + // Updates the reconnection token into the URL if necessary. useEffect(() => { if (searchParams.get("reconnect") === reconnectionToken) { return; @@ -230,7 +163,7 @@ const TerminalPage: FC = () => { ); }, [searchParams, navigate, reconnectionToken]); - // Apply terminal options based on connection state. + // Hook up the terminal through a web socket. useEffect(() => { if (!terminal || !fitAddon) { return; @@ -242,68 +175,136 @@ const TerminalPage: FC = () => { fitAddon.fit(); fitAddon.fit(); - if (!isConnected) { - // Disable user input when not connected. - terminal.options = { - disableStdin: true, - }; - if (workspaceError instanceof Error) { - terminal.writeln( - Language.workspaceErrorMessagePrefix + workspaceError.message, - ); - } - if (workspaceAgentError instanceof Error) { - terminal.writeln( - Language.workspaceAgentErrorMessagePrefix + - workspaceAgentError.message, - ); - } - if (websocketError instanceof Error) { - terminal.writeln( - Language.websocketErrorMessagePrefix + websocketError.message, - ); - } - return; - } - // The terminal should be cleared on each reconnect // because all data is re-rendered from the backend. terminal.clear(); - // Focusing on connection allows users to reload the - // page and start typing immediately. + // Focusing on connection allows users to reload the page and start + // typing immediately. terminal.focus(); - terminal.options = { - disableStdin: false, - windowsMode: workspaceAgent?.operating_system === "windows", - }; - // Update the terminal size post-fit. - sendEvent({ - type: "WRITE", - request: { - height: terminal.rows, - width: terminal.cols, - }, - }); + // Disable input while we connect. + terminal.options.disableStdin = true; + + // Show a message if we failed to find the workspace or agent. + if (workspace.isLoading) { + return; + } else if (workspace.error instanceof Error) { + terminal.writeln( + Language.workspaceErrorMessagePrefix + workspace.error.message, + ); + return; + } else if (!workspaceAgent) { + terminal.writeln( + Language.workspaceAgentErrorMessagePrefix + "no agent found with ID", + ); + return; + } + + // Hook up terminal events to the websocket. + let websocket: WebSocket | null; + const disposers = [ + terminal.onData((data) => { + websocket?.send( + new TextEncoder().encode(JSON.stringify({ data: data })), + ); + }), + terminal.onResize((event) => { + websocket?.send( + new TextEncoder().encode( + JSON.stringify({ + height: event.rows, + width: event.cols, + }), + ), + ); + }), + ]; + + let disposed = false; + + // Open the web socket and hook it up to the terminal. + terminalWebsocketUrl( + proxy.preferredPathAppURL, + reconnectionToken, + workspaceAgent.id, + command, + ) + .then((url) => { + if (disposed) { + return; // Unmounted while we waited for the async call. + } + websocket = new WebSocket(url); + websocket.binaryType = "arraybuffer"; + websocket.addEventListener("open", () => { + // Now that we are connected, allow user input. + terminal.options = { + disableStdin: false, + windowsMode: workspaceAgent?.operating_system === "windows", + }; + // Send the initial size. + websocket?.send( + new TextEncoder().encode( + JSON.stringify({ + height: terminal.rows, + width: terminal.cols, + }), + ), + ); + setTerminalState("connected"); + }); + websocket.addEventListener("error", () => { + terminal.options.disableStdin = true; + terminal.writeln( + Language.websocketErrorMessagePrefix + "socket errored", + ); + setTerminalState("disconnected"); + }); + websocket.addEventListener("close", () => { + terminal.options.disableStdin = true; + setTerminalState("disconnected"); + }); + websocket.addEventListener("message", (event) => { + if (typeof event.data === "string") { + // This exclusively occurs when testing. + // "jest-websocket-mock" doesn't support ArrayBuffer. + terminal.write(event.data); + } else { + terminal.write(new Uint8Array(event.data)); + } + }); + }) + .catch((error) => { + if (disposed) { + return; // Unmounted while we waited for the async call. + } + terminal.writeln(Language.websocketErrorMessagePrefix + error.message); + setTerminalState("disconnected"); + }); + + return () => { + disposed = true; // Could use AbortController instead? + disposers.forEach((d) => d.dispose()); + websocket?.close(1000); + }; }, [ - workspaceError, - workspaceAgentError, - websocketError, - workspaceAgent, - terminal, + command, fitAddon, - isConnected, - sendEvent, + proxy.preferredPathAppURL, + reconnectionToken, + terminal, + workspace.isLoading, + workspace.error, + workspaceAgent, ]); return ( <> - {terminalState.context.workspace + {workspace.data ? pageTitle( - `Terminal · ${terminalState.context.workspace.owner_name}/${terminalState.context.workspace.name}`, + `Terminal · ${workspace.data.owner_name}/${workspace.data.name}`, ) : ""} @@ -313,7 +314,7 @@ const TerminalPage: FC = () => { {lifecycleState === "starting" && } {lifecycleState === "ready" && prevLifecycleState.current === "starting" && } - {isDisconnected && } + {terminalState === "disconnected" && }
{ const BottomBar = ({ proxy, latency }: { proxy: Region; latency?: number }) => { const theme = useTheme(); const color = getLatencyColor(theme, latency); - const anchorRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); return ( theme.spacing(1, 2), + padding: theme.spacing(0, 2), background: (theme) => theme.palette.background.paper, display: "flex", alignItems: "center", @@ -347,82 +346,80 @@ const BottomBar = ({ proxy, latency }: { proxy: Region; latency?: number }) => { borderTop: (theme) => `1px solid ${theme.palette.divider}`, }} > - setIsOpen(true)} - onMouseLeave={() => setIsOpen(false)} - sx={{ - background: "none", - cursor: "pointer", - display: "flex", - alignItems: "center", - gap: 1, - border: 0, - }} - > - + + + + + + + theme.spacing(1, 2), + }, }} - /> - - - setIsOpen(false)} - sx={{ - pointerEvents: "none", - "& .MuiPaper-root": { - padding: (theme) => theme.spacing(1, 2), - marginTop: -1, - }, - }} - anchorOrigin={{ - vertical: "top", - horizontal: "right", - }} - transformOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - > - theme.palette.text.secondary, - fontWeight: 500, + anchorOrigin={{ + vertical: "top", + horizontal: "right", + }} + transformOrigin={{ + vertical: "bottom", + horizontal: "right", }} > - Selected proxy - - - - - + theme.palette.text.secondary, + fontWeight: 500, + }} + > + Selected proxy + + + + + + + {proxy.display_name} - {proxy.display_name} + - - + ); diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx index 84dbb612b79e1..400cf20464fa2 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx @@ -7,10 +7,10 @@ import { nameValidator, onChangeTrimmed, } from "utils/formUtils"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Form, FormFields } from "components/Form/Form"; import { UpdateUserProfileRequest } from "api/typesGenerated"; +import LoadingButton from "@mui/lab/LoadingButton"; export const Language = { usernameLabel: "Username", @@ -76,12 +76,11 @@ export const AccountForm: FC = ({
- {isLoading ? "" : Language.updateSettings} + {Language.updateSettings}
diff --git a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx index d821681fbf1df..38104575761b7 100644 --- a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx +++ b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx @@ -3,7 +3,6 @@ import { FormikContextType, useFormik } from "formik"; import { FC, useEffect, useState } from "react"; import * as Yup from "yup"; import { getFormHelpers } from "utils/formUtils"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Form, FormFields } from "components/Form/Form"; import { @@ -15,6 +14,7 @@ import { Stack } from "components/Stack/Stack"; import { timeZones, getPreferredTimezone } from "utils/timeZones"; import { Alert } from "components/Alert/Alert"; import { timeToCron, quietHoursDisplay, validTime } from "utils/schedule"; +import LoadingButton from "@mui/lab/LoadingButton"; export interface ScheduleFormValues { time: string; @@ -130,7 +130,7 @@ export const ScheduleForm: FC> = ({ type="submit" variant="contained" > - {!isLoading && "Update schedule"} + Update schedule
diff --git a/site/src/pages/UserSettingsPage/SecurityPage/SettingsSecurityForm.tsx b/site/src/pages/UserSettingsPage/SecurityPage/SettingsSecurityForm.tsx index aedeed228fee0..143e6213832b9 100644 --- a/site/src/pages/UserSettingsPage/SecurityPage/SettingsSecurityForm.tsx +++ b/site/src/pages/UserSettingsPage/SecurityPage/SettingsSecurityForm.tsx @@ -6,7 +6,7 @@ import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Form, FormFields } from "components/Form/Form"; import { Alert } from "components/Alert/Alert"; import { getFormHelpers } from "utils/formUtils"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; +import LoadingButton from "@mui/lab/LoadingButton"; interface SecurityFormValues { old_password: string; @@ -107,7 +107,7 @@ export const SecurityForm: FC = ({ type="submit" variant="contained" > - {isLoading ? "" : Language.updatePassword} + {Language.updatePassword} diff --git a/site/src/pages/UsersPage/UsersFilter.tsx b/site/src/pages/UsersPage/UsersFilter.tsx index 588925fe56aa8..366900690b804 100644 --- a/site/src/pages/UsersPage/UsersFilter.tsx +++ b/site/src/pages/UsersPage/UsersFilter.tsx @@ -11,9 +11,13 @@ import { } from "components/Filter/filter"; import { BaseOption } from "components/Filter/options"; import { UseFilterMenuOptions, useFilterMenu } from "components/Filter/menu"; -import { userFilterQuery } from "utils/filters"; import { docs } from "utils/docs"; +const userFilterQuery = { + active: "status:active", + all: "", +}; + type StatusOption = BaseOption & { color: string; }; diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index c6c3e2ba19919..7d09df412b32e 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -1,23 +1,10 @@ -import { User } from "api/typesGenerated"; -import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; -import { nonInitialPage } from "components/PaginationWidget/utils"; -import { useMe } from "hooks/useMe"; -import { usePermissions } from "hooks/usePermissions"; -import { FC, ReactNode, useState } from "react"; -import { Helmet } from "react-helmet-async"; -import { useSearchParams, useNavigate } from "react-router-dom"; -import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { ResetPasswordDialog } from "./ResetPasswordDialog"; -import { pageTitle } from "utils/page"; -import { UsersPageView } from "./UsersPageView"; -import { useStatusFilterMenu } from "./UsersFilter"; -import { useFilter } from "components/Filter/filter"; -import { useDashboard } from "components/Dashboard/DashboardProvider"; -import { useMutation, useQuery, useQueryClient } from "react-query"; +import { type FC, type ReactNode, useState } from "react"; + +import { type User } from "api/typesGenerated"; import { roles } from "api/queries/roles"; +import { groupsByUserId } from "api/queries/groups"; +import { getErrorMessage } from "api/errors"; import { deploymentConfig } from "api/queries/deployment"; -import { prepareQuery } from "utils/filters"; -import { usePagination } from "hooks"; import { users, suspendUser, @@ -27,38 +14,55 @@ import { updateRoles, authMethods, } from "api/queries/users"; -import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; -import { getErrorMessage } from "api/errors"; + +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { useOrganizationId, usePagination } from "hooks"; +import { useMe } from "hooks/useMe"; +import { usePermissions } from "hooks/usePermissions"; +import { useStatusFilterMenu } from "./UsersFilter"; +import { useFilter } from "components/Filter/filter"; +import { useDashboard } from "components/Dashboard/DashboardProvider"; import { generateRandomString } from "utils/random"; +import { prepareQuery } from "utils/filters"; + +import { Helmet } from "react-helmet-async"; +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { nonInitialPage } from "components/PaginationWidget/utils"; +import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"; +import { ResetPasswordDialog } from "./ResetPasswordDialog"; +import { pageTitle } from "utils/page"; +import { UsersPageView } from "./UsersPageView"; +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; export const UsersPage: FC<{ children?: ReactNode }> = () => { const queryClient = useQueryClient(); const navigate = useNavigate(); + const searchParamsResult = useSearchParams(); const { entitlements } = useDashboard(); const [searchParams] = searchParamsResult; - const filter = searchParams.get("filter") ?? ""; - const pagination = usePagination({ - searchParamsResult, - }); + + const pagination = usePagination({ searchParamsResult }); const usersQuery = useQuery( users({ - q: prepareQuery(filter), + q: prepareQuery(searchParams.get("filter") ?? ""), limit: pagination.limit, offset: pagination.offset, }), ); + + const organizationId = useOrganizationId(); + const groupsByUserIdQuery = useQuery(groupsByUserId(organizationId)); + const authMethodsQuery = useQuery(authMethods()); + const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions(); const rolesQuery = useQuery(roles()); const { data: deploymentValues } = useQuery({ ...deploymentConfig(), enabled: viewDeploymentValues, }); - // Indicates if oidc roles are synced from the oidc idp. - // Assign 'false' if unknown. - const oidcRoleSyncEnabled = - viewDeploymentValues && - deploymentValues?.config.oidc?.user_role_field !== ""; + const me = useMe(); const useFilterResult = useFilter({ searchParamsResult, @@ -74,36 +78,47 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { status: option?.value, }), }); - const authMethodsQuery = useQuery(authMethods()); - const isLoading = - usersQuery.isLoading || rolesQuery.isLoading || authMethodsQuery.isLoading; - const [confirmSuspendUser, setConfirmSuspendUser] = useState(); + const [userToSuspend, setUserToSuspend] = useState(); const suspendUserMutation = useMutation(suspendUser(queryClient)); - const [confirmActivateUser, setConfirmActivateUser] = useState(); + const [userToActivate, setUserToActivate] = useState(); const activateUserMutation = useMutation(activateUser(queryClient)); - const [confirmDeleteUser, setConfirmDeleteUser] = useState(); + const [userToDelete, setUserToDelete] = useState(); const deleteUserMutation = useMutation(deleteUser(queryClient)); const [confirmResetPassword, setConfirmResetPassword] = useState<{ user: User; newPassword: string; }>(); - const updatePasswordMutation = useMutation(updatePassword()); + const updatePasswordMutation = useMutation(updatePassword()); const updateRolesMutation = useMutation(updateRoles(queryClient)); + // Indicates if oidc roles are synced from the oidc idp. + // Assign 'false' if unknown. + const oidcRoleSyncEnabled = + viewDeploymentValues && + deploymentValues?.config.oidc?.user_role_field !== ""; + + const isLoading = + usersQuery.isLoading || + rolesQuery.isLoading || + authMethodsQuery.isLoading || + groupsByUserIdQuery.isLoading; + return ( <> {pageTitle("Users")} + { navigate( @@ -116,9 +131,9 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { "/audit?filter=" + encodeURIComponent(`username:${user.username}`), ); }} - onDeleteUser={setConfirmDeleteUser} - onSuspendUser={setConfirmSuspendUser} - onActivateUser={setConfirmActivateUser} + onDeleteUser={setUserToDelete} + onSuspendUser={setUserToSuspend} + onActivateUser={setUserToActivate} onResetUserPassword={(user) => { setConfirmResetPassword({ user, @@ -147,9 +162,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { filterProps={{ filter: useFilterResult, error: usersQuery.error, - menus: { - status: statusMenu, - }, + menus: { status: statusMenu }, }} count={usersQuery.data?.count} page={pagination.page} @@ -158,48 +171,44 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { /> setUserToDelete(undefined)} onConfirm={async () => { try { - await deleteUserMutation.mutateAsync(confirmDeleteUser!.id); - setConfirmDeleteUser(undefined); + await deleteUserMutation.mutateAsync(userToDelete!.id); + setUserToDelete(undefined); displaySuccess("Successfully deleted the user."); } catch (e) { displayError(getErrorMessage(e, "Error deleting user.")); } }} - onCancel={() => { - setConfirmDeleteUser(undefined); - }} /> setUserToSuspend(undefined)} onConfirm={async () => { try { - await suspendUserMutation.mutateAsync(confirmSuspendUser!.id); - setConfirmSuspendUser(undefined); + await suspendUserMutation.mutateAsync(userToSuspend!.id); + setUserToSuspend(undefined); displaySuccess("Successfully suspended the user."); } catch (e) { displayError(getErrorMessage(e, "Error suspending user.")); } }} - onClose={() => { - setConfirmSuspendUser(undefined); - }} description={ <> Do you want to suspend the user{" "} - {confirmSuspendUser?.username ?? ""}? + {userToSuspend?.username ?? ""}? } /> @@ -207,26 +216,24 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { setUserToActivate(undefined)} onConfirm={async () => { try { - await activateUserMutation.mutateAsync(confirmActivateUser!.id); - setConfirmActivateUser(undefined); + await activateUserMutation.mutateAsync(userToActivate!.id); + setUserToActivate(undefined); displaySuccess("Successfully activated the user."); } catch (e) { displayError(getErrorMessage(e, "Error activating user.")); } }} - onClose={() => { - setConfirmActivateUser(undefined); - }} description={ <> Do you want to activate{" "} - {confirmActivateUser?.username ?? ""}? + {userToActivate?.username ?? ""}? } /> diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 56cd39e85d446..5ea743d210781 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -1,5 +1,7 @@ -import { ComponentProps, FC } from "react"; -import * as TypesGen from "api/typesGenerated"; +import { type ComponentProps, type FC } from "react"; +import type * as TypesGen from "api/typesGenerated"; +import { type GroupsByUserId } from "api/queries/groups"; + import { UsersTable } from "./UsersTable/UsersTable"; import { UsersFilter } from "./UsersFilter"; import { @@ -12,10 +14,10 @@ export interface UsersPageViewProps { users?: TypesGen.User[]; roles?: TypesGen.AssignableRoles[]; isUpdatingUserRoles?: boolean; - canEditUsers?: boolean; + canEditUsers: boolean; oidcRoleSyncEnabled: boolean; canViewActivity?: boolean; - isLoading?: boolean; + isLoading: boolean; authMethods?: TypesGen.AuthMethods; onSuspendUser: (user: TypesGen.User) => void; onDeleteUser: (user: TypesGen.User) => void; @@ -30,6 +32,8 @@ export interface UsersPageViewProps { filterProps: ComponentProps; isNonInitialPage: boolean; actorID: string; + groupsByUserId: GroupsByUserId | undefined; + // Pagination count?: number; page: number; @@ -60,6 +64,7 @@ export const UsersPageView: FC> = ({ limit, onPageChange, page, + groupsByUserId, }) => { return ( <> @@ -77,6 +82,7 @@ export const UsersPageView: FC> = ({ = { title: "pages/UsersPage/EditRolesButton", component: EditRolesButton, args: { - defaultIsOpen: true, + isDefaultOpen: true, }, }; export default meta; type Story = StoryObj; +const selectedRoleNames = new Set([MockUserAdminRole.name, MockOwnerRole.name]); + export const Open: Story = { args: { + selectedRoleNames, roles: MockSiteRoles, - selectedRoles: [MockUserAdminRole, MockOwnerRole], }, parameters: { chromatic: { delay: 300 }, @@ -30,8 +32,8 @@ export const Open: Story = { export const Loading: Story = { args: { isLoading: true, + selectedRoleNames, roles: MockSiteRoles, - selectedRoles: [MockUserAdminRole, MockOwnerRole], userLoginType: "password", oidcRoleSync: false, }, diff --git a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx index be5e38f9302a4..eca9bcf389f9f 100644 --- a/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx +++ b/site/src/pages/UsersPage/UsersTable/EditRolesButton.tsx @@ -1,8 +1,7 @@ import IconButton from "@mui/material/IconButton"; import { EditSquare } from "components/Icons/EditSquare"; -import { useRef, useState, FC } from "react"; +import { FC } from "react"; import { makeStyles } from "@mui/styles"; -import Popover from "@mui/material/Popover"; import { Stack } from "components/Stack/Stack"; import Checkbox from "@mui/material/Checkbox"; import UserIcon from "@mui/icons-material/PersonOutline"; @@ -12,6 +11,11 @@ import { HelpTooltipText, HelpTooltipTitle, } from "components/HelpTooltip/HelpTooltip"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; const roleDescriptions: Record = { owner: @@ -57,31 +61,28 @@ const Option: React.FC<{ export interface EditRolesButtonProps { isLoading: boolean; roles: Role[]; - selectedRoles: Role[]; + selectedRoleNames: Set; onChange: (roles: Role["name"][]) => void; - defaultIsOpen?: boolean; + isDefaultOpen?: boolean; oidcRoleSync: boolean; userLoginType: string; } export const EditRolesButton: FC = ({ roles, - selectedRoles, + selectedRoleNames, onChange, isLoading, - defaultIsOpen = false, + isDefaultOpen = false, userLoginType, oidcRoleSync, }) => { const styles = useStyles(); - const anchorRef = useRef(null); - const [isOpen, setIsOpen] = useState(defaultIsOpen); - const id = isOpen ? "edit-roles-popover" : undefined; - const selectedRoleNames = selectedRoles.map((role) => role.name); const handleChange = (roleName: string) => { - if (selectedRoleNames.includes(roleName)) { - onChange(selectedRoleNames.filter((role) => role !== roleName)); + if (selectedRoleNames.has(roleName)) { + const serialized = [...selectedRoleNames]; + onChange(serialized.filter((role) => role !== roleName)); return; } @@ -91,42 +92,30 @@ export const EditRolesButton: FC = ({ const canSetRoles = userLoginType !== "oidc" || (userLoginType === "oidc" && !oidcRoleSync); + if (!canSetRoles) { + return ( + + Externally controlled + + Roles for this user are controlled by the OIDC identity provider. + + + ); + } + return ( - <> - {canSetRoles ? ( + + setIsOpen(true)} > - ) : ( - - Externally controlled - - Roles for this user are controlled by the OIDC identity provider. - - - )} - - setIsOpen(false)} - anchorOrigin={{ - vertical: "bottom", - horizontal: "left", - }} - transformOrigin={{ - vertical: "top", - horizontal: "left", - }} - classes={{ paper: styles.popoverPaper }} - > + + +
= ({
- {Language.usernameLabel} - + {Language.usernameLabel} + + {Language.rolesLabel} - + + + + + + + {Language.groupsLabel} + - {Language.loginTypeLabel} - {Language.statusLabel} + + {Language.loginTypeLabel} + {Language.statusLabel} + {/* 1% is a trick to make the table cell width fit the content */} {canEditUsers && } + { - return role.name === "owner"; -}; - -const roleOrder = ["owner", "user-admin", "template-admin", "auditor"]; - -const sortRoles = (roles: TypesGen.Role[]) => { - return roles.slice(0).sort((a, b) => { - return roleOrder.indexOf(a.name) - roleOrder.indexOf(b.name); - }); -}; - interface UsersTableBodyProps { - users?: TypesGen.User[]; + users: TypesGen.User[] | undefined; + groupsByUserId: GroupsByUserId | undefined; authMethods?: TypesGen.AuthMethods; roles?: TypesGen.AssignableRoles[]; isUpdatingUserRoles?: boolean; - canEditUsers?: boolean; - isLoading?: boolean; + canEditUsers: boolean; + isLoading: boolean; canViewActivity?: boolean; onSuspendUser: (user: TypesGen.User) => void; onDeleteUser: (user: TypesGen.User) => void; @@ -86,6 +76,7 @@ export const UsersTableBody: FC< isNonInitialPage, actorID, oidcRoleSyncEnabled, + groupsByUserId, }) => { return ( @@ -97,15 +88,23 @@ export const UsersTableBody: FC< + + + + + + + + {canEditUsers && ( @@ -114,6 +113,7 @@ export const UsersTableBody: FC< + @@ -125,6 +125,7 @@ export const UsersTableBody: FC< + @@ -136,125 +137,91 @@ export const UsersTableBody: FC< + - <> - {users && - users.map((user) => { - // When the user has no role we want to show they are a Member - const fallbackRole: TypesGen.Role = { - name: "member", - display_name: "Member", - }; - const userRoles = - user.roles.length === 0 - ? [fallbackRole] - : sortRoles(user.roles); + {users?.map((user) => ( + + + + - return ( - - - - - - - {canEditUsers && ( - { - // Remove the fallback role because it is only for the UI - const rolesWithoutFallback = roles.filter( - (role) => role !== fallbackRole.name, - ); - onUpdateUserRoles(user, rolesWithoutFallback); - }} - /> - )} - {userRoles.map((role) => ( - - ))} - - - - - - - {user.status} - - + - {canEditUsers && ( - - Suspend…, - onClick: onSuspendUser, - disabled: false, - } - : { - label: <>Activate…, - onClick: onActivateUser, - disabled: false, - }, - { - label: <>Delete…, - onClick: onDeleteUser, - disabled: user.id === actorID, - }, - { - label: <>Reset password…, - onClick: onResetUserPassword, - disabled: user.login_type !== "password", - }, - { - label: "View workspaces", - onClick: onListWorkspaces, - disabled: false, - }, - { - label: ( - <> - View activity - {!canViewActivity && } - - ), - onClick: onViewActivity, - disabled: !canViewActivity, - }, - ]} - /> - - )} - - ); - })} - + + + + + + + + {user.status} + + + + {canEditUsers && ( + + Suspend…, + onClick: onSuspendUser, + disabled: false, + } + : { + label: <>Activate…, + onClick: onActivateUser, + disabled: false, + }, + { + label: <>Delete…, + onClick: onDeleteUser, + disabled: user.id === actorID, + }, + { + label: <>Reset password…, + onClick: onResetUserPassword, + disabled: user.login_type !== "password", + }, + { + label: "View workspaces", + onClick: onListWorkspaces, + disabled: false, + }, + { + label: ( + <> + View activity + {!canViewActivity && } + + ), + onClick: onViewActivity, + disabled: !canViewActivity, + }, + ]} + /> + + )} + + ))} ); @@ -307,41 +274,6 @@ const LoginType = ({ ); }; -const LastSeen = ({ value, ...boxProps }: { value: string } & BoxProps) => { - const theme = useTheme(); - const t = dayjs(value); - const now = dayjs(); - - let message = t.fromNow(); - let color = theme.palette.text.secondary; - - if (t.isAfter(now.subtract(1, "hour"))) { - color = theme.palette.success.light; - // Since the agent reports on a 10m interval, - // the last_used_at can be inaccurate when recent. - message = "Now"; - } else if (t.isAfter(now.subtract(3, "day"))) { - color = theme.palette.text.secondary; - } else if (t.isAfter(now.subtract(1, "month"))) { - color = theme.palette.warning.light; - } else if (t.isAfter(now.subtract(100, "year"))) { - color = theme.palette.error.light; - } else { - message = "Never"; - } - - return ( - - {message} - - ); -}; - const styles = { status: { textTransform: "capitalize", @@ -349,12 +281,4 @@ const styles = { suspended: (theme) => ({ color: theme.palette.text.secondary, }), - rolePill: (theme) => ({ - backgroundColor: theme.palette.background.paperLight, - borderColor: theme.palette.divider, - }), - rolePillOwner: (theme) => ({ - backgroundColor: theme.palette.info.dark, - borderColor: theme.palette.info.light, - }), } satisfies Record>; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx index 0b106af0890fc..333d7999e7855 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/BuildParametersPopover.tsx @@ -1,7 +1,6 @@ import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Popover from "@mui/material/Popover"; import { useQuery } from "react-query"; import { getWorkspaceParameters } from "api/api"; import { @@ -19,10 +18,15 @@ import { HelpTooltipTitle, } from "components/HelpTooltip/HelpTooltip"; import { useFormik } from "formik"; -import { useRef, useState } from "react"; import { docs } from "utils/docs"; import { getFormHelpers } from "utils/formUtils"; import { getInitialRichParameterValues } from "utils/richParameters"; +import { + Popover, + PopoverContent, + PopoverTrigger, + usePopover, +} from "components/Popover/Popover"; export const BuildParametersPopover = ({ workspace, @@ -33,12 +37,43 @@ export const BuildParametersPopover = ({ disabled?: boolean; onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void; }) => { - const anchorRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); + return ( + + + + + ({ ".MuiPaper-root": { width: theme.spacing(38) } })} + > + + + + ); +}; + +const BuildParametersPopoverContent = ({ + workspace, + onSubmit, +}: { + workspace: Workspace; + onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void; +}) => { + const popover = usePopover(); const { data: parameters } = useQuery({ queryKey: ["workspace", workspace.id, "parameters"], queryFn: () => getWorkspaceParameters(workspace), - enabled: isOpen, + enabled: popover.isOpen, }); const ephemeralParameters = parameters ? parameters.templateVersionRichParameters.filter((p) => p.ephemeral) @@ -46,93 +81,56 @@ export const BuildParametersPopover = ({ return ( <> - - { - setIsOpen(false); - }} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", - }} - sx={{ - ".MuiPaper-root": { - width: (theme) => theme.spacing(38), - marginTop: 1, - }, - }} - > - - {parameters && parameters.buildParameters && ephemeralParameters ? ( - ephemeralParameters.length > 0 ? ( - <> - theme.palette.text.secondary, - p: 2.5, - borderBottom: (theme) => - `1px solid ${theme.palette.divider}`, - }} - > - Build Options - - These parameters only apply for a single workspace start. - - - -
{ - onSubmit(buildParameters); - setIsOpen(false); - }} - ephemeralParameters={ephemeralParameters} - buildParameters={parameters.buildParameters} - /> -
- - ) : ( - theme.palette.text.secondary, - p: 2.5, - borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + {parameters && parameters.buildParameters && ephemeralParameters ? ( + ephemeralParameters.length > 0 ? ( + <> + theme.palette.text.secondary, + p: 2.5, + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + Build Options + + These parameters only apply for a single workspace start. + + + + { + onSubmit(buildParameters); + popover.setIsOpen(false); }} + ephemeralParameters={ephemeralParameters} + buildParameters={parameters.buildParameters} + /> + + + ) : ( + theme.palette.text.secondary, + p: 2.5, + borderBottom: (theme) => `1px solid ${theme.palette.divider}`, + }} + > + Build Options + + This template has no ephemeral build options. + + + - Build Options - - This template has no ephemeral build options. - - - - Read the docs - - - - ) - ) : ( - - )} - -
+ Read the docs + + + + ) + ) : ( + + )} ); }; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx index fad2e65a91ee1..12eaf5c9a3edc 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/Buttons.tsx @@ -4,13 +4,13 @@ import CloudQueueIcon from "@mui/icons-material/CloudQueue"; import CropSquareIcon from "@mui/icons-material/CropSquare"; import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"; import ReplayIcon from "@mui/icons-material/Replay"; -import { LoadingButton } from "components/LoadingButton/LoadingButton"; import { FC } from "react"; import BlockOutlined from "@mui/icons-material/BlockOutlined"; import ButtonGroup from "@mui/material/ButtonGroup"; import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"; import { BuildParametersPopover } from "./BuildParametersPopover"; import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew"; +import LoadingButton from "@mui/lab/LoadingButton"; interface WorkspaceAction { loading?: boolean; @@ -24,13 +24,12 @@ export const UpdateButton: FC = ({ return ( Updating…} loadingPosition="start" data-testid="workspace-update-button" startIcon={} onClick={handleAction} > - Update… + {loading ? <>Updating… : <>Update…} ); }; @@ -42,12 +41,11 @@ export const ActivateButton: FC = ({ return ( Activating…} loadingPosition="start" startIcon={} onClick={handleAction} > - Activate + {loading ? <>Activating… : "Activate"} ); }; @@ -70,12 +68,11 @@ export const StartButton: FC< > Starting…} loadingPosition="start" startIcon={} onClick={() => handleAction()} > - Start + {loading ? <>Starting… : "Start"} = ({ handleAction, loading }) => { return ( Stopping…} loadingPosition="start" startIcon={} onClick={handleAction} data-testid="workspace-stop-button" > - Stop + {loading ? <>Stopping… : "Stop"} ); }; @@ -119,13 +115,12 @@ export const RestartButton: FC< > Restarting…} loadingPosition="start" startIcon={} onClick={() => handleAction()} data-testid="workspace-restart-button" > - Restart… + {loading ? <>Restarting… : <>Restart…} = ({ label }) => { return ( - } - /> + }> + {label} + ); }; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx index c53cfeab9a957..bbe9afe598fba 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx @@ -79,3 +79,17 @@ export const Updating: Story = { workspace: Mocks.MockOutdatedWorkspace, }, }; + +export const RequireActiveVersionStarted: Story = { + args: { + workspace: Mocks.MockOutdatedRunningWorkspaceRequireActiveVersion, + canChangeVersions: false, + }, +}; + +export const RequireActiveVersionStopped: Story = { + args: { + workspace: Mocks.MockOutdatedStoppedWorkspaceRequireActiveVersion, + canChangeVersions: false, + }, +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 04d2744530b90..226396720b593 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -61,7 +61,11 @@ export const WorkspaceActions: FC = ({ canCancel, canAcceptJobs, actions: actionsByStatus, - } = actionsByWorkspaceStatus(workspace, workspace.latest_build.status); + } = actionsByWorkspaceStatus( + workspace, + workspace.latest_build.status, + canChangeVersions, + ); const canBeUpdated = workspace.outdated && canAcceptJobs; const menuTriggerRef = useRef(null); const [isMenuOpen, setIsMenuOpen] = useState(false); diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts index d6f2704a18f80..dc57d0e4fbd0e 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts +++ b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts @@ -33,6 +33,7 @@ interface WorkspaceAbilities { export const actionsByWorkspaceStatus = ( workspace: Workspace, status: WorkspaceStatus, + canChangeVersions: boolean, ): WorkspaceAbilities => { if (workspace.dormant_at) { return { @@ -41,6 +42,26 @@ export const actionsByWorkspaceStatus = ( canAcceptJobs: false, }; } + if ( + workspace.template_require_active_version && + workspace.outdated && + !canChangeVersions + ) { + if (status === "running") { + return { + actions: [ButtonTypesEnum.stop], + canCancel: false, + canAcceptJobs: true, + }; + } + if (status === "stopped") { + return { + actions: [], + canCancel: false, + canAcceptJobs: true, + }; + } + } return statusToActions[status]; }; diff --git a/site/src/pages/WorkspacePage/WorkspaceStats.tsx b/site/src/pages/WorkspacePage/WorkspaceStats.tsx index c55f09d34f693..773be0baa59f6 100644 --- a/site/src/pages/WorkspacePage/WorkspaceStats.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceStats.tsx @@ -1,6 +1,6 @@ import Link from "@mui/material/Link"; import { WorkspaceOutdatedTooltip } from "components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip"; -import { FC, useRef, useState } from "react"; +import { FC } from "react"; import { Link as RouterLink } from "react-router-dom"; import { createDayString } from "utils/createDayString"; import { @@ -16,11 +16,16 @@ import IconButton from "@mui/material/IconButton"; import RemoveIcon from "@mui/icons-material/RemoveOutlined"; import { makeStyles } from "@mui/styles"; import AddIcon from "@mui/icons-material/AddOutlined"; -import Popover from "@mui/material/Popover"; import TextField from "@mui/material/TextField"; import Button from "@mui/material/Button"; import { WorkspaceStatusText } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge"; import { DormantDeletionStat } from "components/WorkspaceDeletion"; +import { + Popover, + PopoverContent, + PopoverTrigger, + usePopover, +} from "components/Popover/Popover"; const Language = { workspaceDetails: "Workspace Details", @@ -62,10 +67,6 @@ export const WorkspaceStats: FC = ({ const styles = useStyles(); const deadlinePlusEnabled = maxDeadlineIncrease >= 1; const deadlineMinusEnabled = maxDeadlineDecrease >= 1; - const addButtonRef = useRef(null); - const subButtonRef = useRef(null); - const [isAddingTime, setIsAddingTime] = useState(false); - const [isSubTime, setIsSubTime] = useState(false); return ( <> @@ -138,26 +139,50 @@ export const WorkspaceStats: FC = ({ {canUpdateWorkspace && canEditDeadline(workspace) && ( - setIsSubTime(true)} - > - - - setIsAddingTime(true)} - > - - + + + + + + + + + + + + + + + + + + + + )} @@ -174,118 +199,106 @@ export const WorkspaceStats: FC = ({ /> )} + + ); +}; - setIsAddingTime(false)} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", +const AddTimeContent = (props: { + maxDeadlineIncrease: number; + onDeadlinePlus: (value: number) => void; +}) => { + const styles = useStyles(); + const popover = usePopover(); + + return ( + <> + Add hours to deadline + + Delay the shutdown of this workspace for a few more hours. This is only + applied once. + + { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const hours = Number(formData.get("hours")); + props.onDeadlinePlus(hours); + popover.setIsOpen(false); }} > - Add hours to deadline - - Delay the shutdown of this workspace for a few more hours. This is - only applied once. - - { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const hours = Number(formData.get("hours")); - onDeadlinePlus(hours); - setIsAddingTime(false); + - + inputProps={{ + min: 0, + max: props.maxDeadlineIncrease, + step: 1, + defaultValue: 1, + }} + /> - - - + + + + ); +}; - setIsSubTime(false)} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", +export const DecreaseTimeContent = (props: { + onDeadlineMinus: (hours: number) => void; + maxDeadlineDecrease: number; +}) => { + const styles = useStyles(); + const popover = usePopover(); + + return ( + <> + + Subtract hours to deadline + + + Anticipate the shutdown of this workspace for a few more hours. This is + only applied once. + +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const hours = Number(formData.get("hours")); + props.onDeadlineMinus(hours); + popover.setIsOpen(false); }} > - - Subtract hours to deadline - - - Anticipate the shutdown of this workspace for a few more hours. This - is only applied once. - - { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - const hours = Number(formData.get("hours")); - onDeadlineMinus(hours); - setIsSubTime(false); + - + inputProps={{ + min: 0, + max: props.maxDeadlineDecrease, + step: 1, + defaultValue: 1, + }} + /> - - -
+ + ); }; diff --git a/site/src/pages/WorkspacesPage/WorkspacesButton.tsx b/site/src/pages/WorkspacesPage/WorkspacesButton.tsx index dfb6e05be35b0..2e3d6563602df 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesButton.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesButton.tsx @@ -1,9 +1,4 @@ -import { - type PropsWithChildren, - type ReactNode, - useState, - useRef, -} from "react"; +import { type PropsWithChildren, type ReactNode, useState } from "react"; import { type Template } from "api/typesGenerated"; import { type UseQueryResult } from "react-query"; import { @@ -20,7 +15,11 @@ import { OverflowY } from "components/OverflowY/OverflowY"; import { EmptyState } from "components/EmptyState/EmptyState"; import { Avatar } from "components/Avatar/Avatar"; import { SearchBox } from "./WorkspacesSearchBox"; -import Popover from "@mui/material/Popover"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; const ICON_SIZE = 18; const COLUMN_GAP = 1.5; @@ -42,9 +41,6 @@ export function WorkspacesButton({ const [searchTerm, setSearchTerm] = useState(""); const processed = sortTemplatesByUsersDesc(templates ?? [], searchTerm); - const anchorRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - let emptyState: ReactNode = undefined; if (templates?.length === 0) { emptyState = ( @@ -62,37 +58,13 @@ export function WorkspacesButton({ } return ( - <> - - setIsOpen(false)} - anchorEl={anchorRef.current} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", - }} - css={(theme) => ({ - marginTop: theme.spacing(1), - "& .MuiPaper-root": { - width: theme.spacing(40), - }, - })} - > + + + + + setSearchTerm(newValue)} @@ -142,8 +114,8 @@ export function WorkspacesButton({ See all templates - - + + ); } diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 4be2a492f278b..b3c3cfcd0365c 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,6 +1,9 @@ import { usePagination } from "hooks/usePagination"; import { Workspace } from "api/typesGenerated"; -import { useDashboard } from "components/Dashboard/DashboardProvider"; +import { + useDashboard, + useIsWorkspaceActionsEnabled, +} from "components/Dashboard/DashboardProvider"; import { type FC, useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; import { pageTitle } from "utils/page"; @@ -58,17 +61,14 @@ const WorkspacesPage: FC = () => { query: filterProps.filter.query, }); - const { entitlements } = useDashboard(); - const schedulingEnabled = - entitlements.features["advanced_template_scheduling"].enabled; - + const experimentEnabled = useIsWorkspaceActionsEnabled(); // If workspace actions are enabled we need to fetch the dormant // workspaces as well. This lets us determine whether we should // show a banner to the user indicating that some of their workspaces // are at risk of being deleted. useEffect(() => { - if (schedulingEnabled) { - const includesDormant = filterProps.filter.query.includes("is-dormant"); + if (experimentEnabled) { + const includesDormant = filterProps.filter.query.includes("dormant_at"); const dormantQuery = includesDormant ? filterProps.filter.query : filterProps.filter.query + " is-dormant:true"; @@ -89,11 +89,12 @@ const WorkspacesPage: FC = () => { // like dormant workspaces don't exist. setDormantWorkspaces([]); } - }, [schedulingEnabled, data, filterProps.filter.query]); + }, [experimentEnabled, data, filterProps.filter.query]); const updateWorkspace = useWorkspaceUpdate(queryKey); const [checkedWorkspaces, setCheckedWorkspaces] = useState([]); const [isDeletingAll, setIsDeletingAll] = useState(false); const [urlSearchParams] = searchParamsResult; + const { entitlements } = useDashboard(); const canCheckWorkspaces = entitlements.features["workspace_batch_actions"].enabled; diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx index cdc5d39ae68cf..53d83008cc308 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -1,7 +1,6 @@ import { FC } from "react"; import Box from "@mui/material/Box"; -import { useDashboard } from "components/Dashboard/DashboardProvider"; - +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"; import { Avatar, AvatarProps } from "components/Avatar/Avatar"; import { Palette, PaletteColor } from "@mui/material/styles"; import { TemplateFilterMenu, StatusFilterMenu } from "./menus"; @@ -16,9 +15,16 @@ import { useFilter, } from "components/Filter/filter"; import { UserFilterMenu, UserMenu } from "components/Filter/UserFilter"; -import { workspaceFilterQuery } from "utils/filters"; import { docs } from "utils/docs"; +export const workspaceFilterQuery = { + me: "owner:me", + all: "", + running: "status:running", + failed: "status:failed", + dormant: "is-dormant:true", +}; + type FilterPreset = { query: string; name: string; @@ -69,10 +75,8 @@ export const WorkspacesFilter = ({ error, menus, }: WorkspaceFilterProps) => { - const { entitlements } = useDashboard(); - const actionsEnabled = - entitlements.features["advanced_template_scheduling"].enabled; - const presets = actionsEnabled ? PRESET_FILTERS : PRESETS_WITH_DORMANT; + const actionsEnabled = useIsWorkspaceActionsEnabled(); + const presets = actionsEnabled ? PRESETS_WITH_DORMANT : PRESET_FILTERS; return ( ({ + id: organizationId, + name: "Everyone", + display_name: "", + organization_id: organizationId, + members: [], + avatar_url: "", + quota_allowance: 0, + source: "user", +}); + export const MockTemplateACL: TypesGen.TemplateACL = { group: [ { ...everyOneGroup(MockOrganization.id), role: "use" }, diff --git a/site/src/theme/constants.ts b/site/src/theme/constants.ts index c8f9ba95afc77..a768dc4ab18e7 100644 --- a/site/src/theme/constants.ts +++ b/site/src/theme/constants.ts @@ -1,7 +1,7 @@ export const borderRadius = 8; export const MONOSPACE_FONT_FAMILY = "'IBM Plex Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'Liberation Mono', 'Monaco', 'Courier New', Courier, monospace"; -export const BODY_FONT_FAMILY = `"Inter", sans-serif`; +export const BODY_FONT_FAMILY = `"Inter", system-ui, sans-serif`; export const navHeight = 62; export const containerWidth = 1380; export const containerWidthMedium = 1080; diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json index 12fbaec3f2805..fe8011d7e8685 100644 --- a/site/src/theme/icons.json +++ b/site/src/theme/icons.json @@ -1,6 +1,8 @@ [ "android-studio.svg", "apache-guacamole.svg", + "apple-black.svg", + "apple-grey.svg", "aws.png", "azure-devops.svg", "azure.png", @@ -14,6 +16,7 @@ "datagrip.svg", "dataspell.svg", "debian.svg", + "discord.svg", "do.png", "docker.png", "dotfiles.svg", @@ -27,6 +30,7 @@ "gitlab.svg", "go.svg", "goland.svg", + "google.svg", "image.svg", "intellij.svg", "java.svg", @@ -39,9 +43,11 @@ "kotlin.svg", "matlab.svg", "memory.svg", + "microsoft.svg", "node.svg", "nomad.svg", "novnc.svg", + "okta.svg", "personalize.svg", "php.svg", "phpstorm.svg", diff --git a/site/src/theme/theme.ts b/site/src/theme/theme.ts index 98b3330f3da73..7ebaec29c01fc 100644 --- a/site/src/theme/theme.ts +++ b/site/src/theme/theme.ts @@ -74,13 +74,15 @@ export let dark = createTheme({ }, typography: { fontFamily: BODY_FONT_FAMILY, + body1: { - fontSize: 16, - lineHeight: "24px", + fontSize: "1rem" /* 16px at default scaling */, + lineHeight: "1.5rem" /* 24px at default scaling */, }, + body2: { - fontSize: 14, - lineHeight: "20px", + fontSize: "0.875rem" /* 14px at default scaling */, + lineHeight: "1.25rem" /* 20px at default scaling */, }, }, shape: { @@ -216,6 +218,12 @@ dark = createTheme(dark, { }, }, }, + MuiLoadingButton: { + defaultProps: { + variant: "outlined", + color: "neutral", + }, + }, MuiTableContainer: { styleOverrides: { root: { diff --git a/site/src/utils/filters.test.ts b/site/src/utils/filters.test.ts deleted file mode 100644 index 35d261a659d2a..0000000000000 --- a/site/src/utils/filters.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as TypesGen from "api/typesGenerated"; -import { queryToFilter } from "./filters"; - -describe("queryToFilter", () => { - it.each< - [string | undefined, TypesGen.WorkspaceFilter | TypesGen.UsersRequest] - >([ - [undefined, {}], - ["", { q: "" }], - ["asdkfvjn", { q: "asdkfvjn" }], - ["owner:me", { q: "owner:me" }], - ["owner:me owner:me2", { q: "owner:me owner:me2" }], - ["me/dev", { q: "me/dev" }], - ["me/", { q: "me/" }], - [" key:val owner:me ", { q: "key:val owner:me" }], - ["status:failed", { q: "status:failed" }], - ])(`query=%p, filter=%p`, (query, filter) => { - expect(queryToFilter(query)).toEqual(filter); - }); -}); diff --git a/site/src/utils/filters.ts b/site/src/utils/filters.ts index 2c8dc63c07501..beb850a65e218 100644 --- a/site/src/utils/filters.ts +++ b/site/src/utils/filters.ts @@ -1,26 +1,3 @@ -import * as TypesGen from "api/typesGenerated"; - -export const queryToFilter = ( - query?: string, -): TypesGen.WorkspaceFilter | TypesGen.UsersRequest => { - return { - q: prepareQuery(query), - }; -}; - export const prepareQuery = (query?: string) => { return query?.trim().replace(/ +/g, " "); }; - -export const workspaceFilterQuery = { - me: "owner:me", - all: "", - running: "status:running", - failed: "status:failed", - dormant: "is-dormant:true", -}; - -export const userFilterQuery = { - active: "status:active", - all: "", -}; diff --git a/site/src/utils/groups.ts b/site/src/utils/groups.ts index 7b89f8e6d19d4..a5321d6e614f8 100644 --- a/site/src/utils/groups.ts +++ b/site/src/utils/groups.ts @@ -1,16 +1,5 @@ import { Group } from "api/typesGenerated"; -export const everyOneGroup = (organizationId: string): Group => ({ - id: organizationId, - name: "Everyone", - display_name: "", - organization_id: organizationId, - members: [], - avatar_url: "", - quota_allowance: 0, - source: "user", -}); - /** * Returns true if the provided group is the 'Everyone' group. * The everyone group represents all the users in an organization diff --git a/site/src/utils/portForward.ts b/site/src/utils/portForward.ts index d2162d948ea10..6d2dc4cbefeb7 100644 --- a/site/src/utils/portForward.ts +++ b/site/src/utils/portForward.ts @@ -12,3 +12,53 @@ export const portForwardURL = ( }--${agentName}--${workspaceName}--${username}`; return `${location.protocol}//${host}`.replace("*", subdomain); }; + +// openMaybePortForwardedURL tries to open the provided URI through the +// port-forwarded URL if it is localhost, otherwise opens it normally. +export const openMaybePortForwardedURL = ( + uri: string, + proxyHost?: string, + agentName?: string, + workspaceName?: string, + username?: string, +) => { + const open = (uri: string) => { + // Copied from: https://github.com/xtermjs/xterm.js/blob/master/addons/xterm-addon-web-links/src/WebLinksAddon.ts#L23 + const newWindow = window.open(); + if (newWindow) { + try { + newWindow.opener = null; + } catch { + // no-op, Electron can throw + } + newWindow.location.href = uri; + } else { + console.warn("Opening link blocked as opener could not be cleared"); + } + }; + + if (!agentName || !workspaceName || !username || !proxyHost) { + open(uri); + return; + } + + try { + const url = new URL(uri); + const localHosts = ["0.0.0.0", "127.0.0.1", "localhost"]; + if (!localHosts.includes(url.hostname)) { + open(uri); + return; + } + open( + portForwardURL( + proxyHost, + parseInt(url.port), + agentName, + workspaceName, + username, + ) + url.pathname, + ); + } catch (ex) { + open(uri); + } +}; diff --git a/site/src/utils/templateVersion.ts b/site/src/utils/templateVersion.ts index 8433d3f884900..00bb3c6562a4e 100644 --- a/site/src/utils/templateVersion.ts +++ b/site/src/utils/templateVersion.ts @@ -1,18 +1,15 @@ import * as API from "api/api"; -import { TemplateVersion } from "api/typesGenerated"; import { FileTree, createFile } from "./filetree"; import { TarReader } from "./tar"; -/** - * Content by filename - */ +// Content by filename export type TemplateVersionFiles = Record; export const getTemplateVersionFiles = async ( - version: TemplateVersion, + fileId: string, ): Promise => { const files: TemplateVersionFiles = {}; - const tarFile = await API.getFile(version.job.file_id); + const tarFile = await API.getFile(fileId); const tarReader = new TarReader(); await tarReader.readFile(tarFile); for (const file of tarReader.fileInfo) { diff --git a/site/src/utils/terminal.ts b/site/src/utils/terminal.ts new file mode 100644 index 0000000000000..52d46feaafcf6 --- /dev/null +++ b/site/src/utils/terminal.ts @@ -0,0 +1,37 @@ +import * as API from "api/api"; + +export const terminalWebsocketUrl = async ( + baseUrl: string | undefined, + reconnect: string, + agentId: string, + command: string | undefined, +): Promise => { + const query = new URLSearchParams({ reconnect }); + if (command) { + query.set("command", command); + } + + const url = new URL(baseUrl || `${location.protocol}//${location.host}`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + if (!url.pathname.endsWith("/")) { + url.pathname + "/"; + } + url.pathname += `api/v2/workspaceagents/${agentId}/pty`; + url.search = "?" + query.toString(); + + // If the URL is just the primary API, we don't need a signed token to + // connect. + if (!baseUrl) { + return url.toString(); + } + + // Do ticket issuance and set the query parameter. + const tokenRes = await API.issueReconnectingPTYSignedToken({ + url: url.toString(), + agentID: agentId, + }); + query.set("coder_signed_app_token_23db1dde", tokenRes.signed_token); + url.search = "?" + query.toString(); + + return url.toString(); +}; diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx index 8e4c6596e49a4..9365e5d615cac 100644 --- a/site/src/utils/workspace.tsx +++ b/site/src/utils/workspace.tsx @@ -286,3 +286,20 @@ export const hasJobError = (workspace: TypesGen.Workspace) => { export const paramsUsedToCreateWorkspace = ( param: TypesGen.TemplateVersionParameter, ) => !param.ephemeral; + +export const getMatchingAgentOrFirst = ( + workspace: TypesGen.Workspace, + agentName: string | undefined, +): TypesGen.WorkspaceAgent | undefined => { + return workspace.latest_build.resources + .map((resource) => { + if (!resource.agents || resource.agents.length === 0) { + return; + } + if (!agentName) { + return resource.agents[0]; + } + return resource.agents.find((agent) => agent.name === agentName); + }) + .filter((a) => a)[0]; +}; diff --git a/site/src/xServices/template/searchUsersAndGroupsXService.ts b/site/src/xServices/template/searchUsersAndGroupsXService.ts deleted file mode 100644 index aee89da2819da..0000000000000 --- a/site/src/xServices/template/searchUsersAndGroupsXService.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { getGroups, getTemplateACLAvailable, getUsers } from "api/api"; -import { Group, User } from "api/typesGenerated"; -import { queryToFilter } from "utils/filters"; -import { everyOneGroup } from "utils/groups"; -import { assign, createMachine } from "xstate"; - -export type SearchUsersAndGroupsEvent = - | { type: "SEARCH"; query: string } - | { type: "CLEAR_RESULTS" }; - -export const searchUsersAndGroupsMachine = createMachine( - { - id: "searchUsersAndGroups", - predictableActionArguments: true, - schema: { - context: {} as { - organizationId: string; - templateID?: string; - userResults: User[]; - groupResults: Group[]; - }, - events: {} as SearchUsersAndGroupsEvent, - services: {} as { - search: { - data: { - users: User[]; - groups: Group[]; - }; - }; - }, - }, - tsTypes: {} as import("./searchUsersAndGroupsXService.typegen").Typegen0, - initial: "idle", - states: { - idle: { - on: { - SEARCH: { - target: "searching", - cond: "queryHasMinLength", - }, - CLEAR_RESULTS: { - actions: ["clearResults"], - target: "idle", - }, - }, - }, - searching: { - invoke: { - src: "search", - onDone: { - target: "idle", - actions: ["assignSearchResults"], - }, - }, - }, - }, - }, - { - services: { - search: async ({ organizationId, templateID }, { query }) => { - let users, groups; - if (templateID && templateID !== "") { - const res = await getTemplateACLAvailable( - templateID, - queryToFilter(query), - ); - users = res.users; - groups = res.groups; - } else { - const [userRes, groupsRes] = await Promise.all([ - getUsers(queryToFilter(query)), - getGroups(organizationId), - ]); - - users = userRes.users; - groups = groupsRes; - } - - // The Everyone groups is not returned by the API so we have to add it - // manually - return { - users: users, - groups: [everyOneGroup(organizationId), ...groups], - }; - }, - }, - actions: { - assignSearchResults: assign({ - userResults: (_, { data }) => data.users, - groupResults: (_, { data }) => data.groups, - }), - clearResults: assign({ - userResults: (_) => [], - groupResults: (_) => [], - }), - }, - guards: { - queryHasMinLength: (_, { query }) => query.length >= 3, - }, - }, -); diff --git a/site/src/xServices/template/templateACLXService.ts b/site/src/xServices/template/templateACLXService.ts deleted file mode 100644 index 9b0b1e7462d1d..0000000000000 --- a/site/src/xServices/template/templateACLXService.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { getTemplateACL, updateTemplateACL } from "api/api"; -import { - TemplateACL, - TemplateGroup, - TemplateRole, - TemplateUser, -} from "api/typesGenerated"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; -import { assign, createMachine } from "xstate"; - -export const templateACLMachine = createMachine( - { - schema: { - context: {} as { - templateId: string; - templateACL?: TemplateACL; - // User - userToBeAdded?: TemplateUser; - userToBeUpdated?: TemplateUser; - addUserCallback?: () => void; - // Group - groupToBeAdded?: TemplateGroup; - groupToBeUpdated?: TemplateGroup; - addGroupCallback?: () => void; - }, - services: {} as { - loadTemplateACL: { - data: TemplateACL; - }; - // User - addUser: { - data: unknown; - }; - updateUser: { - data: unknown; - }; - // Group - addGroup: { - data: unknown; - }; - updateGroup: { - data: unknown; - }; - }, - events: {} as // User - | { - type: "ADD_USER"; - user: TemplateUser; - role: TemplateRole; - onDone: () => void; - } - | { - type: "UPDATE_USER_ROLE"; - user: TemplateUser; - role: TemplateRole; - } - | { - type: "REMOVE_USER"; - user: TemplateUser; - } - // Group - | { - type: "ADD_GROUP"; - group: TemplateGroup; - role: TemplateRole; - onDone: () => void; - } - | { - type: "UPDATE_GROUP_ROLE"; - group: TemplateGroup; - role: TemplateRole; - } - | { - type: "REMOVE_GROUP"; - group: TemplateGroup; - }, - }, - tsTypes: {} as import("./templateACLXService.typegen").Typegen0, - id: "templateACL", - initial: "loading", - states: { - loading: { - invoke: { - src: "loadTemplateACL", - onDone: { - actions: ["assignTemplateACL"], - target: "idle", - }, - }, - }, - idle: { - on: { - // User - ADD_USER: { target: "addingUser", actions: ["assignUserToBeAdded"] }, - UPDATE_USER_ROLE: { - target: "updatingUser", - actions: ["assignUserToBeUpdated"], - }, - REMOVE_USER: { - target: "removingUser", - actions: ["removeUserFromTemplateACL"], - }, - // Group - ADD_GROUP: { - target: "addingGroup", - actions: ["assignGroupToBeAdded"], - }, - UPDATE_GROUP_ROLE: { - target: "updatingGroup", - actions: ["assignGroupToBeUpdated"], - }, - REMOVE_GROUP: { - target: "removingGroup", - actions: ["removeGroupFromTemplateACL"], - }, - }, - }, - // User - addingUser: { - invoke: { - src: "addUser", - onDone: { - target: "idle", - actions: ["addUserToTemplateACL", "runAddUserCallback"], - }, - }, - }, - updatingUser: { - invoke: { - src: "updateUser", - onDone: { - target: "idle", - actions: [ - "updateUserOnTemplateACL", - "clearUserToBeUpdated", - "displayUpdateUserSuccessMessage", - ], - }, - }, - }, - removingUser: { - invoke: { - src: "removeUser", - onDone: { - target: "idle", - actions: ["displayRemoveUserSuccessMessage"], - }, - }, - }, - // Group - addingGroup: { - invoke: { - src: "addGroup", - onDone: { - target: "idle", - actions: ["addGroupToTemplateACL", "runAddGroupCallback"], - }, - }, - }, - updatingGroup: { - invoke: { - src: "updateGroup", - onDone: { - target: "idle", - actions: [ - "updateGroupOnTemplateACL", - "clearGroupToBeUpdated", - "displayUpdateGroupSuccessMessage", - ], - }, - }, - }, - removingGroup: { - invoke: { - src: "removeGroup", - onDone: { - target: "idle", - actions: ["displayRemoveGroupSuccessMessage"], - }, - }, - }, - }, - }, - { - services: { - loadTemplateACL: ({ templateId }) => getTemplateACL(templateId), - // User - addUser: ({ templateId }, { user, role }) => - updateTemplateACL(templateId, { - user_perms: { - [user.id]: role, - }, - }), - updateUser: ({ templateId }, { user, role }) => - updateTemplateACL(templateId, { - user_perms: { - [user.id]: role, - }, - }), - removeUser: ({ templateId }, { user }) => - updateTemplateACL(templateId, { - user_perms: { - [user.id]: "", - }, - }), - // Group - addGroup: ({ templateId }, { group, role }) => - updateTemplateACL(templateId, { - group_perms: { - [group.id]: role, - }, - }), - updateGroup: ({ templateId }, { group, role }) => - updateTemplateACL(templateId, { - group_perms: { - [group.id]: role, - }, - }), - removeGroup: ({ templateId }, { group }) => - updateTemplateACL(templateId, { - group_perms: { - [group.id]: "", - }, - }), - }, - actions: { - assignTemplateACL: assign({ - templateACL: (_, { data }) => data, - }), - // User - assignUserToBeAdded: assign({ - userToBeAdded: (_, { user, role }) => ({ ...user, role }), - addUserCallback: (_, { onDone }) => onDone, - }), - addUserToTemplateACL: assign({ - templateACL: ({ templateACL, userToBeAdded }) => { - if (!userToBeAdded) { - throw new Error("No user to be added"); - } - if (!templateACL) { - throw new Error("Template ACL is not loaded yet"); - } - return { - ...templateACL, - users: [...templateACL.users, userToBeAdded], - }; - }, - }), - runAddUserCallback: ({ addUserCallback }) => { - if (addUserCallback) { - addUserCallback(); - } - }, - assignUserToBeUpdated: assign({ - userToBeUpdated: (_, { user, role }) => ({ ...user, role }), - }), - updateUserOnTemplateACL: assign({ - templateACL: ({ templateACL, userToBeUpdated }) => { - if (!userToBeUpdated) { - throw new Error("No user to be added"); - } - if (!templateACL) { - throw new Error("Template ACL is not loaded yet"); - } - return { - ...templateACL, - users: templateACL.users.map((oldTemplateUser) => { - return oldTemplateUser.id === userToBeUpdated.id - ? userToBeUpdated - : oldTemplateUser; - }), - }; - }, - }), - clearUserToBeUpdated: assign({ - userToBeUpdated: (_) => undefined, - }), - displayUpdateUserSuccessMessage: () => { - displaySuccess("User role update successfully!"); - }, - removeUserFromTemplateACL: assign({ - templateACL: ({ templateACL }, { user }) => { - if (!templateACL) { - throw new Error("Template ACL is not loaded yet"); - } - return { - ...templateACL, - users: templateACL.users.filter((oldTemplateUser) => { - return oldTemplateUser.id !== user.id; - }), - }; - }, - }), - displayRemoveUserSuccessMessage: () => { - displaySuccess("User removed successfully!"); - }, - // Group - assignGroupToBeAdded: assign({ - groupToBeAdded: (_, { group, role }) => ({ ...group, role }), - addGroupCallback: (_, { onDone }) => onDone, - }), - addGroupToTemplateACL: assign({ - templateACL: ({ templateACL, groupToBeAdded }) => { - if (!groupToBeAdded) { - throw new Error("No group to be added"); - } - if (!templateACL) { - throw new Error("Template ACL is not loaded yet"); - } - return { - ...templateACL, - group: [...templateACL.group, groupToBeAdded], - }; - }, - }), - runAddGroupCallback: ({ addGroupCallback }) => { - if (addGroupCallback) { - addGroupCallback(); - } - }, - assignGroupToBeUpdated: assign({ - groupToBeUpdated: (_, { group, role }) => ({ ...group, role }), - }), - updateGroupOnTemplateACL: assign({ - templateACL: ({ templateACL, groupToBeUpdated }) => { - if (!groupToBeUpdated) { - throw new Error("No group to be added"); - } - if (!templateACL) { - throw new Error("Template ACL is not loaded yet"); - } - return { - ...templateACL, - group: templateACL.group.map((oldTemplateGroup) => { - return oldTemplateGroup.id === groupToBeUpdated.id - ? groupToBeUpdated - : oldTemplateGroup; - }), - }; - }, - }), - clearGroupToBeUpdated: assign({ - groupToBeUpdated: (_) => undefined, - }), - displayUpdateGroupSuccessMessage: () => { - displaySuccess("Group role update successfully!"); - }, - removeGroupFromTemplateACL: assign({ - templateACL: ({ templateACL }, { group }) => { - if (!templateACL) { - throw new Error("Template ACL is not loaded yet"); - } - return { - ...templateACL, - group: templateACL.group.filter((oldTemplateGroup) => { - return oldTemplateGroup.id !== group.id; - }), - }; - }, - }), - displayRemoveGroupSuccessMessage: () => { - displaySuccess("Group removed successfully!"); - }, - }, - }, -); diff --git a/site/src/xServices/templateVersion/templateVersionXService.ts b/site/src/xServices/templateVersion/templateVersionXService.ts deleted file mode 100644 index 4cad1e061c0af..0000000000000 --- a/site/src/xServices/templateVersion/templateVersionXService.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { - getPreviousTemplateVersionByName, - GetPreviousTemplateVersionByNameResponse, - getTemplateByName, - getTemplateVersionByName, -} from "api/api"; -import { Template, TemplateVersion } from "api/typesGenerated"; -import { - getTemplateVersionFiles, - TemplateVersionFiles, -} from "utils/templateVersion"; -import { assign, createMachine } from "xstate"; - -export interface TemplateVersionMachineContext { - orgId: string; - templateName: string; - versionName: string; - template?: Template; - currentVersion?: TemplateVersion; - currentFiles?: TemplateVersionFiles; - error?: unknown; - // Get file diffs - previousVersion?: TemplateVersion; - previousFiles?: TemplateVersionFiles; -} - -export const templateVersionMachine = createMachine( - { - predictableActionArguments: true, - id: "templateVersion", - schema: { - context: {} as TemplateVersionMachineContext, - services: {} as { - loadVersions: { - data: { - currentVersion: GetPreviousTemplateVersionByNameResponse; - previousVersion: GetPreviousTemplateVersionByNameResponse; - }; - }; - loadTemplate: { - data: { - template: Template; - }; - }; - loadFiles: { - data: { - currentFiles: TemplateVersionFiles; - previousFiles: TemplateVersionFiles; - }; - }; - }, - }, - tsTypes: {} as import("./templateVersionXService.typegen").Typegen0, - initial: "initialInfo", - states: { - initialInfo: { - type: "parallel", - states: { - versions: { - initial: "loadingVersions", - states: { - loadingVersions: { - invoke: { - src: "loadVersions", - onDone: [ - { - actions: "assignVersions", - target: "success", - }, - ], - }, - }, - success: { - type: "final", - }, - }, - }, - template: { - initial: "loadingTemplate", - states: { - loadingTemplate: { - invoke: { - src: "loadTemplate", - onDone: [ - { - actions: "assignTemplate", - target: "success", - }, - ], - }, - }, - success: { - type: "final", - }, - }, - }, - }, - onDone: { - target: "loadingFiles", - }, - }, - loadingFiles: { - invoke: { - src: "loadFiles", - onDone: { - target: "done.ok", - actions: ["assignFiles"], - }, - onError: { - target: "done.error", - actions: ["assignError"], - }, - }, - }, - done: { - states: { - ok: { type: "final" }, - error: { type: "final" }, - }, - }, - }, - }, - { - actions: { - assignError: assign({ - error: (_, { data }) => data, - }), - assignTemplate: assign({ - template: (_, { data }) => data.template, - }), - assignVersions: assign({ - currentVersion: (_, { data }) => data.currentVersion, - previousVersion: (_, { data }) => data.previousVersion, - }), - assignFiles: assign({ - currentFiles: (_, { data }) => data.currentFiles, - previousFiles: (_, { data }) => data.previousFiles, - }), - }, - services: { - loadVersions: async ({ orgId, templateName, versionName }) => { - const [currentVersion, previousVersion] = await Promise.all([ - getTemplateVersionByName(orgId, templateName, versionName), - getPreviousTemplateVersionByName(orgId, templateName, versionName), - ]); - - return { - currentVersion, - previousVersion, - }; - }, - loadTemplate: async ({ orgId, templateName }) => { - const template = await getTemplateByName(orgId, templateName); - - return { - template, - }; - }, - loadFiles: async ({ currentVersion, previousVersion }) => { - if (!currentVersion) { - throw new Error("Version is not defined"); - } - const loadFilesPromises: ReturnType[] = - []; - loadFilesPromises.push(getTemplateVersionFiles(currentVersion)); - if (previousVersion) { - loadFilesPromises.push(getTemplateVersionFiles(previousVersion)); - } - const [currentFiles, previousFiles] = await Promise.all( - loadFilesPromises, - ); - return { - currentFiles, - previousFiles, - }; - }, - }, - }, -); diff --git a/site/src/xServices/terminal/terminalXService.ts b/site/src/xServices/terminal/terminalXService.ts deleted file mode 100644 index b983726ea2baf..0000000000000 --- a/site/src/xServices/terminal/terminalXService.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { assign, createMachine } from "xstate"; -import * as API from "api/api"; -import * as TypesGen from "api/typesGenerated"; - -interface ReconnectingPTYRequest { - readonly data?: string; - readonly height?: number; - readonly width?: number; -} - -export interface TerminalContext { - workspaceError?: unknown; - workspace?: TypesGen.Workspace; - workspaceAgent?: TypesGen.WorkspaceAgent; - workspaceAgentError?: unknown; - websocket?: WebSocket; - websocketError?: unknown; - websocketURL?: string; - websocketURLError?: unknown; - - // Assigned by connecting! - // The workspace agent is entirely optional. If the agent is omitted the - // first agent will be used. - agentName?: string; - username?: string; - workspaceName?: string; - reconnection?: string; - command?: string; - // If baseURL is not..... - baseURL?: string; -} - -export type TerminalEvent = - | { - type: "CONNECT"; - agentName?: string; - reconnection?: string; - workspaceName?: string; - username?: string; - } - | { type: "WRITE"; request: ReconnectingPTYRequest } - | { type: "READ"; data: ArrayBuffer } - | { type: "DISCONNECT" }; - -export const terminalMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QBcwCcC2BLAdgQwBsBlZPVAOljGQFcAHAYggHscxLSLVNdCSz2VWnQDaABgC6iUHWawsyLK2kgAHogAcAVgCM5MQGYdAJgMaALOYDsV8zq0AaEAE9ExgGwHyATmPf3VmI6Yub+5gYAvhFO3Nj4xJyC1PTkMMgA6sxoANawdHgAxuxpijhQmTl5hWBMrOy4AG7M2cXUFbn5ReJSSCCy8orKveoI5qbkOhq23hozflruxuZOrghGXuZaRsFiWlbeWuYa7lEx6HF8iZTJdKltWR3Vd8il5Q9VRQzoaFnkdARkABmWQwz3aHzA3RU-QUShwKhGYy8k2msw080WyxcbmMYnIoW8hO8ZkMYisOlOIFivASAmer3BnTAAEEYDhkLU2ORGs1Whl3kzWWB2VDejDBvDhogdAYrFoJuYxJjdmJvCENCs3Fo8e4glpjFptWSDhpKdT4vwKCVcG9KoK2Rzvr9-kCQWCBdUhSLJNC5LChqARotNQgpniNAYtAdjFYDL4pidolTzjTLXyGWAAEZEZgFFrIACqACUADKc+o4JotZ45vPUYsl0UyP0ShGIWVWchGA27Akzbwh4LjDQmAk6AmBbxmlMWq7WsrpLO1-MNr5oH5oP4A5DAzA13Mr0tNvotuFthBWYyDo56ULGewWHTk0zGac8Wd0gqsNgFV7l7mVry5BfjgP7IMe4pnlKobmO45D3mG7ihFMBgBIOhgaPoizar4xIRv4b4XLSFAgWBNprhuW6unupFgL+EGngGaiIMG2IIPYtj4psMYaGIxh+Do9iEamVy0b+kAMOkRYAJIACoAKIMQMUGBogdiYZs3Z+N444GqYg42F4kY6LqmxWLMUaREm5qXJ+350agEAMEW8nMgAIkp-qSqpow6N45BIRYkYRgsBxyoOASdnG2gyu43jcUhwkfiR9niU5bnSUQADCADyAByeXyVlsmea20F+Zho6EksgU6WIGpsZMuj6OZfimLB0bmEltkUBAWCwGJjkMLlBVFSVPpiox3nMexV6Na+lI4MwEBwCoNnEWAvrKUxIwALRjCGu1yuQRpiCEBpyrshLdRt1zCFtXnnu4IabCdZ1nWMezalGFLWTOPVJMI7p2tUD1lT5hyDgYXjvR9KrxSZXV-e+AN3SkaSMk8862o8RRgypM1DiGL4+FY7iGvqniXvYSNnCjt1COj9wg0UlA0AURSwPAk3bdNQbQ-o3YLCYHGwcTRwBeE-GYjToRWDdab0jamNFF6yD4ztiD+PKgTdtYljuAEBjE3G+J+HFI7ah45IK3O1AZtmB71qWGt84gezofe+j2Lq7h+XxSFWXTRGK4NNqu09fFYUssyLPxCyOI1hjyh4CzW1b3jy8jIeialjkR9BhzweTiwBPqOm2Asg5TOYXbqmY6xISEtt0n1A155ABc+aheiGCYfhGAnthWIO33kOSxwRn3vgBFEURAA */ - createMachine( - { - id: "terminalState", - predictableActionArguments: true, - tsTypes: {} as import("./terminalXService.typegen").Typegen0, - schema: { - context: {} as TerminalContext, - events: {} as TerminalEvent, - services: {} as { - getWorkspace: { - data: TypesGen.Workspace; - }; - getWorkspaceAgent: { - data: TypesGen.WorkspaceAgent; - }; - getWebsocketURL: { - data: string; - }; - connect: { - data: WebSocket; - }; - }, - }, - initial: "setup", - states: { - setup: { - type: "parallel", - states: { - getWorkspace: { - initial: "gettingWorkspace", - states: { - gettingWorkspace: { - invoke: { - src: "getWorkspace", - id: "getWorkspace", - onDone: [ - { - actions: ["assignWorkspace", "clearWorkspaceError"], - target: "success", - }, - ], - onError: [ - { - actions: "assignWorkspaceError", - target: "success", - }, - ], - }, - }, - success: { - type: "final", - }, - }, - }, - }, - onDone: { - target: "gettingWorkspaceAgent", - }, - }, - gettingWorkspaceAgent: { - invoke: { - src: "getWorkspaceAgent", - id: "getWorkspaceAgent", - onDone: [ - { - actions: ["assignWorkspaceAgent", "clearWorkspaceAgentError"], - target: "gettingWebSocketURL", - }, - ], - onError: [ - { - actions: "assignWorkspaceAgentError", - target: "disconnected", - }, - ], - }, - }, - gettingWebSocketURL: { - invoke: { - src: "getWebsocketURL", - id: "getWebsocketURL", - onDone: [ - { - actions: ["assignWebsocketURL", "clearWebsocketURLError"], - target: "connecting", - }, - ], - onError: [ - { - actions: "assignWebsocketURLError", - target: "disconnected", - }, - ], - }, - }, - connecting: { - invoke: { - src: "connect", - id: "connect", - onDone: [ - { - actions: ["assignWebsocket", "clearWebsocketError"], - target: "connected", - }, - ], - onError: [ - { - actions: "assignWebsocketError", - target: "disconnected", - }, - ], - }, - }, - connected: { - on: { - WRITE: { - actions: "sendMessage", - }, - READ: { - actions: "readMessage", - }, - DISCONNECT: { - actions: "disconnect", - target: "disconnected", - }, - }, - }, - disconnected: { - on: { - CONNECT: { - actions: "assignConnection", - target: "gettingWorkspaceAgent", - }, - }, - }, - }, - }, - { - services: { - getWorkspace: async (context) => { - if (!context.workspaceName) { - throw new Error("workspace name not set"); - } - return API.getWorkspaceByOwnerAndName( - context.username, - context.workspaceName, - ); - }, - getWorkspaceAgent: async (context) => { - if (!context.workspace || !context.workspaceName) { - throw new Error("workspace or workspace name is not set"); - } - - const agent = context.workspace.latest_build.resources - .map((resource) => { - if (!resource.agents || resource.agents.length === 0) { - return; - } - if (!context.agentName) { - return resource.agents[0]; - } - return resource.agents.find( - (agent) => agent.name === context.agentName, - ); - }) - .filter((a) => a)[0]; - if (!agent) { - throw new Error("no agent found with id"); - } - return agent; - }, - getWebsocketURL: async (context) => { - if (!context.workspaceAgent) { - throw new Error("workspace agent is not set"); - } - if (!context.reconnection) { - throw new Error("reconnection ID is not set"); - } - - let baseURL = context.baseURL || ""; - if (!baseURL) { - baseURL = `${location.protocol}//${location.host}`; - } - - const query = new URLSearchParams({ - reconnect: context.reconnection, - }); - if (context.command) { - query.set("command", context.command); - } - - const url = new URL(baseURL); - url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - if (!url.pathname.endsWith("/")) { - url.pathname + "/"; - } - url.pathname += `api/v2/workspaceagents/${context.workspaceAgent.id}/pty`; - url.search = "?" + query.toString(); - - // If the URL is just the primary API, we don't need a signed token to - // connect. - if (!context.baseURL) { - return url.toString(); - } - - // Do ticket issuance and set the query parameter. - const tokenRes = await API.issueReconnectingPTYSignedToken({ - url: url.toString(), - agentID: context.workspaceAgent.id, - }); - query.set("coder_signed_app_token_23db1dde", tokenRes.signed_token); - url.search = "?" + query.toString(); - - return url.toString(); - }, - connect: (context) => (send) => { - return new Promise((resolve, reject) => { - if (!context.workspaceAgent) { - return reject("workspace agent is not set"); - } - if (!context.websocketURL) { - return reject("websocket URL is not set"); - } - - const socket = new WebSocket(context.websocketURL); - socket.binaryType = "arraybuffer"; - socket.addEventListener("open", () => { - resolve(socket); - }); - socket.addEventListener("error", () => { - reject(new Error("socket errored")); - }); - socket.addEventListener("close", () => { - send({ - type: "DISCONNECT", - }); - }); - socket.addEventListener("message", (event) => { - send({ - type: "READ", - data: event.data, - }); - }); - }); - }, - }, - actions: { - assignConnection: assign((context, event) => ({ - ...context, - agentName: event.agentName ?? context.agentName, - reconnection: event.reconnection ?? context.reconnection, - workspaceName: event.workspaceName ?? context.workspaceName, - })), - assignWorkspace: assign({ - workspace: (_, event) => event.data, - }), - assignWorkspaceError: assign({ - workspaceError: (_, event) => event.data, - }), - clearWorkspaceError: assign((context) => ({ - ...context, - workspaceError: undefined, - })), - assignWorkspaceAgent: assign({ - workspaceAgent: (_, event) => event.data, - }), - assignWorkspaceAgentError: assign({ - workspaceAgentError: (_, event) => event.data, - }), - clearWorkspaceAgentError: assign((context: TerminalContext) => ({ - ...context, - workspaceAgentError: undefined, - })), - assignWebsocket: assign({ - websocket: (_, event) => event.data, - }), - assignWebsocketError: assign({ - websocketError: (_, event) => event.data, - }), - clearWebsocketError: assign((context: TerminalContext) => ({ - ...context, - webSocketError: undefined, - })), - assignWebsocketURL: assign({ - websocketURL: (context, event) => event.data ?? context.websocketURL, - }), - assignWebsocketURLError: assign({ - websocketURLError: (_, event) => event.data, - }), - clearWebsocketURLError: assign((context: TerminalContext) => ({ - ...context, - websocketURLError: undefined, - })), - sendMessage: (context, event) => { - if (!context.websocket) { - throw new Error("websocket doesn't exist"); - } - context.websocket.send( - new TextEncoder().encode(JSON.stringify(event.request)), - ); - }, - disconnect: (context: TerminalContext) => { - // Code 1000 is a successful exit! - context.websocket?.close(1000); - }, - }, - }, - ); diff --git a/site/src/xServices/updateCheck/updateCheckXService.test.ts b/site/src/xServices/updateCheck/updateCheckXService.test.ts deleted file mode 100644 index 0da61689550fc..0000000000000 --- a/site/src/xServices/updateCheck/updateCheckXService.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { waitFor } from "@testing-library/react"; -import { MockPermissions, MockUpdateCheck } from "testHelpers/entities"; -import { interpret } from "xstate"; -import { - clearDismissedVersionOnLocal, - getDismissedVersionOnLocal, - saveDismissedVersionOnLocal, - updateCheckMachine, -} from "./updateCheckXService"; - -describe("updateCheckMachine", () => { - beforeEach(() => { - clearDismissedVersionOnLocal(); - }); - - it("is dismissed when does not have permission to see it", () => { - const machine = updateCheckMachine.withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: false, - }, - }); - - const updateCheckService = interpret(machine); - updateCheckService.start(); - expect(updateCheckService.state.matches("dismissed")).toBeTruthy(); - }); - - it("is dismissed when it is already using current version", async () => { - const machine = updateCheckMachine - .withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: true, - }, - }) - .withConfig({ - services: { - getUpdateCheck: () => - Promise.resolve({ - ...MockUpdateCheck, - current: true, - }), - }, - }); - - const updateCheckService = interpret(machine); - updateCheckService.start(); - - await waitFor(() => { - expect(updateCheckService.state.matches("dismissed")).toBeTruthy(); - }); - }); - - it("is dismissed when it was dismissed previously", async () => { - const machine = updateCheckMachine - .withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: true, - }, - }) - .withConfig({ - services: { - getUpdateCheck: () => - Promise.resolve({ - ...MockUpdateCheck, - current: false, - }), - }, - }); - - saveDismissedVersionOnLocal(MockUpdateCheck.version); - const updateCheckService = interpret(machine); - updateCheckService.start(); - - await waitFor(() => { - expect(updateCheckService.state.matches("dismissed")).toBeTruthy(); - }); - }); - - it("shows when has permission and is outdated", async () => { - const machine = updateCheckMachine - .withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: true, - }, - }) - .withConfig({ - services: { - getUpdateCheck: () => - Promise.resolve({ - ...MockUpdateCheck, - current: false, - }), - }, - }); - - const updateCheckService = interpret(machine); - updateCheckService.start(); - - await waitFor(() => { - expect(updateCheckService.state.matches("show")).toBeTruthy(); - }); - }); - - it("it is dismissed when the DISMISS event happens", async () => { - const machine = updateCheckMachine - .withContext({ - permissions: { - ...MockPermissions, - viewUpdateCheck: true, - }, - }) - .withConfig({ - services: { - getUpdateCheck: () => - Promise.resolve({ - ...MockUpdateCheck, - current: false, - }), - }, - }); - - const updateCheckService = interpret(machine); - updateCheckService.start(); - await waitFor(() => { - expect(updateCheckService.state.matches("show")).toBeTruthy(); - }); - - updateCheckService.send("DISMISS"); - await waitFor(() => { - expect(updateCheckService.state.matches("dismissed")).toBeTruthy(); - }); - expect(getDismissedVersionOnLocal()).toEqual(MockUpdateCheck.version); - }); -}); diff --git a/site/src/xServices/updateCheck/updateCheckXService.ts b/site/src/xServices/updateCheck/updateCheckXService.ts deleted file mode 100644 index 099c8501b320f..0000000000000 --- a/site/src/xServices/updateCheck/updateCheckXService.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { assign, createMachine } from "xstate"; -import { getUpdateCheck } from "api/api"; -import { AuthorizationResponse, UpdateCheckResponse } from "api/typesGenerated"; -import { checks, Permissions } from "components/AuthProvider/permissions"; - -export interface UpdateCheckContext { - permissions: Permissions; - updateCheck?: UpdateCheckResponse; - error?: unknown; -} - -export type UpdateCheckEvent = { type: "DISMISS" }; - -export const updateCheckMachine = createMachine( - { - id: "updateCheckState", - predictableActionArguments: true, - tsTypes: {} as import("./updateCheckXService.typegen").Typegen0, - schema: { - context: {} as UpdateCheckContext, - events: {} as UpdateCheckEvent, - services: {} as { - checkPermissions: { - data: AuthorizationResponse; - }; - getUpdateCheck: { - data: UpdateCheckResponse; - }; - }, - }, - initial: "checkingPermissions", - states: { - checkingPermissions: { - always: [ - { - target: "fetchingUpdateCheck", - cond: "canViewUpdateCheck", - }, - { - target: "dismissed", - }, - ], - }, - fetchingUpdateCheck: { - invoke: { - src: "getUpdateCheck", - id: "getUpdateCheck", - onDone: [ - { - actions: ["assignUpdateCheck"], - target: "show", - cond: "shouldShowUpdateCheck", - }, - { - target: "dismissed", - }, - ], - onError: [ - { - actions: ["assignError"], - target: "dismissed", - }, - ], - }, - }, - show: { - on: { - DISMISS: { - actions: ["setDismissedVersion"], - target: "dismissed", - }, - }, - }, - dismissed: { - type: "final", - }, - }, - }, - { - services: { - // For some reason, when passing values directly, jest.spy does not work. - getUpdateCheck: () => getUpdateCheck(), - }, - actions: { - assignUpdateCheck: assign({ - updateCheck: (_, event) => event.data, - }), - assignError: assign({ - error: (_, event) => event.data, - }), - setDismissedVersion: ({ updateCheck }) => { - if (!updateCheck) { - throw new Error("Update check is not set"); - } - - saveDismissedVersionOnLocal(updateCheck.version); - }, - }, - guards: { - canViewUpdateCheck: ({ permissions }) => - permissions[checks.viewUpdateCheck] || false, - shouldShowUpdateCheck: (_, { data }) => { - const isNotDismissed = getDismissedVersionOnLocal() !== data.version; - const isOutdated = !data.current; - return isNotDismissed && isOutdated; - }, - }, - }, -); - -// Exporting to be used in the tests -export const saveDismissedVersionOnLocal = (version: string): void => { - window.localStorage.setItem("dismissedVersion", version); -}; - -export const getDismissedVersionOnLocal = (): string | undefined => { - return localStorage.getItem("dismissedVersion") ?? undefined; -}; - -export const clearDismissedVersionOnLocal = (): void => { - localStorage.removeItem("dismissedVersion"); -}; diff --git a/site/static/icon/apple-black.svg b/site/static/icon/apple-black.svg new file mode 100644 index 0000000000000..82b0cddcdbf27 --- /dev/null +++ b/site/static/icon/apple-black.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/site/static/icon/apple-grey.svg b/site/static/icon/apple-grey.svg new file mode 100644 index 0000000000000..3f06156a4bce8 --- /dev/null +++ b/site/static/icon/apple-grey.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/site/static/icon/discord.svg b/site/static/icon/discord.svg new file mode 100644 index 0000000000000..ca65400760907 --- /dev/null +++ b/site/static/icon/discord.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/static/icon/google.svg b/site/static/icon/google.svg new file mode 100644 index 0000000000000..088288fa3fb36 --- /dev/null +++ b/site/static/icon/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/static/icon/microsoft.svg b/site/static/icon/microsoft.svg new file mode 100644 index 0000000000000..5334aa7ca6864 --- /dev/null +++ b/site/static/icon/microsoft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/static/icon/okta.svg b/site/static/icon/okta.svg new file mode 100644 index 0000000000000..5595186b2abec --- /dev/null +++ b/site/static/icon/okta.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + +