mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
Merge remote-tracking branch 'origin/main' into jjs/presets
This commit is contained in:
@ -83,6 +83,12 @@ func (f *OutputFormatter) Format(ctx context.Context, data any) (string, error)
|
|||||||
return "", xerrors.Errorf("unknown output format %q", f.formatID)
|
return "", xerrors.Errorf("unknown output format %q", f.formatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatID will return the ID of the format selected by `--output`.
|
||||||
|
// If no flag is present, it returns the 'default' formatter.
|
||||||
|
func (f *OutputFormatter) FormatID() string {
|
||||||
|
return f.formatID
|
||||||
|
}
|
||||||
|
|
||||||
type tableFormat struct {
|
type tableFormat struct {
|
||||||
defaultColumns []string
|
defaultColumns []string
|
||||||
allColumns []string
|
allColumns []string
|
||||||
|
@ -112,7 +112,7 @@ func (r *RootCmd) list() *serpent.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(res) == 0 {
|
if len(res) == 0 && formatter.FormatID() != cliui.JSONFormat().ID() {
|
||||||
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n")
|
pretty.Fprintf(inv.Stderr, cliui.DefaultStyles.Prompt, "No workspaces found! Create one:\n")
|
||||||
_, _ = fmt.Fprintln(inv.Stderr)
|
_, _ = fmt.Fprintln(inv.Stderr)
|
||||||
_, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder create <name>"))
|
_, _ = fmt.Fprintln(inv.Stderr, " "+pretty.Sprint(cliui.DefaultStyles.Code, "coder create <name>"))
|
||||||
|
@ -74,4 +74,30 @@ func TestList(t *testing.T) {
|
|||||||
require.NoError(t, json.Unmarshal(out.Bytes(), &workspaces))
|
require.NoError(t, json.Unmarshal(out.Bytes(), &workspaces))
|
||||||
require.Len(t, workspaces, 1)
|
require.Len(t, workspaces, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("NoWorkspacesJSON", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
client := coderdtest.New(t, nil)
|
||||||
|
owner := coderdtest.CreateFirstUser(t, client)
|
||||||
|
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||||
|
|
||||||
|
inv, root := clitest.New(t, "list", "--output=json")
|
||||||
|
clitest.SetupConfig(t, member, root)
|
||||||
|
|
||||||
|
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancelFunc()
|
||||||
|
|
||||||
|
stdout := bytes.NewBuffer(nil)
|
||||||
|
stderr := bytes.NewBuffer(nil)
|
||||||
|
inv.Stdout = stdout
|
||||||
|
inv.Stderr = stderr
|
||||||
|
err := inv.WithContext(ctx).Run()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var workspaces []codersdk.Workspace
|
||||||
|
require.NoError(t, json.Unmarshal(stdout.Bytes(), &workspaces))
|
||||||
|
require.Len(t, workspaces, 0)
|
||||||
|
|
||||||
|
require.Len(t, stderr.Bytes(), 0)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
16
coderd/apidoc/docs.go
generated
16
coderd/apidoc/docs.go
generated
@ -3055,6 +3055,16 @@ const docTemplate = `{
|
|||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"format": "uuid",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Filter results by job IDs",
|
||||||
|
"name": "ids",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"enum": [
|
"enum": [
|
||||||
"pending",
|
"pending",
|
||||||
@ -3075,6 +3085,12 @@ const docTemplate = `{
|
|||||||
"description": "Filter results by status",
|
"description": "Filter results by status",
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})",
|
||||||
|
"name": "tags",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
16
coderd/apidoc/swagger.json
generated
16
coderd/apidoc/swagger.json
generated
@ -2683,6 +2683,16 @@
|
|||||||
"name": "limit",
|
"name": "limit",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"format": "uuid",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "Filter results by job IDs",
|
||||||
|
"name": "ids",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"enum": [
|
"enum": [
|
||||||
"pending",
|
"pending",
|
||||||
@ -2703,6 +2713,12 @@
|
|||||||
"description": "Filter results by status",
|
"description": "Filter results by status",
|
||||||
"name": "status",
|
"name": "status",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"description": "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})",
|
||||||
|
"name": "tags",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
@ -1304,7 +1304,7 @@ func New(options *Options) *API {
|
|||||||
func(next http.Handler) http.Handler {
|
func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDebugInfo) {
|
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDebugInfo) {
|
||||||
httpapi.ResourceNotFound(rw)
|
httpapi.Forbidden(rw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4170,6 +4170,9 @@ func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePosition
|
|||||||
if len(arg.IDs) > 0 && !slices.Contains(arg.IDs, job.ID) {
|
if len(arg.IDs) > 0 && !slices.Contains(arg.IDs, job.ID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if len(arg.Tags) > 0 && !tagsSubset(job.Tags, arg.Tags) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
row := database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow{
|
row := database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow{
|
||||||
ProvisionerJob: rowQP.ProvisionerJob,
|
ProvisionerJob: rowQP.ProvisionerJob,
|
||||||
|
@ -6472,6 +6472,7 @@ WHERE
|
|||||||
($1::uuid IS NULL OR pj.organization_id = $1)
|
($1::uuid IS NULL OR pj.organization_id = $1)
|
||||||
AND (COALESCE(array_length($2::uuid[], 1), 0) = 0 OR pj.id = ANY($2::uuid[]))
|
AND (COALESCE(array_length($2::uuid[], 1), 0) = 0 OR pj.id = ANY($2::uuid[]))
|
||||||
AND (COALESCE(array_length($3::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY($3::provisioner_job_status[]))
|
AND (COALESCE(array_length($3::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY($3::provisioner_job_status[]))
|
||||||
|
AND ($4::tagset = 'null'::tagset OR provisioner_tagset_contains(pj.tags::tagset, $4::tagset))
|
||||||
GROUP BY
|
GROUP BY
|
||||||
pj.id,
|
pj.id,
|
||||||
qp.queue_position,
|
qp.queue_position,
|
||||||
@ -6486,13 +6487,14 @@ GROUP BY
|
|||||||
ORDER BY
|
ORDER BY
|
||||||
pj.created_at DESC
|
pj.created_at DESC
|
||||||
LIMIT
|
LIMIT
|
||||||
$4::int
|
$5::int
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams struct {
|
type GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams struct {
|
||||||
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
|
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
|
||||||
IDs []uuid.UUID `db:"ids" json:"ids"`
|
IDs []uuid.UUID `db:"ids" json:"ids"`
|
||||||
Status []ProvisionerJobStatus `db:"status" json:"status"`
|
Status []ProvisionerJobStatus `db:"status" json:"status"`
|
||||||
|
Tags StringMap `db:"tags" json:"tags"`
|
||||||
Limit sql.NullInt32 `db:"limit" json:"limit"`
|
Limit sql.NullInt32 `db:"limit" json:"limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -6515,6 +6517,7 @@ func (q *sqlQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionA
|
|||||||
arg.OrganizationID,
|
arg.OrganizationID,
|
||||||
pq.Array(arg.IDs),
|
pq.Array(arg.IDs),
|
||||||
pq.Array(arg.Status),
|
pq.Array(arg.Status),
|
||||||
|
arg.Tags,
|
||||||
arg.Limit,
|
arg.Limit,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -158,6 +158,7 @@ WHERE
|
|||||||
(sqlc.narg('organization_id')::uuid IS NULL OR pj.organization_id = @organization_id)
|
(sqlc.narg('organization_id')::uuid IS NULL OR pj.organization_id = @organization_id)
|
||||||
AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pj.id = ANY(@ids::uuid[]))
|
AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pj.id = ANY(@ids::uuid[]))
|
||||||
AND (COALESCE(array_length(@status::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY(@status::provisioner_job_status[]))
|
AND (COALESCE(array_length(@status::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY(@status::provisioner_job_status[]))
|
||||||
|
AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pj.tags::tagset, @tags::tagset))
|
||||||
GROUP BY
|
GROUP BY
|
||||||
pj.id,
|
pj.id,
|
||||||
qp.queue_position,
|
qp.queue_position,
|
||||||
|
@ -72,7 +72,9 @@ func (api *API) provisionerJob(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// @Tags Organizations
|
// @Tags Organizations
|
||||||
// @Param organization path string true "Organization ID" format(uuid)
|
// @Param organization path string true "Organization ID" format(uuid)
|
||||||
// @Param limit query int false "Page limit"
|
// @Param limit query int false "Page limit"
|
||||||
|
// @Param ids query []string false "Filter results by job IDs" format(uuid)
|
||||||
// @Param status query codersdk.ProvisionerJobStatus false "Filter results by status" enums(pending,running,succeeded,canceling,canceled,failed)
|
// @Param status query codersdk.ProvisionerJobStatus false "Filter results by status" enums(pending,running,succeeded,canceling,canceled,failed)
|
||||||
|
// @Param tags query object false "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})"
|
||||||
// @Success 200 {array} codersdk.ProvisionerJob
|
// @Success 200 {array} codersdk.ProvisionerJob
|
||||||
// @Router /organizations/{organization}/provisionerjobs [get]
|
// @Router /organizations/{organization}/provisionerjobs [get]
|
||||||
func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) {
|
||||||
@ -103,6 +105,10 @@ func (api *API) handleAuthAndFetchProvisionerJobs(rw http.ResponseWriter, r *htt
|
|||||||
p := httpapi.NewQueryParamParser()
|
p := httpapi.NewQueryParamParser()
|
||||||
limit := p.PositiveInt32(qp, 50, "limit")
|
limit := p.PositiveInt32(qp, 50, "limit")
|
||||||
status := p.Strings(qp, nil, "status")
|
status := p.Strings(qp, nil, "status")
|
||||||
|
if ids == nil {
|
||||||
|
ids = p.UUIDs(qp, nil, "ids")
|
||||||
|
}
|
||||||
|
tagsRaw := p.String(qp, "", "tags")
|
||||||
p.ErrorExcessParams(qp)
|
p.ErrorExcessParams(qp)
|
||||||
if len(p.Errors) > 0 {
|
if len(p.Errors) > 0 {
|
||||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
@ -112,11 +118,23 @@ func (api *API) handleAuthAndFetchProvisionerJobs(rw http.ResponseWriter, r *htt
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tags := database.StringMap{}
|
||||||
|
if tagsRaw != "" {
|
||||||
|
if err := tags.Scan([]byte(tagsRaw)); err != nil {
|
||||||
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
|
Message: "Invalid tags query parameter",
|
||||||
|
Detail: err.Error(),
|
||||||
|
})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
jobs, err := api.Database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx, database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams{
|
jobs, err := api.Database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisioner(ctx, database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams{
|
||||||
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
|
||||||
Status: slice.StringEnums[database.ProvisionerJobStatus](status),
|
Status: slice.StringEnums[database.ProvisionerJobStatus](status),
|
||||||
Limit: sql.NullInt32{Int32: limit, Valid: limit > 0},
|
Limit: sql.NullInt32{Int32: limit, Valid: limit > 0},
|
||||||
IDs: ids,
|
IDs: ids,
|
||||||
|
Tags: tags,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if httpapi.Is404Error(err) {
|
if httpapi.Is404Error(err) {
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -65,9 +66,10 @@ func TestProvisionerJobs(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Add more jobs than the default limit.
|
// Add more jobs than the default limit.
|
||||||
for range 60 {
|
for i := range 60 {
|
||||||
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
|
||||||
OrganizationID: owner.OrganizationID,
|
OrganizationID: owner.OrganizationID,
|
||||||
|
Tags: database.StringMap{"count": strconv.Itoa(i)},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,6 +134,16 @@ func TestProvisionerJobs(t *testing.T) {
|
|||||||
require.Len(t, jobs, 50)
|
require.Len(t, jobs, 50)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("IDs", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||||
|
jobs, err := templateAdminClient.OrganizationProvisionerJobs(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerJobsOptions{
|
||||||
|
IDs: []uuid.UUID{workspace.LatestBuild.Job.ID, version.Job.ID},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jobs, 2)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Status", func(t *testing.T) {
|
t.Run("Status", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||||
@ -142,6 +154,16 @@ func TestProvisionerJobs(t *testing.T) {
|
|||||||
require.Len(t, jobs, 1)
|
require.Len(t, jobs, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("Tags", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||||
|
jobs, err := templateAdminClient.OrganizationProvisionerJobs(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerJobsOptions{
|
||||||
|
Tags: map[string]string{"count": "1"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, jobs, 1)
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("Limit", func(t *testing.T) {
|
t.Run("Limit", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||||
|
@ -346,7 +346,9 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio
|
|||||||
|
|
||||||
type OrganizationProvisionerJobsOptions struct {
|
type OrganizationProvisionerJobsOptions struct {
|
||||||
Limit int
|
Limit int
|
||||||
|
IDs []uuid.UUID
|
||||||
Status []ProvisionerJobStatus
|
Status []ProvisionerJobStatus
|
||||||
|
Tags map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) OrganizationProvisionerJobs(ctx context.Context, organizationID uuid.UUID, opts *OrganizationProvisionerJobsOptions) ([]ProvisionerJob, error) {
|
func (c *Client) OrganizationProvisionerJobs(ctx context.Context, organizationID uuid.UUID, opts *OrganizationProvisionerJobsOptions) ([]ProvisionerJob, error) {
|
||||||
@ -355,9 +357,19 @@ func (c *Client) OrganizationProvisionerJobs(ctx context.Context, organizationID
|
|||||||
if opts.Limit > 0 {
|
if opts.Limit > 0 {
|
||||||
qp.Add("limit", strconv.Itoa(opts.Limit))
|
qp.Add("limit", strconv.Itoa(opts.Limit))
|
||||||
}
|
}
|
||||||
|
if len(opts.IDs) > 0 {
|
||||||
|
qp.Add("ids", joinSliceStringer(opts.IDs))
|
||||||
|
}
|
||||||
if len(opts.Status) > 0 {
|
if len(opts.Status) > 0 {
|
||||||
qp.Add("status", joinSlice(opts.Status))
|
qp.Add("status", joinSlice(opts.Status))
|
||||||
}
|
}
|
||||||
|
if len(opts.Tags) > 0 {
|
||||||
|
tagsRaw, err := json.Marshal(opts.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerrors.Errorf("marshal tags: %w", err)
|
||||||
|
}
|
||||||
|
qp.Add("tags", string(tagsRaw))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := c.Request(ctx, http.MethodGet,
|
res, err := c.Request(ctx, http.MethodGet,
|
||||||
@ -401,6 +413,14 @@ func joinSlice[T ~string](s []T) string {
|
|||||||
return strings.Join(ss, ",")
|
return strings.Join(ss, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func joinSliceStringer[T fmt.Stringer](s []T) string {
|
||||||
|
var ss []string
|
||||||
|
for _, v := range s {
|
||||||
|
ss = append(ss, v.String())
|
||||||
|
}
|
||||||
|
return strings.Join(ss, ",")
|
||||||
|
}
|
||||||
|
|
||||||
// CreateTemplateVersion processes source-code and optionally associates the version with a template.
|
// CreateTemplateVersion processes source-code and optionally associates the version with a template.
|
||||||
// Executing without a template is useful for validating source-code.
|
// Executing without a template is useful for validating source-code.
|
||||||
func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid.UUID, req CreateTemplateVersionRequest) (TemplateVersion, error) {
|
func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid.UUID, req CreateTemplateVersionRequest) (TemplateVersion, error) {
|
||||||
|
@ -3,43 +3,54 @@
|
|||||||
Notifications are sent by Coder in response to specific internal events, such as
|
Notifications are sent by Coder in response to specific internal events, such as
|
||||||
a workspace being deleted or a user being created.
|
a workspace being deleted or a user being created.
|
||||||
|
|
||||||
|
Available events may differ between versions.
|
||||||
|
For a list of all events, visit your Coder deployment's
|
||||||
|
`https://coder.example.com/deployment/notifications`.
|
||||||
|
|
||||||
## Event Types
|
## Event Types
|
||||||
|
|
||||||
Notifications are sent in response to internal events, to alert the affected
|
Notifications are sent in response to internal events, to alert the affected
|
||||||
user(s) of this event. Currently we support the following list of events:
|
user(s) of the event.
|
||||||
|
|
||||||
|
Coder supports the following list of events:
|
||||||
|
|
||||||
### Workspace Events
|
### Workspace Events
|
||||||
|
|
||||||
_These notifications are sent to the workspace owner._
|
These notifications are sent to the workspace owner:
|
||||||
|
|
||||||
- Workspace Deleted
|
- Workspace created
|
||||||
- Workspace Manual Build Failure
|
- Workspace deleted
|
||||||
- Workspace Automatic Build Failure
|
- Workspace manual build failure
|
||||||
- Workspace Automatically Updated
|
- Workspace automatic build failure
|
||||||
- Workspace Dormant
|
- Workspace manually updated
|
||||||
- Workspace Marked For Deletion
|
- Workspace automatically updated
|
||||||
|
- Workspace marked as dormant
|
||||||
|
- Workspace marked for deletion
|
||||||
|
|
||||||
### User Events
|
### User Events
|
||||||
|
|
||||||
_These notifications are sent to users with **owner** and **user admin** roles._
|
These notifications sent to users with **owner** and **user admin** roles:
|
||||||
|
|
||||||
- User Account Created
|
- User account created
|
||||||
- User Account Deleted
|
- User account deleted
|
||||||
- User Account Suspended
|
- User account suspended
|
||||||
- User Account Activated
|
- User account activated
|
||||||
- _(coming soon) User Password Reset_
|
|
||||||
- _(coming soon) User Email Verification_
|
|
||||||
|
|
||||||
_These notifications are sent to the user themselves._
|
These notifications sent to users themselves:
|
||||||
|
|
||||||
- User Account Suspended
|
- User account suspended
|
||||||
- User Account Activated
|
- User account activated
|
||||||
|
- User password reset (One-time passcode)
|
||||||
|
|
||||||
### Template Events
|
### Template Events
|
||||||
|
|
||||||
_These notifications are sent to users with **template admin** roles._
|
These notifications are sent to users with **template admin** roles:
|
||||||
|
|
||||||
- Template Deleted
|
- Template deleted
|
||||||
|
- Template deprecated
|
||||||
|
- Report: Workspace builds failed for template
|
||||||
|
- This notification is delivered as part of a weekly cron job and summarizes
|
||||||
|
the failed builds for a given template.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
@ -89,11 +89,11 @@ To build the server to receive webhooks and interact with Slack:
|
|||||||
return res.status(400).send("Error: request body is missing");
|
return res.status(400).send("Error: request body is missing");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, body } = req.body;
|
const { title_markdown, body_markdown } = req.body;
|
||||||
if (!title || !body) {
|
if (!title_markdown || !body_markdown) {
|
||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.send('Error: missing fields: "title", or "body"');
|
.send('Error: missing fields: "title_markdown", or "body_markdown"');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = req.body.payload;
|
const payload = req.body.payload;
|
||||||
@ -119,11 +119,11 @@ To build the server to receive webhooks and interact with Slack:
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: "header",
|
type: "header",
|
||||||
text: { type: "plain_text", text: title },
|
text: { type: "mrkdwn", text: title_markdown },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "section",
|
type: "section",
|
||||||
text: { type: "mrkdwn", text: body },
|
text: { type: "mrkdwn", text: body_markdown },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
@ -67,10 +67,10 @@ The process of setting up a Teams workflow consists of three key steps:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": {
|
"title_markdown": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"body": {
|
"body_markdown": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,11 +108,11 @@ The process of setting up a Teams workflow consists of three key steps:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "TextBlock",
|
"type": "TextBlock",
|
||||||
"text": "**@{replace(body('Parse_JSON')?['title'], '"', '\"')}**"
|
"text": "**@{replace(body('Parse_JSON')?['title_markdown'], '"', '\"')}**"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "TextBlock",
|
"type": "TextBlock",
|
||||||
"text": "@{replace(body('Parse_JSON')?['body'], '"', '\"')}",
|
"text": "@{replace(body('Parse_JSON')?['body_markdown'], '"', '\"')}",
|
||||||
"wrap": true
|
"wrap": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -122,7 +122,7 @@ stopped due to the policy at the start of the user's quiet hours.
|
|||||||

|

|
||||||
|
|
||||||
Admins can define the default quiet hours for all users with the
|
Admins can define the default quiet hours for all users with the
|
||||||
`--default-quiet-hours-schedule` flag or `CODER_DEFAULT_QUIET_HOURS_SCHEDULE`
|
[CODER_QUIET_HOURS_DEFAULT_SCHEDULE](../../../reference/cli/server.md#--default-quiet-hours-schedule)
|
||||||
environment variable. The value should be a cron expression such as
|
environment variable. The value should be a cron expression such as
|
||||||
`CRON_TZ=America/Chicago 30 2 * * *` which would set the default quiet hours to
|
`CRON_TZ=America/Chicago 30 2 * * *` which would set the default quiet hours to
|
||||||
2:30 AM in the America/Chicago timezone. The cron schedule can only have a
|
2:30 AM in the America/Chicago timezone. The cron schedule can only have a
|
||||||
|
4
docs/reference/api/organizations.md
generated
4
docs/reference/api/organizations.md
generated
@ -360,10 +360,12 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi
|
|||||||
### Parameters
|
### Parameters
|
||||||
|
|
||||||
| Name | In | Type | Required | Description |
|
| Name | In | Type | Required | Description |
|
||||||
|----------------|-------|--------------|----------|--------------------------|
|
|----------------|-------|--------------|----------|------------------------------------------------------------------------------------|
|
||||||
| `organization` | path | string(uuid) | true | Organization ID |
|
| `organization` | path | string(uuid) | true | Organization ID |
|
||||||
| `limit` | query | integer | false | Page limit |
|
| `limit` | query | integer | false | Page limit |
|
||||||
|
| `ids` | query | array(uuid) | false | Filter results by job IDs |
|
||||||
| `status` | query | string | false | Filter results by status |
|
| `status` | query | string | false | Filter results by status |
|
||||||
|
| `tags` | query | object | false | Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'}) |
|
||||||
|
|
||||||
#### Enumerated Values
|
#### Enumerated Values
|
||||||
|
|
||||||
|
@ -280,6 +280,7 @@ resource "kubernetes_deployment" "main" {
|
|||||||
security_context {
|
security_context {
|
||||||
run_as_user = 1000
|
run_as_user = 1000
|
||||||
fs_group = 1000
|
fs_group = 1000
|
||||||
|
run_as_non_root = true
|
||||||
}
|
}
|
||||||
|
|
||||||
container {
|
container {
|
||||||
|
2
site/src/api/typesGenerated.ts
generated
2
site/src/api/typesGenerated.ts
generated
@ -1438,7 +1438,9 @@ export interface OrganizationMemberWithUserData extends OrganizationMember {
|
|||||||
// From codersdk/organizations.go
|
// From codersdk/organizations.go
|
||||||
export interface OrganizationProvisionerJobsOptions {
|
export interface OrganizationProvisionerJobsOptions {
|
||||||
readonly Limit: number;
|
readonly Limit: number;
|
||||||
|
readonly IDs: readonly string[];
|
||||||
readonly Status: readonly ProvisionerJobStatus[];
|
readonly Status: readonly ProvisionerJobStatus[];
|
||||||
|
readonly Tags: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// From codersdk/idpsync.go
|
// From codersdk/idpsync.go
|
||||||
|
@ -96,6 +96,7 @@ export const DestructiveSmall: Story = {
|
|||||||
export const IconButtonDefault: Story = {
|
export const IconButtonDefault: Story = {
|
||||||
args: {
|
args: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
|
size: "icon",
|
||||||
children: <PlusIcon />,
|
children: <PlusIcon />,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -103,6 +104,7 @@ export const IconButtonDefault: Story = {
|
|||||||
export const IconButtonOutline: Story = {
|
export const IconButtonOutline: Story = {
|
||||||
args: {
|
args: {
|
||||||
variant: "outline",
|
variant: "outline",
|
||||||
|
size: "icon",
|
||||||
children: <PlusIcon />,
|
children: <PlusIcon />,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -110,6 +112,7 @@ export const IconButtonOutline: Story = {
|
|||||||
export const IconButtonSubtle: Story = {
|
export const IconButtonSubtle: Story = {
|
||||||
args: {
|
args: {
|
||||||
variant: "subtle",
|
variant: "subtle",
|
||||||
|
size: "icon",
|
||||||
children: <PlusIcon />,
|
children: <PlusIcon />,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,14 +1,6 @@
|
|||||||
import type { Interpolation, Theme } from "@emotion/react";
|
|
||||||
import PersonAdd from "@mui/icons-material/PersonAdd";
|
import PersonAdd from "@mui/icons-material/PersonAdd";
|
||||||
import LoadingButton from "@mui/lab/LoadingButton";
|
import LoadingButton from "@mui/lab/LoadingButton";
|
||||||
import Table from "@mui/material/Table";
|
|
||||||
import TableBody from "@mui/material/TableBody";
|
|
||||||
import TableCell from "@mui/material/TableCell";
|
|
||||||
import TableContainer from "@mui/material/TableContainer";
|
|
||||||
import TableHead from "@mui/material/TableHead";
|
|
||||||
import TableRow from "@mui/material/TableRow";
|
|
||||||
import { getErrorMessage } from "api/errors";
|
import { getErrorMessage } from "api/errors";
|
||||||
import type { GroupsByUserId } from "api/queries/groups";
|
|
||||||
import type {
|
import type {
|
||||||
Group,
|
Group,
|
||||||
OrganizationMemberWithUserData,
|
OrganizationMemberWithUserData,
|
||||||
@ -28,6 +20,13 @@ import {
|
|||||||
} from "components/MoreMenu/MoreMenu";
|
} from "components/MoreMenu/MoreMenu";
|
||||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { Stack } from "components/Stack/Stack";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "components/Table/Table";
|
||||||
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
|
||||||
import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell";
|
import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
@ -80,10 +79,8 @@ export const OrganizationMembersPageView: FC<
|
|||||||
onSubmit={addMember}
|
onSubmit={addMember}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TableContainer>
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell width="33%">User</TableCell>
|
<TableCell width="33%">User</TableCell>
|
||||||
<TableCell width="33%">
|
<TableCell width="33%">
|
||||||
@ -100,10 +97,10 @@ export const OrganizationMembersPageView: FC<
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell width="1%" />
|
<TableCell width="1%" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{members?.map((member) => (
|
{members?.map((member) => (
|
||||||
<TableRow key={member.user_id}>
|
<TableRow key={member.user_id} className="align-baseline">
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<AvatarData
|
<AvatarData
|
||||||
avatar={
|
avatar={
|
||||||
@ -156,7 +153,6 @@ export const OrganizationMembersPageView: FC<
|
|||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableContainer>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -190,7 +186,7 @@ const AddOrganizationMember: FC<AddOrganizationMemberProps> = ({
|
|||||||
>
|
>
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
<Stack direction="row" alignItems="center" spacing={1}>
|
||||||
<UserAutocomplete
|
<UserAutocomplete
|
||||||
css={styles.autoComplete}
|
className="w-[300px]"
|
||||||
value={selectedUser}
|
value={selectedUser}
|
||||||
onChange={(newValue) => {
|
onChange={(newValue) => {
|
||||||
setSelectedUser(newValue);
|
setSelectedUser(newValue);
|
||||||
@ -210,17 +206,3 @@ const AddOrganizationMember: FC<AddOrganizationMemberProps> = ({
|
|||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = {
|
|
||||||
role: (theme) => ({
|
|
||||||
backgroundColor: theme.roles.notice.background,
|
|
||||||
borderColor: theme.roles.notice.outline,
|
|
||||||
}),
|
|
||||||
globalRole: (theme) => ({
|
|
||||||
backgroundColor: theme.roles.inactive.background,
|
|
||||||
borderColor: theme.roles.inactive.outline,
|
|
||||||
}),
|
|
||||||
autoComplete: {
|
|
||||||
width: 300,
|
|
||||||
},
|
|
||||||
} satisfies Record<string, Interpolation<Theme>>;
|
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import type { Interpolation, Theme } from "@emotion/react";
|
|
||||||
import UserIcon from "@mui/icons-material/PersonOutline";
|
import UserIcon from "@mui/icons-material/PersonOutline";
|
||||||
import Checkbox from "@mui/material/Checkbox";
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
import IconButton from "@mui/material/IconButton";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import type { SlimRole } from "api/typesGenerated";
|
import type { SlimRole } from "api/typesGenerated";
|
||||||
|
import { Button } from "components/Button/Button";
|
||||||
import {
|
import {
|
||||||
HelpTooltip,
|
HelpTooltip,
|
||||||
HelpTooltipContent,
|
HelpTooltipContent,
|
||||||
@ -12,13 +11,11 @@ import {
|
|||||||
HelpTooltipTrigger,
|
HelpTooltipTrigger,
|
||||||
} from "components/HelpTooltip/HelpTooltip";
|
} from "components/HelpTooltip/HelpTooltip";
|
||||||
import { EditSquare } from "components/Icons/EditSquare";
|
import { EditSquare } from "components/Icons/EditSquare";
|
||||||
import { Stack } from "components/Stack/Stack";
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "components/deprecated/Popover/Popover";
|
} from "components/deprecated/Popover/Popover";
|
||||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
|
||||||
const roleDescriptions: Record<string, string> = {
|
const roleDescriptions: Record<string, string> = {
|
||||||
@ -47,23 +44,23 @@ const Option: FC<OptionProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<label htmlFor={name} css={styles.option}>
|
<label htmlFor={name} className="cursor-pointer">
|
||||||
<Stack direction="row" alignItems="flex-start">
|
<div className="flex items-start gap-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={name}
|
id={name}
|
||||||
size="small"
|
size="small"
|
||||||
css={styles.checkbox}
|
className="p-0 relative top-px"
|
||||||
value={value}
|
value={value}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
onChange(e.currentTarget.value);
|
onChange(e.currentTarget.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack spacing={0}>
|
<div className="flex flex-col">
|
||||||
<strong>{name}</strong>
|
<strong>{name}</strong>
|
||||||
<span css={styles.optionDescription}>{description}</span>
|
<span className="text-xs text-content-secondary">{description}</span>
|
||||||
</Stack>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -85,8 +82,6 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
|||||||
userLoginType,
|
userLoginType,
|
||||||
oidcRoleSync,
|
oidcRoleSync,
|
||||||
}) => {
|
}) => {
|
||||||
const paper = useClassName(classNames.paper, []);
|
|
||||||
|
|
||||||
const handleChange = (roleName: string) => {
|
const handleChange = (roleName: string) => {
|
||||||
if (selectedRoleNames.has(roleName)) {
|
if (selectedRoleNames.has(roleName)) {
|
||||||
const serialized = [...selectedRoleNames];
|
const serialized = [...selectedRoleNames];
|
||||||
@ -118,23 +113,24 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
|||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
<Tooltip title="Edit user roles">
|
<Tooltip title="Edit user roles">
|
||||||
<IconButton
|
<Button
|
||||||
|
variant="subtle"
|
||||||
aria-label="Edit user roles"
|
aria-label="Edit user roles"
|
||||||
size="small"
|
size="icon"
|
||||||
css={styles.editButton}
|
className="text-content-secondary hover:text-content-primary"
|
||||||
>
|
>
|
||||||
<EditSquare />
|
<EditSquare />
|
||||||
</IconButton>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent classes={{ paper }} disablePortal={false}>
|
<PopoverContent className="w-80" disablePortal={false}>
|
||||||
<fieldset
|
<fieldset
|
||||||
css={styles.fieldset}
|
className="border-0 m-0 p-0 disabled:opacity-50"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
title="Available roles"
|
title="Available roles"
|
||||||
>
|
>
|
||||||
<Stack css={styles.options} spacing={3}>
|
<div className="flex flex-col gap-4 p-6">
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<Option
|
<Option
|
||||||
key={role.name}
|
key={role.name}
|
||||||
@ -145,88 +141,20 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
|||||||
description={roleDescriptions[role.name] ?? ""}
|
description={roleDescriptions[role.name] ?? ""}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div css={styles.footer}>
|
<div className="p-6 border-t-1 border-solid border-border text-sm">
|
||||||
<Stack direction="row" alignItems="flex-start">
|
<div className="flex gap-4">
|
||||||
<UserIcon css={styles.userIcon} />
|
<UserIcon className="size-icon-sm" />
|
||||||
<Stack spacing={0}>
|
<div className="flex flex-col">
|
||||||
<strong>Member</strong>
|
<strong>Member</strong>
|
||||||
<span css={styles.optionDescription}>
|
<span className="text-xs text-content-secondary">
|
||||||
{roleDescriptions.member}
|
{roleDescriptions.member}
|
||||||
</span>
|
</span>
|
||||||
</Stack>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const classNames = {
|
|
||||||
paper: (css, theme) => css`
|
|
||||||
width: 360px;
|
|
||||||
margin-top: 8px;
|
|
||||||
background: ${theme.palette.background.paper};
|
|
||||||
`,
|
|
||||||
} satisfies Record<string, ClassName>;
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
editButton: (theme) => ({
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
|
|
||||||
"& .MuiSvgIcon-root": {
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
position: "relative",
|
|
||||||
top: -2, // Align the pencil square
|
|
||||||
},
|
|
||||||
|
|
||||||
"&:hover": {
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
fieldset: {
|
|
||||||
border: 0,
|
|
||||||
margin: 0,
|
|
||||||
padding: 0,
|
|
||||||
|
|
||||||
"&:disabled": {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
padding: 24,
|
|
||||||
},
|
|
||||||
option: {
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
checkbox: {
|
|
||||||
padding: 0,
|
|
||||||
position: "relative",
|
|
||||||
top: 1, // Alignment
|
|
||||||
|
|
||||||
"& svg": {
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
optionDescription: (theme) => ({
|
|
||||||
fontSize: 13,
|
|
||||||
color: theme.palette.text.secondary,
|
|
||||||
lineHeight: "160%",
|
|
||||||
}),
|
|
||||||
footer: (theme) => ({
|
|
||||||
padding: 24,
|
|
||||||
backgroundColor: theme.palette.background.paper,
|
|
||||||
borderTop: `1px solid ${theme.palette.divider}`,
|
|
||||||
fontSize: 14,
|
|
||||||
}),
|
|
||||||
userIcon: (theme) => ({
|
|
||||||
width: 20, // Same as the checkbox
|
|
||||||
height: 20,
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
}),
|
|
||||||
} satisfies Record<string, Interpolation<Theme>>;
|
|
||||||
|
@ -14,11 +14,10 @@
|
|||||||
* users like that, though, know that it will be painful
|
* users like that, though, know that it will be painful
|
||||||
*/
|
*/
|
||||||
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
|
||||||
import Stack from "@mui/material/Stack";
|
|
||||||
import TableCell from "@mui/material/TableCell";
|
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import type { LoginType, SlimRole } from "api/typesGenerated";
|
import type { LoginType, SlimRole } from "api/typesGenerated";
|
||||||
import { Pill } from "components/Pill/Pill";
|
import { Pill } from "components/Pill/Pill";
|
||||||
|
import { TableCell } from "components/Table/Table";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@ -59,7 +58,7 @@ export const UserRoleCell: FC<UserRoleCellProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Stack direction="row" spacing={1}>
|
<div className="flex flex-row gap-1 items-center">
|
||||||
{canEditUsers && (
|
{canEditUsers && (
|
||||||
<EditRolesButton
|
<EditRolesButton
|
||||||
roles={sortRolesByAccessLevel(allAvailableRoles ?? [])}
|
roles={sortRolesByAccessLevel(allAvailableRoles ?? [])}
|
||||||
@ -97,7 +96,7 @@ export const UserRoleCell: FC<UserRoleCellProps> = ({
|
|||||||
</Pill>
|
</Pill>
|
||||||
|
|
||||||
{extraRoles.length > 0 && <OverflowRolePill roles={extraRoles} />}
|
{extraRoles.length > 0 && <OverflowRolePill roles={extraRoles} />}
|
||||||
</Stack>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -2,11 +2,10 @@ import { useTheme } from "@emotion/react";
|
|||||||
import GroupIcon from "@mui/icons-material/Group";
|
import GroupIcon from "@mui/icons-material/Group";
|
||||||
import List from "@mui/material/List";
|
import List from "@mui/material/List";
|
||||||
import ListItem from "@mui/material/ListItem";
|
import ListItem from "@mui/material/ListItem";
|
||||||
import TableCell from "@mui/material/TableCell";
|
|
||||||
import type { Group } from "api/typesGenerated";
|
import type { Group } from "api/typesGenerated";
|
||||||
import { Avatar } from "components/Avatar/Avatar";
|
import { Avatar } from "components/Avatar/Avatar";
|
||||||
import { OverflowY } from "components/OverflowY/OverflowY";
|
import { OverflowY } from "components/OverflowY/OverflowY";
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { TableCell } from "components/Table/Table";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@ -40,12 +39,9 @@ export const UserGroupsCell: FC<GroupsCellProps> = ({ userGroups }) => {
|
|||||||
color: "inherit",
|
color: "inherit",
|
||||||
lineHeight: "1",
|
lineHeight: "1",
|
||||||
}}
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Stack
|
<div className="flex flex-row gap-2 items-center">
|
||||||
spacing={0}
|
|
||||||
direction="row"
|
|
||||||
css={{ columnGap: 8, alignItems: "center" }}
|
|
||||||
>
|
|
||||||
<GroupIcon
|
<GroupIcon
|
||||||
css={{
|
css={{
|
||||||
width: "1rem",
|
width: "1rem",
|
||||||
@ -57,7 +53,7 @@ export const UserGroupsCell: FC<GroupsCellProps> = ({ userGroups }) => {
|
|||||||
<span>
|
<span>
|
||||||
{userGroups.length} Group{userGroups.length !== 1 && "s"}
|
{userGroups.length} Group{userGroups.length !== 1 && "s"}
|
||||||
</span>
|
</span>
|
||||||
</Stack>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
@ -49,6 +49,7 @@ export const WorkspaceBuildPage: FC = () => {
|
|||||||
<WorkspaceBuildPageView
|
<WorkspaceBuildPageView
|
||||||
logs={logs}
|
logs={logs}
|
||||||
build={build}
|
build={build}
|
||||||
|
buildError={wsBuildQuery.error}
|
||||||
builds={buildsQuery.data}
|
builds={buildsQuery.data}
|
||||||
activeBuildNumber={buildNumber}
|
activeBuildNumber={buildNumber}
|
||||||
/>
|
/>
|
||||||
|
@ -5,7 +5,9 @@ import type {
|
|||||||
WorkspaceBuild,
|
WorkspaceBuild,
|
||||||
} from "api/typesGenerated";
|
} from "api/typesGenerated";
|
||||||
import { Alert } from "components/Alert/Alert";
|
import { Alert } from "components/Alert/Alert";
|
||||||
|
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||||
import { Loader } from "components/Loader/Loader";
|
import { Loader } from "components/Loader/Loader";
|
||||||
|
import { Margins } from "components/Margins/Margins";
|
||||||
import {
|
import {
|
||||||
FullWidthPageHeader,
|
FullWidthPageHeader,
|
||||||
PageHeaderSubtitle,
|
PageHeaderSubtitle,
|
||||||
@ -48,6 +50,7 @@ const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
|
|||||||
export interface WorkspaceBuildPageViewProps {
|
export interface WorkspaceBuildPageViewProps {
|
||||||
logs: ProvisionerJobLog[] | undefined;
|
logs: ProvisionerJobLog[] | undefined;
|
||||||
build: WorkspaceBuild | undefined;
|
build: WorkspaceBuild | undefined;
|
||||||
|
buildError?: unknown;
|
||||||
builds: WorkspaceBuild[] | undefined;
|
builds: WorkspaceBuild[] | undefined;
|
||||||
activeBuildNumber: number;
|
activeBuildNumber: number;
|
||||||
}
|
}
|
||||||
@ -55,6 +58,7 @@ export interface WorkspaceBuildPageViewProps {
|
|||||||
export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
||||||
logs,
|
logs,
|
||||||
build,
|
build,
|
||||||
|
buildError,
|
||||||
builds,
|
builds,
|
||||||
activeBuildNumber,
|
activeBuildNumber,
|
||||||
}) => {
|
}) => {
|
||||||
@ -64,6 +68,17 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
|
|||||||
defaultValue: "build",
|
defaultValue: "build",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (buildError) {
|
||||||
|
return (
|
||||||
|
<Margins>
|
||||||
|
<ErrorAlert
|
||||||
|
error={buildError}
|
||||||
|
css={{ marginTop: 16, marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
</Margins>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!build) {
|
if (!build) {
|
||||||
return <Loader />;
|
return <Loader />;
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import ScheduleIcon from "@mui/icons-material/Schedule";
|
|||||||
import { visuallyHidden } from "@mui/utils";
|
import { visuallyHidden } from "@mui/utils";
|
||||||
import type { Workspace } from "api/typesGenerated";
|
import type { Workspace } from "api/typesGenerated";
|
||||||
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||||
|
import { ExternalImage } from "components/ExternalImage/ExternalImage";
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { Stack } from "components/Stack/Stack";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
@ -247,7 +248,11 @@ const Resources: FC<StageProps> = ({ workspaces }) => {
|
|||||||
>
|
>
|
||||||
{Object.entries(resources).map(([type, summary]) => (
|
{Object.entries(resources).map(([type, summary]) => (
|
||||||
<Stack key={type} direction="row" alignItems="center" spacing={1}>
|
<Stack key={type} direction="row" alignItems="center" spacing={1}>
|
||||||
<img alt="" src={summary.icon} css={styles.summaryIcon} />
|
<ExternalImage
|
||||||
|
src={summary.icon}
|
||||||
|
width={styles.summaryIcon.width}
|
||||||
|
height={styles.summaryIcon.height}
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
{summary.count} <code>{type}</code>
|
{summary.count} <code>{type}</code>
|
||||||
</span>
|
</span>
|
||||||
|
@ -230,7 +230,7 @@ func (t *Tunnel) start(req *StartRequest) error {
|
|||||||
if apiToken == "" {
|
if apiToken == "" {
|
||||||
return xerrors.New("missing api token")
|
return xerrors.New("missing api token")
|
||||||
}
|
}
|
||||||
var header http.Header
|
header := make(http.Header)
|
||||||
for _, h := range req.GetHeaders() {
|
for _, h := range req.GetHeaders() {
|
||||||
header.Add(h.GetName(), h.GetValue())
|
header.Add(h.GetName(), h.GetValue())
|
||||||
}
|
}
|
||||||
|
@ -100,6 +100,9 @@ func TestTunnel_StartStop(t *testing.T) {
|
|||||||
TunnelFileDescriptor: 2,
|
TunnelFileDescriptor: 2,
|
||||||
CoderUrl: "https://coder.example.com",
|
CoderUrl: "https://coder.example.com",
|
||||||
ApiToken: "fakeToken",
|
ApiToken: "fakeToken",
|
||||||
|
Headers: []*StartRequest_Header{
|
||||||
|
{Name: "X-Test-Header", Value: "test"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
Reference in New Issue
Block a user