Compare commits

...

6 Commits

Author SHA1 Message Date
gcp-cherry-pick-bot[bot] 75e7a93598 fix: stop tearing down non-TTY processes on SSH session end (cherry-pick #18673) (#18677)
Cherry-picked fix: stop tearing down non-TTY processes on SSH session
end (#18673)

(possibly temporary) fix for #18519

Matches OpenSSH for non-tty sessions, where we don't actively terminate
the process.

Adds explicit tracking to the SSH server for these processes so that if
we are shutting down we terminate them: this ensures that we can shut
down quickly to allow shutdown scripts to run. It also ensures our tests
don't leak system resources.

Co-authored-by: Spike Curtis <spike@coder.com>
2025-06-30 23:09:47 +04:00
gcp-cherry-pick-bot[bot] 8e8dd58506 fix(site): remove trailing comment from cursor.svg (cherry-pick #18072) (#18378)
Cherry-picked fix(site): remove trailing comment from cursor.svg
(#18072)

The trailing comment was preventing the SVG from rendering on Coder
Desktop macOS, with the SVG loader we use. I've moved it to a place
where it's apparently OK? Couldn't tell you why.
https://validator.w3.org/ had no complaints.

I tested this by hardcoding the icon to that served by a build of coder
with this new svg.



![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/jI7h94jB23BidWsYTSCk/4c94ae5f-d0e2-496e-90eb-4968cf40d639.png)

The first icon is without the trailing comment, the second is with.

Co-authored-by: Ethan <39577870+ethanndickson@users.noreply.github.com>
2025-06-16 13:36:44 +10:00
gcp-cherry-pick-bot[bot] bc089f3410 chore: add windows icon (cherry-pick #18312) (#18322)
Co-authored-by: ケイラ <mckayla@hey.com>
2025-06-11 13:27:38 +05:00
Cian Johnston b906c16b3b chore: revert breaking changes relating to WorkspaceOwnerName (#18304)
Cherry-picks following commits:

*
https://github.com/coder/coder/commit/f974add3730452bcf242af55fd1c5fe68ffda77f
reverts
https://github.com/coder/coder/commit/d63417b5426fdfbb980e77aebb0d48fa535ababc
*
https://github.com/coder/coder/commit/d779126ee34720adc7af455a110d932a7facd268
reverts
https://github.com/coder/coder/commit/2ec74041970680b66564dbd79238b62502f61598

---------

Co-authored-by: Bruno Quaresma <bruno@coder.com>
2025-06-10 14:00:45 +01:00
Stephen Kirby 3a68676b84 chore: cherry-pick bug fixes for release 2.23 (#18219)
Co-authored-by: Steven Masley <Emyrk@users.noreply.github.com>
Co-authored-by: Jaayden Halko <jaayden.halko@gmail.com>
Co-authored-by: Steven Masley <stevenmasley@gmail.com>
Co-authored-by: Ethan <39577870+ethanndickson@users.noreply.github.com>
2025-06-03 14:27:57 -05:00
gcp-cherry-pick-bot[bot] d3b6863ae9 docs: add link for Coder Desktop docs on workspace page (cherry-pick #18202) (#18204)
Co-authored-by: Atif Ali <atif@coder.com>
2025-06-03 19:19:35 +05:00
38 changed files with 537 additions and 337 deletions
+41 -1
View File
@@ -124,6 +124,7 @@ type Server struct {
listeners map[net.Listener]struct{}
conns map[net.Conn]struct{}
sessions map[ssh.Session]struct{}
processes map[*os.Process]struct{}
closing chan struct{}
// Wait for goroutines to exit, waited without
// a lock on mu but protected by closing.
@@ -182,6 +183,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
fs: fs,
conns: make(map[net.Conn]struct{}),
sessions: make(map[ssh.Session]struct{}),
processes: make(map[*os.Process]struct{}),
logger: logger,
config: config,
@@ -586,7 +588,10 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
// otherwise context cancellation will not propagate properly
// and SSH server close may be delayed.
cmd.SysProcAttr = cmdSysProcAttr()
cmd.Cancel = cmdCancel(session.Context(), logger, cmd)
// to match OpenSSH, we don't actually tear a non-TTY command down, even if the session ends.
// c.f. https://github.com/coder/coder/issues/18519#issuecomment-3019118271
cmd.Cancel = nil
cmd.Stdout = session
cmd.Stderr = session.Stderr()
@@ -609,6 +614,16 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "start_command").Add(1)
return xerrors.Errorf("start: %w", err)
}
// Since we don't cancel the process when the session stops, we still need to tear it down if we are closing. So
// track it here.
if !s.trackProcess(cmd.Process, true) {
// must be closing
err = cmdCancel(logger, cmd.Process)
return xerrors.Errorf("failed to track process: %w", err)
}
defer s.trackProcess(cmd.Process, false)
sigs := make(chan ssh.Signal, 1)
session.Signals(sigs)
defer func() {
@@ -1052,6 +1067,27 @@ func (s *Server) trackSession(ss ssh.Session, add bool) (ok bool) {
return true
}
// trackCommand registers the process with the server. If the server is
// closing, the process is not registered and should be closed.
//
//nolint:revive
func (s *Server) trackProcess(p *os.Process, add bool) (ok bool) {
s.mu.Lock()
defer s.mu.Unlock()
if add {
if s.closing != nil {
// Server closed.
return false
}
s.wg.Add(1)
s.processes[p] = struct{}{}
return true
}
s.wg.Done()
delete(s.processes, p)
return true
}
// Close the server and all active connections. Server can be re-used
// after Close is done.
func (s *Server) Close() error {
@@ -1091,6 +1127,10 @@ func (s *Server) Close() error {
_ = c.Close()
}
for p := range s.processes {
_ = cmdCancel(s.logger, p)
}
s.logger.Debug(ctx, "closing SSH server")
err := s.srv.Close()
+4 -6
View File
@@ -4,7 +4,7 @@ package agentssh
import (
"context"
"os/exec"
"os"
"syscall"
"cdr.dev/slog"
@@ -16,9 +16,7 @@ func cmdSysProcAttr() *syscall.SysProcAttr {
}
}
func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
return func() error {
logger.Debug(ctx, "cmdCancel: sending SIGHUP to process and children", slog.F("pid", cmd.Process.Pid))
return syscall.Kill(-cmd.Process.Pid, syscall.SIGHUP)
}
func cmdCancel(logger slog.Logger, p *os.Process) error {
logger.Debug(context.Background(), "cmdCancel: sending SIGHUP to process and children", slog.F("pid", p.Pid))
return syscall.Kill(-p.Pid, syscall.SIGHUP)
}
+9 -11
View File
@@ -2,7 +2,7 @@ package agentssh
import (
"context"
"os/exec"
"os"
"syscall"
"cdr.dev/slog"
@@ -12,14 +12,12 @@ func cmdSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{}
}
func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
return func() error {
logger.Debug(ctx, "cmdCancel: killing process", slog.F("pid", cmd.Process.Pid))
// Windows doesn't support sending signals to process groups, so we
// have to kill the process directly. In the future, we may want to
// implement a more sophisticated solution for process groups on
// Windows, but for now, this is a simple way to ensure that the
// process is terminated when the context is cancelled.
return cmd.Process.Kill()
}
func cmdCancel(logger slog.Logger, p *os.Process) error {
logger.Debug(context.Background(), "cmdCancel: killing process", slog.F("pid", p.Pid))
// Windows doesn't support sending signals to process groups, so we
// have to kill the process directly. In the future, we may want to
// implement a more sophisticated solution for process groups on
// Windows, but for now, this is a simple way to ensure that the
// process is terminated when the context is cancelled.
return p.Kill()
}
+9
View File
@@ -1594,12 +1594,14 @@ func writeCoderConnectNetInfo(ctx context.Context, networkInfoDir string) error
// Converts workspace name input to owner/workspace.agent format
// Possible valid input formats:
// workspace
// workspace.agent
// owner/workspace
// owner--workspace
// owner/workspace--agent
// owner/workspace.agent
// owner--workspace--agent
// owner--workspace.agent
// agent.workspace.owner - for parity with Coder Connect
func normalizeWorkspaceInput(input string) string {
// Split on "/", "--", and "."
parts := workspaceNameRe.Split(input, -1)
@@ -1608,8 +1610,15 @@ func normalizeWorkspaceInput(input string) string {
case 1:
return input // "workspace"
case 2:
if strings.Contains(input, ".") {
return fmt.Sprintf("%s.%s", parts[0], parts[1]) // "workspace.agent"
}
return fmt.Sprintf("%s/%s", parts[0], parts[1]) // "owner/workspace"
case 3:
// If the only separator is a dot, it's the Coder Connect format
if !strings.Contains(input, "/") && !strings.Contains(input, "--") {
return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0]) // "owner/workspace.agent"
}
return fmt.Sprintf("%s/%s.%s", parts[0], parts[1], parts[2]) // "owner/workspace.agent"
default:
return input // Fallback
+2
View File
@@ -107,12 +107,14 @@ func TestSSH(t *testing.T) {
cases := []string{
"myworkspace",
"myworkspace.dev",
"myuser/myworkspace",
"myuser--myworkspace",
"myuser/myworkspace--dev",
"myuser/myworkspace.dev",
"myuser--myworkspace--dev",
"myuser--myworkspace.dev",
"dev.myworkspace.myuser",
}
for _, tc := range cases {
+1 -1
View File
@@ -23,7 +23,7 @@
"workspace_id": "===========[workspace ID]===========",
"workspace_name": "test-workspace",
"workspace_owner_id": "==========[first user ID]===========",
"workspace_owner_username": "testuser",
"workspace_owner_name": "testuser",
"template_version_id": "============[version ID]============",
"template_version_name": "===========[version name]===========",
"build_number": 1,
+2 -3
View File
@@ -17002,6 +17002,7 @@ const docTemplate = `{
"format": "uuid"
},
"owner_name": {
"description": "OwnerName is the username of the owner of the workspace.",
"type": "string"
},
"template_active_version_id": {
@@ -17847,9 +17848,7 @@ const docTemplate = `{
"format": "uuid"
},
"workspace_owner_name": {
"type": "string"
},
"workspace_owner_username": {
"description": "WorkspaceOwnerName is the username of the owner of the workspace.",
"type": "string"
}
}
+2 -3
View File
@@ -15507,6 +15507,7 @@
"format": "uuid"
},
"owner_name": {
"description": "OwnerName is the username of the owner of the workspace.",
"type": "string"
},
"template_active_version_id": {
@@ -16297,9 +16298,7 @@
"format": "uuid"
},
"workspace_owner_name": {
"type": "string"
},
"workspace_owner_username": {
"description": "WorkspaceOwnerName is the username of the owner of the workspace.",
"type": "string"
}
}
+2 -2
View File
@@ -462,7 +462,7 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
if getWorkspaceErr != nil {
return ""
}
return fmt.Sprintf("/@%s/%s", workspace.OwnerUsername, workspace.Name)
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
case database.ResourceTypeWorkspaceApp:
if additionalFields.WorkspaceOwner != "" && additionalFields.WorkspaceName != "" {
@@ -472,7 +472,7 @@ func (api *API) auditLogResourceLink(ctx context.Context, alog database.GetAudit
if getWorkspaceErr != nil {
return ""
}
return fmt.Sprintf("/@%s/%s", workspace.OwnerUsername, workspace.Name)
return fmt.Sprintf("/@%s/%s", workspace.OwnerName, workspace.Name)
case database.ResourceTypeOauth2ProviderApp:
return fmt.Sprintf("/deployment/oauth2-provider/apps/%s", alog.AuditLog.ResourceID)
+1 -1
View File
@@ -860,7 +860,7 @@ func New(options *Options) *API {
next.ServeHTTP(w, r)
})
},
// httpmw.CSRF(options.DeploymentValues.HTTPCookies),
httpmw.CSRF(options.DeploymentValues.HTTPCookies),
)
// This incurs a performance hit from the middleware, but is required to make sure
+90 -7
View File
@@ -15,6 +15,7 @@ import (
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/database/pubsub"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/coder/v2/provisioner/echo"
@@ -211,6 +212,86 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) {
require.Zero(t, setup.api.FileCache.Count())
})
t.Run("RebuildParameters", func(t *testing.T) {
t.Parallel()
dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf")
require.NoError(t, err)
modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules"))
require.NoError(t, err)
setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{
provisionerDaemonVersion: provProto.CurrentVersion.String(),
mainTF: dynamicParametersTerraformSource,
modulesArchive: modulesArchive,
plan: nil,
static: nil,
})
ctx := testutil.Context(t, testutil.WaitMedium)
stream := setup.stream
previews := stream.Chan()
// Should see the output of the module represented
preview := testutil.RequireReceive(ctx, t, previews)
require.Equal(t, -1, preview.ID)
require.Empty(t, preview.Diagnostics)
require.Len(t, preview.Parameters, 1)
require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name)
require.True(t, preview.Parameters[0].Value.Valid)
require.Equal(t, "CL", preview.Parameters[0].Value.Value)
_ = stream.Close(websocket.StatusGoingAway)
wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(request *codersdk.CreateWorkspaceRequest) {
request.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{
Name: preview.Parameters[0].Name,
Value: "GO",
},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
params, err := setup.client.WorkspaceBuildParameters(ctx, wrk.LatestBuild.ID)
require.NoError(t, err)
require.Len(t, params, 1)
require.Equal(t, "jetbrains_ide", params[0].Name)
require.Equal(t, "GO", params[0].Value)
// A helper function to assert params
doTransition := func(t *testing.T, trans codersdk.WorkspaceTransition) {
t.Helper()
fooVal := coderdtest.RandomUsername(t)
bld, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: setup.template.ActiveVersionID,
Transition: trans,
RichParameterValues: []codersdk.WorkspaceBuildParameter{
// No validation, so this should work as is.
// Overwrite the value on each transition
{Name: "foo", Value: fooVal},
},
EnableDynamicParameters: ptr.Ref(true),
})
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID)
latestParams, err := setup.client.WorkspaceBuildParameters(ctx, bld.ID)
require.NoError(t, err)
require.ElementsMatch(t, latestParams, []codersdk.WorkspaceBuildParameter{
{Name: "jetbrains_ide", Value: "GO"},
{Name: "foo", Value: fooVal},
})
}
// Restart the workspace, then delete. Asserting params on all builds.
doTransition(t, codersdk.WorkspaceTransitionStop)
doTransition(t, codersdk.WorkspaceTransitionStart)
doTransition(t, codersdk.WorkspaceTransitionDelete)
})
t.Run("BadOwner", func(t *testing.T) {
t.Parallel()
@@ -266,9 +347,10 @@ type setupDynamicParamsTestParams struct {
}
type dynamicParamsTest struct {
client *codersdk.Client
api *coderd.API
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
client *codersdk.Client
api *coderd.API
stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest]
template codersdk.Template
}
func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest {
@@ -300,7 +382,7 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files)
coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID)
_ = coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID)
ctx := testutil.Context(t, testutil.WaitShort)
stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, version.ID)
@@ -321,9 +403,10 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn
})
return dynamicParamsTest{
client: ownerClient,
stream: stream,
api: api,
client: ownerClient,
api: api,
stream: stream,
template: tpl,
}
}
+1 -2
View File
@@ -1095,8 +1095,7 @@ func (api *API) convertWorkspaceBuild(
CreatedAt: build.CreatedAt,
UpdatedAt: build.UpdatedAt,
WorkspaceOwnerID: workspace.OwnerID,
WorkspaceOwnerName: workspace.OwnerName,
WorkspaceOwnerUsername: workspace.OwnerUsername,
WorkspaceOwnerName: workspace.OwnerUsername,
WorkspaceOwnerAvatarURL: workspace.OwnerAvatarUrl,
WorkspaceID: build.WorkspaceID,
WorkspaceName: workspace.Name,
+1 -2
View File
@@ -78,8 +78,7 @@ func TestWorkspaceBuild(t *testing.T) {
}, testutil.WaitShort, testutil.IntervalFast)
wb, err := client.WorkspaceBuild(testutil.Context(t, testutil.WaitShort), workspace.LatestBuild.ID)
require.NoError(t, err)
require.Equal(t, up.Username, wb.WorkspaceOwnerUsername)
require.Equal(t, up.Name, wb.WorkspaceOwnerName)
require.Equal(t, up.Username, wb.WorkspaceOwnerName)
require.Equal(t, up.AvatarURL, wb.WorkspaceOwnerAvatarURL)
}
+24 -4
View File
@@ -623,6 +623,11 @@ func (b *Builder) getParameters() (names, values []string, err error) {
return nil, nil, BuildError{http.StatusBadRequest, "Unable to build workspace with unsupported parameters", err}
}
lastBuildParameterValues := db2sdk.WorkspaceBuildParameters(lastBuildParameters)
resolver := codersdk.ParameterResolver{
Rich: lastBuildParameterValues,
}
// Dynamic parameters skip all parameter validation.
// Deleting a workspace also should skip parameter validation.
// Pass the user's input as is.
@@ -632,19 +637,34 @@ func (b *Builder) getParameters() (names, values []string, err error) {
// conditional parameter existence, the static frame of reference
// is not sufficient. So assume the user is correct, or pull in the
// dynamic param code to find the actual parameters.
latestValues := make(map[string]string, len(b.richParameterValues))
for _, latest := range b.richParameterValues {
latestValues[latest.Name] = latest.Value
}
// Merge the inputs with values from the previous build.
for _, last := range lastBuildParameterValues {
// TODO: Ideally we use the resolver here and look at parameter
// fields such as 'ephemeral'. This requires loading the terraform
// files. For now, just send the previous inputs as is.
if _, exists := latestValues[last.Name]; exists {
// latestValues take priority, so skip this previous value.
continue
}
names = append(names, last.Name)
values = append(values, last.Value)
}
for _, value := range b.richParameterValues {
names = append(names, value.Name)
values = append(values, value.Value)
}
b.parameterNames = &names
b.parameterValues = &values
return names, values, nil
}
resolver := codersdk.ParameterResolver{
Rich: db2sdk.WorkspaceBuildParameters(lastBuildParameters),
}
for _, templateVersionParameter := range templateVersionParameters {
tvp, err := db2sdk.TemplateVersionParameter(templateVersionParameter)
if err != nil {
+8 -8
View File
@@ -51,14 +51,14 @@ const (
// WorkspaceBuild is an at-point representation of a workspace state.
// BuildNumbers start at 1 and increase by 1 for each subsequent build
type WorkspaceBuild struct {
ID uuid.UUID `json:"id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
WorkspaceName string `json:"workspace_name"`
WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"`
WorkspaceOwnerName string `json:"workspace_owner_name,omitempty"`
WorkspaceOwnerUsername string `json:"workspace_owner_username"`
ID uuid.UUID `json:"id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
WorkspaceID uuid.UUID `json:"workspace_id" format:"uuid"`
WorkspaceName string `json:"workspace_name"`
WorkspaceOwnerID uuid.UUID `json:"workspace_owner_id" format:"uuid"`
// WorkspaceOwnerName is the username of the owner of the workspace.
WorkspaceOwnerName string `json:"workspace_owner_name"`
WorkspaceOwnerAvatarURL string `json:"workspace_owner_avatar_url,omitempty"`
TemplateVersionID uuid.UUID `json:"template_version_id" format:"uuid"`
TemplateVersionName string `json:"template_version_name"`
+5 -5
View File
@@ -26,10 +26,11 @@ const (
// Workspace is a deployment of a template. It references a specific
// version and can be updated.
type Workspace struct {
ID uuid.UUID `json:"id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
ID uuid.UUID `json:"id" format:"uuid"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
OwnerID uuid.UUID `json:"owner_id" format:"uuid"`
// OwnerName is the username of the owner of the workspace.
OwnerName string `json:"owner_name"`
OwnerAvatarURL string `json:"owner_avatar_url"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
@@ -49,7 +50,6 @@ type Workspace struct {
AutostartSchedule *string `json:"autostart_schedule,omitempty"`
TTLMillis *int64 `json:"ttl_ms,omitempty"`
LastUsedAt time.Time `json:"last_used_at" format:"date-time"`
// DeletingAt indicates the time at which the workspace will be permanently deleted.
// A workspace is eligible for deletion if it is dormant (a non-nil dormant_at value)
// and a value has been specified for time_til_dormant_autodelete on its template.
+6 -12
View File
@@ -225,8 +225,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```
@@ -461,8 +460,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild} \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```
@@ -1176,8 +1174,7 @@ curl -X GET http://coder-server:8080/api/v2/workspacebuilds/{workspacebuild}/sta
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```
@@ -1485,8 +1482,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
]
```
@@ -1658,8 +1654,7 @@ Status Code **200**
| `» workspace_name` | string | false | | |
| `» workspace_owner_avatar_url` | string | false | | |
| `» workspace_owner_id` | string(uuid) | false | | |
| `» workspace_owner_name` | string | false | | |
| `» workspace_owner_username` | string | false | | |
| `» workspace_owner_name` | string | false | | Workspace owner name is the username of the owner of the workspace. |
#### Enumerated Values
@@ -1972,8 +1967,7 @@ curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/builds \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```
+29 -33
View File
@@ -8409,8 +8409,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -8456,7 +8455,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `outdated` | boolean | false | | |
| `owner_avatar_url` | string | false | | |
| `owner_id` | string | false | | |
| `owner_name` | string | false | | |
| `owner_name` | string | false | | Owner name is the username of the owner of the workspace. |
| `template_active_version_id` | string | false | | |
| `template_allow_user_cancel_workspace_jobs` | boolean | false | | |
| `template_display_name` | string | false | | |
@@ -9401,39 +9400,37 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|------------------------------|-------------------------------------------------------------------|----------|--------------|-------------|
| `build_number` | integer | false | | |
| `created_at` | string | false | | |
| `daily_cost` | integer | false | | |
| `deadline` | string | false | | |
| `id` | string | false | | |
| `initiator_id` | string | false | | |
| `initiator_name` | string | false | | |
| `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | |
| `matched_provisioners` | [codersdk.MatchedProvisioners](#codersdkmatchedprovisioners) | false | | |
| `max_deadline` | string | false | | |
| `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | |
| `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | |
| `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | |
| `template_version_id` | string | false | | |
| `template_version_name` | string | false | | |
| `template_version_preset_id` | string | false | | |
| `transition` | [codersdk.WorkspaceTransition](#codersdkworkspacetransition) | false | | |
| `updated_at` | string | false | | |
| `workspace_id` | string | false | | |
| `workspace_name` | string | false | | |
| `workspace_owner_avatar_url` | string | false | | |
| `workspace_owner_id` | string | false | | |
| `workspace_owner_name` | string | false | | |
| `workspace_owner_username` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|------------------------------|-------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------|
| `build_number` | integer | false | | |
| `created_at` | string | false | | |
| `daily_cost` | integer | false | | |
| `deadline` | string | false | | |
| `id` | string | false | | |
| `initiator_id` | string | false | | |
| `initiator_name` | string | false | | |
| `job` | [codersdk.ProvisionerJob](#codersdkprovisionerjob) | false | | |
| `matched_provisioners` | [codersdk.MatchedProvisioners](#codersdkmatchedprovisioners) | false | | |
| `max_deadline` | string | false | | |
| `reason` | [codersdk.BuildReason](#codersdkbuildreason) | false | | |
| `resources` | array of [codersdk.WorkspaceResource](#codersdkworkspaceresource) | false | | |
| `status` | [codersdk.WorkspaceStatus](#codersdkworkspacestatus) | false | | |
| `template_version_id` | string | false | | |
| `template_version_name` | string | false | | |
| `template_version_preset_id` | string | false | | |
| `transition` | [codersdk.WorkspaceTransition](#codersdkworkspacetransition) | false | | |
| `updated_at` | string | false | | |
| `workspace_id` | string | false | | |
| `workspace_name` | string | false | | |
| `workspace_owner_avatar_url` | string | false | | |
| `workspace_owner_id` | string | false | | |
| `workspace_owner_name` | string | false | | Workspace owner name is the username of the owner of the workspace. |
#### Enumerated Values
@@ -10112,8 +10109,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
+6 -12
View File
@@ -280,8 +280,7 @@ of the template will be used.
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -565,8 +564,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -876,8 +874,7 @@ of the template will be used.
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -1147,8 +1144,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -1433,8 +1429,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
@@ -1834,8 +1829,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \
"workspace_name": "string",
"workspace_owner_avatar_url": "string",
"workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7",
"workspace_owner_name": "string",
"workspace_owner_username": "string"
"workspace_owner_name": "string"
},
"name": "string",
"next_start_at": "2019-08-24T14:15:22Z",
+1 -1
View File
@@ -1165,7 +1165,7 @@ class ApiMethods {
)
) {
const { job } = await this.getWorkspaceBuildByNumber(
build.workspace_owner_username,
build.workspace_owner_name,
build.workspace_name,
build.build_number,
);
+1 -1
View File
@@ -279,7 +279,7 @@ const updateWorkspaceBuild = async (
queryClient: QueryClient,
) => {
const workspaceKey = workspaceByOwnerAndNameKey(
build.workspace_owner_username,
build.workspace_owner_name,
build.workspace_name,
);
const previousData = queryClient.getQueryData<Workspace>(workspaceKey);
+1 -2
View File
@@ -3622,8 +3622,7 @@ export interface WorkspaceBuild {
readonly workspace_id: string;
readonly workspace_name: string;
readonly workspace_owner_id: string;
readonly workspace_owner_name?: string;
readonly workspace_owner_username: string;
readonly workspace_owner_name: string;
readonly workspace_owner_avatar_url?: string;
readonly template_version_id: string;
readonly template_version_name: string;
@@ -12,27 +12,30 @@ const meta: Meta<typeof FeatureStageBadge> = {
export default meta;
type Story = StoryObj<typeof FeatureStageBadge>;
export const MediumBeta: Story = {
args: {
size: "md",
},
};
export const SmallBeta: Story = {
args: {
size: "sm",
contentType: "beta",
},
};
export const LargeBeta: Story = {
args: {
size: "lg",
},
};
export const MediumExperimental: Story = {
export const MediumBeta: Story = {
args: {
size: "md",
contentType: "experimental",
contentType: "beta",
},
};
export const SmallEarlyAccess: Story = {
args: {
size: "sm",
contentType: "early_access",
},
};
export const MediumEarlyAccess: Story = {
args: {
size: "md",
contentType: "early_access",
},
};
@@ -1,9 +1,12 @@
import type { Interpolation, Theme } from "@emotion/react";
import Link from "@mui/material/Link";
import { visuallyHidden } from "@mui/utils";
import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip";
import { Popover, PopoverTrigger } from "components/deprecated/Popover/Popover";
import { Link } from "components/Link/Link";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { FC, HTMLAttributes, ReactNode } from "react";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
/**
@@ -11,132 +14,73 @@ import { docs } from "utils/docs";
* ensure that we can't accidentally make typos when writing the badge text.
*/
export const featureStageBadgeTypes = {
early_access: "early access",
beta: "beta",
experimental: "experimental",
} as const satisfies Record<string, ReactNode>;
type FeatureStageBadgeProps = Readonly<
Omit<HTMLAttributes<HTMLSpanElement>, "children"> & {
contentType: keyof typeof featureStageBadgeTypes;
labelText?: string;
size?: "sm" | "md" | "lg";
showTooltip?: boolean;
size?: "sm" | "md";
}
>;
const badgeColorClasses = {
early_access: "bg-surface-orange text-content-warning",
beta: "bg-surface-sky text-highlight-sky",
} as const;
const badgeSizeClasses = {
sm: "text-xs font-medium px-2 py-1",
md: "text-base px-2 py-1",
} as const;
export const FeatureStageBadge: FC<FeatureStageBadgeProps> = ({
contentType,
labelText = "",
size = "md",
showTooltip = true, // This is a temporary until the deprecated popover is removed
className,
...delegatedProps
}) => {
const colorClasses = badgeColorClasses[contentType];
const sizeClasses = badgeSizeClasses[size];
return (
<Popover mode="hover">
<PopoverTrigger>
{({ isOpen }) => (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span
css={[
styles.badge,
size === "sm" && styles.badgeSmallText,
size === "lg" && styles.badgeLargeText,
isOpen && styles.badgeHover,
]}
className={cn(
"block max-w-fit cursor-default flex-shrink-0 leading-none whitespace-nowrap border rounded-md transition-colors duration-200 ease-in-out bg-transparent border-solid border-transparent",
sizeClasses,
colorClasses,
className,
)}
{...delegatedProps}
>
<span style={visuallyHidden}> (This is a</span>
<span className="sr-only"> (This is a</span>
<span className="first-letter:uppercase">
{labelText && `${labelText} `}
{featureStageBadgeTypes[contentType]}
</span>
<span style={visuallyHidden}> feature)</span>
<span className="sr-only"> feature)</span>
</span>
)}
</PopoverTrigger>
{showTooltip && (
<HelpTooltipContent
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
>
<p css={styles.tooltipDescription}>
</TooltipTrigger>
<TooltipContent align="start" className="max-w-xs text-sm">
<p className="m-0">
This feature has not yet reached general availability (GA).
</p>
<Link
href={docs("/install/releases/feature-stages")}
target="_blank"
rel="noreferrer"
css={styles.tooltipLink}
className="font-semibold"
>
Learn about feature stages
<span style={visuallyHidden}> (link opens in new tab)</span>
<span className="sr-only"> (link opens in new tab)</span>
</Link>
</HelpTooltipContent>
)}
</Popover>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const styles = {
badge: (theme) => ({
// Base type is based on a span so that the element can be placed inside
// more types of HTML elements without creating invalid markdown, but we
// still want the default display behavior to be div-like
display: "block",
maxWidth: "fit-content",
// Base style assumes that medium badges will be the default
fontSize: "0.75rem",
cursor: "default",
flexShrink: 0,
padding: "4px 8px",
lineHeight: 1,
whiteSpace: "nowrap",
border: `1px solid ${theme.branding.featureStage.border}`,
color: theme.branding.featureStage.text,
backgroundColor: theme.branding.featureStage.background,
borderRadius: "6px",
transition:
"color 0.2s ease-in-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out",
}),
badgeHover: (theme) => ({
color: theme.branding.featureStage.hover.text,
borderColor: theme.branding.featureStage.hover.border,
backgroundColor: theme.branding.featureStage.hover.background,
}),
badgeLargeText: {
fontSize: "1rem",
},
badgeSmallText: {
// Have to beef up font weight so that the letters still maintain the
// same relative thickness as all our other main UI text
fontWeight: 500,
fontSize: "0.625rem",
},
tooltipTitle: (theme) => ({
color: theme.palette.text.primary,
fontWeight: 600,
fontFamily: "inherit",
fontSize: 18,
margin: 0,
lineHeight: 1,
paddingBottom: "8px",
}),
tooltipDescription: {
margin: 0,
lineHeight: 1.4,
paddingBottom: "8px",
},
tooltipLink: {
fontWeight: 600,
lineHeight: 1.2,
},
} as const satisfies Record<string, Interpolation<Theme>>;
@@ -562,11 +562,11 @@ export const PortForwardPopoverView: FC<PortForwardPopoverViewProps> = ({
const classNames = {
paper: (css, theme) => css`
padding: 0;
width: 404px;
color: ${theme.palette.text.secondary};
margin-top: 4px;
`,
padding: 0;
width: 404px;
color: ${theme.palette.text.secondary};
margin-top: 4px;
`,
} satisfies Record<string, ClassName>;
const styles = {
@@ -73,6 +73,9 @@ export const AgentSSHButton: FC<AgentSSHButtonProps> = ({
>
Connect via JetBrains Gateway
</HelpTooltipLink>
<HelpTooltipLink href={docs("/user-guides/desktop")}>
Connect via Coder Desktop
</HelpTooltipLink>
<HelpTooltipLink href={docs("/user-guides/workspace-access#ssh")}>
SSH configuration
</HelpTooltipLink>
@@ -84,6 +84,7 @@ export const DynamicParameter: FC<DynamicParameterProps> = ({
value={value}
onChange={onChange}
disabled={disabled}
isPreset={isPreset}
/>
) : (
<ParameterField
@@ -231,6 +232,7 @@ interface DebouncedParameterFieldProps {
onChange: (value: string) => void;
disabled?: boolean;
id: string;
isPreset?: boolean;
}
const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
@@ -239,6 +241,7 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
onChange,
disabled,
id,
isPreset,
}) => {
const [localValue, setLocalValue] = useState(
value !== undefined ? value : validValue(parameter.value),
@@ -251,19 +254,26 @@ const DebouncedParameterField: FC<DebouncedParameterFieldProps> = ({
// This is necessary in the case of fields being set by preset parameters
useEffect(() => {
if (value !== undefined && value !== prevValueRef.current) {
if (isPreset && value !== undefined && value !== prevValueRef.current) {
setLocalValue(value);
prevValueRef.current = value;
}
}, [value]);
}, [value, isPreset]);
useEffect(() => {
if (prevDebouncedValueRef.current !== undefined) {
// Only call onChangeEvent if debouncedLocalValue is different from the previously committed value
// and it's not the initial undefined state.
if (
prevDebouncedValueRef.current !== undefined &&
prevDebouncedValueRef.current !== debouncedLocalValue
) {
onChangeEvent(debouncedLocalValue);
}
// Update the ref to the current debounced value for the next comparison
prevDebouncedValueRef.current = debouncedLocalValue;
}, [debouncedLocalValue, onChangeEvent]);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeTextarea = useEffectEvent(() => {
@@ -513,7 +523,9 @@ const ParameterField: FC<ParameterFieldProps> = ({
max={parameter.validations[0]?.validation_max ?? 100}
disabled={disabled}
/>
<span className="w-4 font-medium">{parameter.value.value}</span>
<span className="w-4 font-medium">
{Number.isFinite(Number(value)) ? value : "0"}
</span>
</div>
);
case "error":
@@ -3,12 +3,12 @@ import type { FriendlyDiagnostic, PreviewParameter } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { Badge } from "components/Badge/Badge";
import { Button } from "components/Button/Button";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import { Input } from "components/Input/Input";
import { Label } from "components/Label/Label";
import { Link } from "components/Link/Link";
import { Pill } from "components/Pill/Pill";
import {
Select,
SelectContent,
@@ -353,21 +353,39 @@ export const CreateWorkspacePageViewExperimental: FC<
</div>
<div className="flex flex-col gap-6 max-w-screen-md mx-auto">
<header className="flex flex-col items-start gap-3 mt-10">
<div className="flex items-center gap-2">
<Avatar
variant="icon"
size="md"
src={template.icon}
fallback={template.name}
/>
<p className="text-base font-medium m-0">
{template.display_name.length > 0
? template.display_name
: template.name}
</p>
<div className="flex items-center gap-2 justify-between w-full">
<span className="flex items-center gap-2">
<Avatar
variant="icon"
size="md"
src={template.icon}
fallback={template.name}
/>
<p className="text-base font-medium m-0">
{template.display_name.length > 0
? template.display_name
: template.name}
</p>
{template.deprecated && (
<Badge variant="warning" size="sm">
Deprecated
</Badge>
)}
</span>
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
<Undo2 />
Classic workspace creation
</Button>
)}
</div>
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl font-semibold m-0">New workspace</h1>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
@@ -389,19 +407,11 @@ export const CreateWorkspacePageViewExperimental: FC<
</Tooltip>
</TooltipProvider>
</span>
{template.deprecated && <Pill type="warning">Deprecated</Pill>}
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
<Undo2 />
Use the classic workspace creation flow
</Button>
)}
<FeatureStageBadge
contentType={"early_access"}
size="sm"
labelText="Dynamic parameters"
/>
</header>
<form
@@ -555,7 +565,7 @@ export const CreateWorkspacePageViewExperimental: FC<
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<Label className="text-sm">Preset</Label>
<FeatureStageBadge contentType={"beta"} size="md" />
<FeatureStageBadge contentType={"beta"} size="sm" />
</div>
<div className="flex flex-col gap-4">
<div className="max-w-lg">
+1 -1
View File
@@ -53,7 +53,7 @@ export const Section: FC<SectionProps> = ({
{featureStage && (
<FeatureStageBadge
contentType={featureStage}
size="lg"
size="md"
css={{ marginBottom: "5px" }}
/>
)}
@@ -205,7 +205,7 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
fontWeight: 600,
}}
>
{`coder rm ${`${build.workspace_owner_username}/${build.workspace_name}`} --orphan`}
{`coder rm ${`${build.workspace_owner_name}/${build.workspace_name}`} --orphan`}
</code>{" "}
to delete the workspace skipping resource destruction.
</div>
@@ -117,18 +117,18 @@ export const WorkspaceParametersPageView: FC<
return (
<div className="flex flex-col gap-10">
<header className="flex flex-col items-start gap-2">
<span className="flex flex-row justify-between items-center gap-2">
<span className="flex flex-row justify-between w-full items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
{experimentalFormContext && (
<ShadcnButton
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
Try out the new workspace parameters
</ShadcnButton>
)}
</span>
{experimentalFormContext && (
<ShadcnButton
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
Try out the new workspace parameters
</ShadcnButton>
)}
</header>
{submitError && !isApiValidationError(submitError) ? (
@@ -9,6 +9,7 @@ import type {
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button";
import { EmptyState } from "components/EmptyState/EmptyState";
import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge";
import { Link } from "components/Link/Link";
import { Loader } from "components/Loader/Loader";
import {
@@ -26,6 +27,7 @@ import { useMutation, useQuery } from "react-query";
import { useNavigate } from "react-router-dom";
import { docs } from "utils/docs";
import { pageTitle } from "utils/page";
import type { AutofillBuildParameter } from "utils/richParameters";
import {
type WorkspacePermissions,
workspaceChecks,
@@ -39,11 +41,27 @@ const WorkspaceParametersPageExperimental: FC = () => {
const navigate = useNavigate();
const experimentalFormContext = useContext(ExperimentalFormContext);
// autofill the form with the workspace build parameters from the latest build
const {
data: latestBuildParameters,
isLoading: latestBuildParametersLoading,
} = useQuery({
queryKey: ["workspaceBuilds", workspace.latest_build.id, "parameters"],
queryFn: () => API.getWorkspaceBuildParameters(workspace.latest_build.id),
});
const [latestResponse, setLatestResponse] =
useState<DynamicParametersResponse | null>(null);
const wsResponseId = useRef<number>(-1);
const ws = useRef<WebSocket | null>(null);
const [wsError, setWsError] = useState<Error | null>(null);
const initialParamsSentRef = useRef(false);
const autofillParameters: AutofillBuildParameter[] =
latestBuildParameters?.map((p) => ({
...p,
source: "active_build",
})) ?? [];
const sendMessage = useEffectEvent((formValues: Record<string, string>) => {
const request: DynamicParametersRequest = {
@@ -57,11 +75,34 @@ const WorkspaceParametersPageExperimental: FC = () => {
}
});
// On page load, sends initial workspace build parameters to the websocket.
// This ensures the backend has the form's complete initial state,
// vital for rendering dynamic UI elements dependent on initial parameter values.
const sendInitialParameters = useEffectEvent(() => {
if (initialParamsSentRef.current) return;
if (autofillParameters.length === 0) return;
const initialParamsToSend: Record<string, string> = {};
for (const param of autofillParameters) {
if (param.name && param.value) {
initialParamsToSend[param.name] = param.value;
}
}
if (Object.keys(initialParamsToSend).length === 0) return;
sendMessage(initialParamsToSend);
initialParamsSentRef.current = true;
});
const onMessage = useEffectEvent((response: DynamicParametersResponse) => {
if (latestResponse && latestResponse?.id >= response.id) {
return;
}
if (!initialParamsSentRef.current && response.parameters?.length > 0) {
sendInitialParameters();
}
setLatestResponse(response);
});
@@ -149,6 +190,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
const error = wsError || updateParameters.error;
if (
latestBuildParametersLoading ||
!latestResponse ||
(ws.current && ws.current.readyState === WebSocket.CONNECTING)
) {
@@ -162,39 +204,46 @@ const WorkspaceParametersPageExperimental: FC = () => {
</Helmet>
<header className="flex flex-col items-start gap-2">
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp className="size-icon-xs text-content-secondary" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-sm">
Dynamic Parameters enhances Coder's existing parameter system
with real-time validation, conditional parameter behavior, and
richer input types.
<br />
<Link
href={docs(
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
)}
>
View docs
</Link>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="flex flex-row items-center gap-2 justify-between w-full">
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<CircleHelp className="size-icon-xs text-content-secondary" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-sm">
Dynamic Parameters enhances Coder's existing parameter system
with real-time validation, conditional parameter behavior, and
richer input types.
<br />
<Link
href={docs(
"/admin/templates/extending-templates/parameters#enable-dynamic-parameters-early-access",
)}
>
View docs
</Link>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
<Undo2 />
Classic workspace parameters
</Button>
)}
</span>
{experimentalFormContext && (
<Button
size="sm"
variant="outline"
onClick={experimentalFormContext.toggleOptedOut}
>
<Undo2 />
Use the classic workspace parameters
</Button>
)}
<FeatureStageBadge
contentType={"early_access"}
size="sm"
labelText="Dynamic parameters"
/>
</header>
{Boolean(error) && <ErrorAlert error={error} />}
@@ -202,6 +251,7 @@ const WorkspaceParametersPageExperimental: FC = () => {
{sortedParams.length > 0 ? (
<WorkspaceParametersPageViewExperimental
workspace={workspace}
autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={latestResponse.diagnostics}
@@ -16,9 +16,11 @@ import {
} from "modules/workspaces/DynamicParameter/DynamicParameter";
import type { FC } from "react";
import { docs } from "utils/docs";
import type { AutofillBuildParameter } from "utils/richParameters";
type WorkspaceParametersPageViewExperimentalProps = {
workspace: Workspace;
autofillParameters: AutofillBuildParameter[];
parameters: PreviewParameter[];
diagnostics: PreviewParameter["diagnostics"];
canChangeVersions: boolean;
@@ -34,6 +36,7 @@ export const WorkspaceParametersPageViewExperimental: FC<
WorkspaceParametersPageViewExperimentalProps
> = ({
workspace,
autofillParameters,
parameters,
diagnostics,
canChangeVersions,
@@ -42,17 +45,32 @@ export const WorkspaceParametersPageViewExperimental: FC<
sendMessage,
onCancel,
}) => {
const autofillByName = Object.fromEntries(
autofillParameters.map((param) => [param.name, param]),
);
const initialTouched = parameters.reduce(
(touched, parameter) => {
if (autofillByName[parameter.name] !== undefined) {
touched[parameter.name] = true;
}
return touched;
},
{} as Record<string, boolean>,
);
const form = useFormik({
onSubmit,
initialValues: {
rich_parameter_values: getInitialParameterValues(parameters),
rich_parameter_values: getInitialParameterValues(
parameters,
autofillParameters,
),
},
initialTouched,
validationSchema: useValidationSchemaForDynamicParameters(parameters),
enableReinitialize: false,
validateOnChange: true,
validateOnBlur: true,
});
// Group parameters by ephemeral status
const ephemeralParameters = parameters.filter((p) => p.ephemeral);
const standardParameters = parameters.filter((p) => !p.ephemeral);
@@ -16,7 +16,7 @@ import {
type Update,
} from "./BatchUpdateConfirmation";
const workspaces = [
const workspaces: Workspace[] = [
{ ...MockRunningOutdatedWorkspace, id: "1" },
{ ...MockDormantOutdatedWorkspace, id: "2" },
{ ...MockOutdatedWorkspace, id: "3" },
+4 -4
View File
@@ -1289,7 +1289,7 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = {
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
workspace_owner_id: MockUserOwner.id,
workspace_owner_username: MockUserOwner.username,
workspace_owner_name: MockUserOwner.username,
workspace_owner_avatar_url: MockUserOwner.avatar_url,
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
deadline: "2022-05-17T23:39:00.00Z",
@@ -1317,7 +1317,7 @@ const MockWorkspaceBuildAutostart: TypesGen.WorkspaceBuild = {
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
workspace_owner_id: MockUserOwner.id,
workspace_owner_username: MockUserOwner.username,
workspace_owner_name: MockUserOwner.username,
workspace_owner_avatar_url: MockUserOwner.avatar_url,
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
deadline: "2022-05-17T23:39:00.00Z",
@@ -1341,7 +1341,7 @@ const MockWorkspaceBuildAutostop: TypesGen.WorkspaceBuild = {
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
workspace_owner_id: MockUserOwner.id,
workspace_owner_username: MockUserOwner.username,
workspace_owner_name: MockUserOwner.username,
workspace_owner_avatar_url: MockUserOwner.avatar_url,
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
deadline: "2022-05-17T23:39:00.00Z",
@@ -1367,7 +1367,7 @@ export const MockFailedWorkspaceBuild = (
updated_at: "2022-05-17T17:39:01.382927298Z",
workspace_name: "test-workspace",
workspace_owner_id: MockUserOwner.id,
workspace_owner_username: MockUserOwner.username,
workspace_owner_name: MockUserOwner.username,
workspace_owner_avatar_url: MockUserOwner.avatar_url,
workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3",
deadline: "2022-05-17T23:39:00.00Z",
+1
View File
@@ -105,6 +105,7 @@
"vsphere.svg",
"webstorm.svg",
"widgets.svg",
"windows.svg",
"windsurf.svg",
"zed.svg"
]
+21 -20
View File
@@ -1,4 +1,25 @@
<?xml version="1.0"?>
<!-- MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.-->
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<title>Cursor</title>
<defs>
@@ -24,24 +45,4 @@
<path d="m22.35,6l-10.42,6l-10.43,-6l20.85,0z" fill="#FFF" id="svg_5"/>
</g>
</svg>
<!-- MIT License
Copyright (c) 2023 LobeHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.-->

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+29
View File
@@ -0,0 +1,29 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_8)">
<rect width="30" height="30" fill="url(#paint0_linear_1_8)"/>
<rect width="30" height="30" transform="translate(0 34)" fill="url(#paint1_linear_1_8)"/>
<rect width="30" height="30" transform="translate(34)" fill="url(#paint2_linear_1_8)"/>
<rect width="30" height="30" transform="translate(34 34)" fill="url(#paint3_linear_1_8)"/>
</g>
<defs>
<linearGradient id="paint0_linear_1_8" x1="0" y1="0" x2="30" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#76F1FF"/>
<stop offset="1" stop-color="#52D5FF"/>
</linearGradient>
<linearGradient id="paint1_linear_1_8" x1="0" y1="0" x2="30" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#51BFEA"/>
<stop offset="1" stop-color="#25AFFF"/>
</linearGradient>
<linearGradient id="paint2_linear_1_8" x1="0" y1="0" x2="30" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#5BDCFF"/>
<stop offset="1" stop-color="#34BCFF"/>
</linearGradient>
<linearGradient id="paint3_linear_1_8" x1="0" y1="0" x2="30" y2="30" gradientUnits="userSpaceOnUse">
<stop stop-color="#39C1FF"/>
<stop offset="1" stop-color="#0D9CFD"/>
</linearGradient>
<clipPath id="clip0_1_8">
<rect width="64" height="64" rx="4" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB