Merge remote-tracking branch 'origin/main' into jjs/presets

This commit is contained in:
Sas Swart
2025-02-14 08:34:48 +00:00
29 changed files with 331 additions and 252 deletions

View File

@ -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

View File

@ -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>"))

View File

@ -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
View File

@ -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": {

View File

@ -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": {

View File

@ -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
} }

View File

@ -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,

View File

@ -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 {

View File

@ -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,

View File

@ -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) {

View File

@ -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)

View File

@ -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) {

View File

@ -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

View File

@ -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 },
}, },
], ],
}; };

View File

@ -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
}, },
{ {

View File

@ -122,7 +122,7 @@ stopped due to the policy at the start of the user's quiet hours.
![User schedule settings](../../../images/admin/templates/schedule/user-quiet-hours.png) ![User schedule settings](../../../images/admin/templates/schedule/user-quiet-hours.png)
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

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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 />,
}, },
}; };

View File

@ -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>>;

View File

@ -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>>;

View File

@ -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>
); );
}; };

View File

@ -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>

View File

@ -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}
/> />

View File

@ -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 />;
} }

View File

@ -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>

View File

@ -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())
} }

View File

@ -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"},
},
}, },
}, },
}) })