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)
}
// 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 {
defaultColumns []string
allColumns []string

View File

@ -112,7 +112,7 @@ func (r *RootCmd) list() *serpent.Command {
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")
_, _ = fmt.Fprintln(inv.Stderr)
_, _ = 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.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",
"in": "query"
},
{
"type": "array",
"format": "uuid",
"items": {
"type": "string"
},
"description": "Filter results by job IDs",
"name": "ids",
"in": "query"
},
{
"enum": [
"pending",
@ -3075,6 +3085,12 @@ const docTemplate = `{
"description": "Filter results by status",
"name": "status",
"in": "query"
},
{
"type": "object",
"description": "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})",
"name": "tags",
"in": "query"
}
],
"responses": {

View File

@ -2683,6 +2683,16 @@
"name": "limit",
"in": "query"
},
{
"type": "array",
"format": "uuid",
"items": {
"type": "string"
},
"description": "Filter results by job IDs",
"name": "ids",
"in": "query"
},
{
"enum": [
"pending",
@ -2703,6 +2713,12 @@
"description": "Filter results by status",
"name": "status",
"in": "query"
},
{
"type": "object",
"description": "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})",
"name": "tags",
"in": "query"
}
],
"responses": {

View File

@ -1304,7 +1304,7 @@ func New(options *Options) *API {
func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDebugInfo) {
httpapi.ResourceNotFound(rw)
httpapi.Forbidden(rw)
return
}

View File

@ -4170,6 +4170,9 @@ func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePosition
if len(arg.IDs) > 0 && !slices.Contains(arg.IDs, job.ID) {
continue
}
if len(arg.Tags) > 0 && !tagsSubset(job.Tags, arg.Tags) {
continue
}
row := database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow{
ProvisionerJob: rowQP.ProvisionerJob,

View File

@ -6472,6 +6472,7 @@ WHERE
($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($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
pj.id,
qp.queue_position,
@ -6486,13 +6487,14 @@ GROUP BY
ORDER BY
pj.created_at DESC
LIMIT
$4::int
$5::int
`
type GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams struct {
OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
IDs []uuid.UUID `db:"ids" json:"ids"`
Status []ProvisionerJobStatus `db:"status" json:"status"`
Tags StringMap `db:"tags" json:"tags"`
Limit sql.NullInt32 `db:"limit" json:"limit"`
}
@ -6515,6 +6517,7 @@ func (q *sqlQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionA
arg.OrganizationID,
pq.Array(arg.IDs),
pq.Array(arg.Status),
arg.Tags,
arg.Limit,
)
if err != nil {

View File

@ -158,6 +158,7 @@ WHERE
(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(@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
pj.id,
qp.queue_position,

View File

@ -72,7 +72,9 @@ func (api *API) provisionerJob(rw http.ResponseWriter, r *http.Request) {
// @Tags Organizations
// @Param organization path string true "Organization ID" format(uuid)
// @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 tags query object false "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})"
// @Success 200 {array} codersdk.ProvisionerJob
// @Router /organizations/{organization}/provisionerjobs [get]
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()
limit := p.PositiveInt32(qp, 50, "limit")
status := p.Strings(qp, nil, "status")
if ids == nil {
ids = p.UUIDs(qp, nil, "ids")
}
tagsRaw := p.String(qp, "", "tags")
p.ErrorExcessParams(qp)
if len(p.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@ -112,11 +118,23 @@ func (api *API) handleAuthAndFetchProvisionerJobs(rw http.ResponseWriter, r *htt
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{
OrganizationID: uuid.NullUUID{UUID: org.ID, Valid: true},
Status: slice.StringEnums[database.ProvisionerJobStatus](status),
Limit: sql.NullInt32{Int32: limit, Valid: limit > 0},
IDs: ids,
Tags: tags,
})
if err != nil {
if httpapi.Is404Error(err) {

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"strconv"
"testing"
"time"
@ -65,9 +66,10 @@ func TestProvisionerJobs(t *testing.T) {
})
// Add more jobs than the default limit.
for range 60 {
for i := range 60 {
dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
OrganizationID: owner.OrganizationID,
Tags: database.StringMap{"count": strconv.Itoa(i)},
})
}
@ -132,6 +134,16 @@ func TestProvisionerJobs(t *testing.T) {
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.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
@ -142,6 +154,16 @@ func TestProvisionerJobs(t *testing.T) {
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.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)

View File

@ -346,7 +346,9 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio
type OrganizationProvisionerJobsOptions struct {
Limit int
IDs []uuid.UUID
Status []ProvisionerJobStatus
Tags map[string]string
}
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 {
qp.Add("limit", strconv.Itoa(opts.Limit))
}
if len(opts.IDs) > 0 {
qp.Add("ids", joinSliceStringer(opts.IDs))
}
if len(opts.Status) > 0 {
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,
@ -401,6 +413,14 @@ func joinSlice[T ~string](s []T) string {
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.
// Executing without a template is useful for validating source-code.
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
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
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
_These notifications are sent to the workspace owner._
These notifications are sent to the workspace owner:
- Workspace Deleted
- Workspace Manual Build Failure
- Workspace Automatic Build Failure
- Workspace Automatically Updated
- Workspace Dormant
- Workspace Marked For Deletion
- Workspace created
- Workspace deleted
- Workspace manual build failure
- Workspace automatic build failure
- Workspace manually updated
- Workspace automatically updated
- Workspace marked as dormant
- Workspace marked for deletion
### 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 Deleted
- User Account Suspended
- User Account Activated
- _(coming soon) User Password Reset_
- _(coming soon) User Email Verification_
- User account created
- User account deleted
- User account suspended
- User account activated
_These notifications are sent to the user themselves._
These notifications sent to users themselves:
- User Account Suspended
- User Account Activated
- User account suspended
- User account activated
- User password reset (One-time passcode)
### 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

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");
}
const { title, body } = req.body;
if (!title || !body) {
const { title_markdown, body_markdown } = req.body;
if (!title_markdown || !body_markdown) {
return res
.status(400)
.send('Error: missing fields: "title", or "body"');
.send('Error: missing fields: "title_markdown", or "body_markdown"');
}
const payload = req.body.payload;
@ -119,11 +119,11 @@ To build the server to receive webhooks and interact with Slack:
blocks: [
{
type: "header",
text: { type: "plain_text", text: title },
text: { type: "mrkdwn", text: title_markdown },
},
{
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"
},
"body": {
"body_markdown": {
"type": "string"
}
}
@ -108,11 +108,11 @@ The process of setting up a Teams workflow consists of three key steps:
},
{
"type": "TextBlock",
"text": "**@{replace(body('Parse_JSON')?['title'], '"', '\"')}**"
"text": "**@{replace(body('Parse_JSON')?['title_markdown'], '"', '\"')}**"
},
{
"type": "TextBlock",
"text": "@{replace(body('Parse_JSON')?['body'], '"', '\"')}",
"text": "@{replace(body('Parse_JSON')?['body_markdown'], '"', '\"')}",
"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)
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
`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

View File

@ -360,10 +360,12 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi
### Parameters
| Name | In | Type | Required | Description |
|----------------|-------|--------------|----------|--------------------------|
|----------------|-------|--------------|----------|------------------------------------------------------------------------------------|
| `organization` | path | string(uuid) | true | Organization ID |
| `limit` | query | integer | false | Page limit |
| `ids` | query | array(uuid) | false | Filter results by job IDs |
| `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

View File

@ -280,6 +280,7 @@ resource "kubernetes_deployment" "main" {
security_context {
run_as_user = 1000
fs_group = 1000
run_as_non_root = true
}
container {

View File

@ -1438,7 +1438,9 @@ export interface OrganizationMemberWithUserData extends OrganizationMember {
// From codersdk/organizations.go
export interface OrganizationProvisionerJobsOptions {
readonly Limit: number;
readonly IDs: readonly string[];
readonly Status: readonly ProvisionerJobStatus[];
readonly Tags: Record<string, string>;
}
// From codersdk/idpsync.go

View File

@ -96,6 +96,7 @@ export const DestructiveSmall: Story = {
export const IconButtonDefault: Story = {
args: {
variant: "default",
size: "icon",
children: <PlusIcon />,
},
};
@ -103,6 +104,7 @@ export const IconButtonDefault: Story = {
export const IconButtonOutline: Story = {
args: {
variant: "outline",
size: "icon",
children: <PlusIcon />,
},
};
@ -110,6 +112,7 @@ export const IconButtonOutline: Story = {
export const IconButtonSubtle: Story = {
args: {
variant: "subtle",
size: "icon",
children: <PlusIcon />,
},
};

View File

@ -1,14 +1,6 @@
import type { Interpolation, Theme } from "@emotion/react";
import PersonAdd from "@mui/icons-material/PersonAdd";
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 type { GroupsByUserId } from "api/queries/groups";
import type {
Group,
OrganizationMemberWithUserData,
@ -28,6 +20,13 @@ import {
} from "components/MoreMenu/MoreMenu";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Stack } from "components/Stack/Stack";
import {
Table,
TableBody,
TableCell,
TableHeader,
TableRow,
} from "components/Table/Table";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { UserGroupsCell } from "pages/UsersPage/UsersTable/UserGroupsCell";
import { type FC, useState } from "react";
@ -80,10 +79,8 @@ export const OrganizationMembersPageView: FC<
onSubmit={addMember}
/>
)}
<TableContainer>
<Table>
<TableHead>
<TableHeader>
<TableRow>
<TableCell width="33%">User</TableCell>
<TableCell width="33%">
@ -100,10 +97,10 @@ export const OrganizationMembersPageView: FC<
</TableCell>
<TableCell width="1%" />
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{members?.map((member) => (
<TableRow key={member.user_id}>
<TableRow key={member.user_id} className="align-baseline">
<TableCell>
<AvatarData
avatar={
@ -156,7 +153,6 @@ export const OrganizationMembersPageView: FC<
))}
</TableBody>
</Table>
</TableContainer>
</Stack>
</div>
);
@ -190,7 +186,7 @@ const AddOrganizationMember: FC<AddOrganizationMemberProps> = ({
>
<Stack direction="row" alignItems="center" spacing={1}>
<UserAutocomplete
css={styles.autoComplete}
className="w-[300px]"
value={selectedUser}
onChange={(newValue) => {
setSelectedUser(newValue);
@ -210,17 +206,3 @@ const AddOrganizationMember: FC<AddOrganizationMemberProps> = ({
</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 Checkbox from "@mui/material/Checkbox";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import type { SlimRole } from "api/typesGenerated";
import { Button } from "components/Button/Button";
import {
HelpTooltip,
HelpTooltipContent,
@ -12,13 +11,11 @@ import {
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import { EditSquare } from "components/Icons/EditSquare";
import { Stack } from "components/Stack/Stack";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { type ClassName, useClassName } from "hooks/useClassName";
import type { FC } from "react";
const roleDescriptions: Record<string, string> = {
@ -47,23 +44,23 @@ const Option: FC<OptionProps> = ({
onChange,
}) => {
return (
<label htmlFor={name} css={styles.option}>
<Stack direction="row" alignItems="flex-start">
<label htmlFor={name} className="cursor-pointer">
<div className="flex items-start gap-4">
<Checkbox
id={name}
size="small"
css={styles.checkbox}
className="p-0 relative top-px"
value={value}
checked={isChecked}
onChange={(e) => {
onChange(e.currentTarget.value);
}}
/>
<Stack spacing={0}>
<div className="flex flex-col">
<strong>{name}</strong>
<span css={styles.optionDescription}>{description}</span>
</Stack>
</Stack>
<span className="text-xs text-content-secondary">{description}</span>
</div>
</div>
</label>
);
};
@ -85,8 +82,6 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
userLoginType,
oidcRoleSync,
}) => {
const paper = useClassName(classNames.paper, []);
const handleChange = (roleName: string) => {
if (selectedRoleNames.has(roleName)) {
const serialized = [...selectedRoleNames];
@ -118,23 +113,24 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
<Popover>
<PopoverTrigger>
<Tooltip title="Edit user roles">
<IconButton
<Button
variant="subtle"
aria-label="Edit user roles"
size="small"
css={styles.editButton}
size="icon"
className="text-content-secondary hover:text-content-primary"
>
<EditSquare />
</IconButton>
</Button>
</Tooltip>
</PopoverTrigger>
<PopoverContent classes={{ paper }} disablePortal={false}>
<PopoverContent className="w-80" disablePortal={false}>
<fieldset
css={styles.fieldset}
className="border-0 m-0 p-0 disabled:opacity-50"
disabled={isLoading}
title="Available roles"
>
<Stack css={styles.options} spacing={3}>
<div className="flex flex-col gap-4 p-6">
{roles.map((role) => (
<Option
key={role.name}
@ -145,88 +141,20 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
description={roleDescriptions[role.name] ?? ""}
/>
))}
</Stack>
</div>
</fieldset>
<div css={styles.footer}>
<Stack direction="row" alignItems="flex-start">
<UserIcon css={styles.userIcon} />
<Stack spacing={0}>
<div className="p-6 border-t-1 border-solid border-border text-sm">
<div className="flex gap-4">
<UserIcon className="size-icon-sm" />
<div className="flex flex-col">
<strong>Member</strong>
<span css={styles.optionDescription}>
<span className="text-xs text-content-secondary">
{roleDescriptions.member}
</span>
</Stack>
</Stack>
</div>
</div>
</div>
</PopoverContent>
</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
*/
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 type { LoginType, SlimRole } from "api/typesGenerated";
import { Pill } from "components/Pill/Pill";
import { TableCell } from "components/Table/Table";
import {
Popover,
PopoverContent,
@ -59,7 +58,7 @@ export const UserRoleCell: FC<UserRoleCellProps> = ({
return (
<TableCell>
<Stack direction="row" spacing={1}>
<div className="flex flex-row gap-1 items-center">
{canEditUsers && (
<EditRolesButton
roles={sortRolesByAccessLevel(allAvailableRoles ?? [])}
@ -97,7 +96,7 @@ export const UserRoleCell: FC<UserRoleCellProps> = ({
</Pill>
{extraRoles.length > 0 && <OverflowRolePill roles={extraRoles} />}
</Stack>
</div>
</TableCell>
);
};

View File

@ -2,11 +2,10 @@ import { useTheme } from "@emotion/react";
import GroupIcon from "@mui/icons-material/Group";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import TableCell from "@mui/material/TableCell";
import type { Group } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { OverflowY } from "components/OverflowY/OverflowY";
import { Stack } from "components/Stack/Stack";
import { TableCell } from "components/Table/Table";
import {
Popover,
PopoverContent,
@ -40,12 +39,9 @@ export const UserGroupsCell: FC<GroupsCellProps> = ({ userGroups }) => {
color: "inherit",
lineHeight: "1",
}}
type="button"
>
<Stack
spacing={0}
direction="row"
css={{ columnGap: 8, alignItems: "center" }}
>
<div className="flex flex-row gap-2 items-center">
<GroupIcon
css={{
width: "1rem",
@ -57,7 +53,7 @@ export const UserGroupsCell: FC<GroupsCellProps> = ({ userGroups }) => {
<span>
{userGroups.length} Group{userGroups.length !== 1 && "s"}
</span>
</Stack>
</div>
</button>
</PopoverTrigger>

View File

@ -49,6 +49,7 @@ export const WorkspaceBuildPage: FC = () => {
<WorkspaceBuildPageView
logs={logs}
build={build}
buildError={wsBuildQuery.error}
builds={buildsQuery.data}
activeBuildNumber={buildNumber}
/>

View File

@ -5,7 +5,9 @@ import type {
WorkspaceBuild,
} from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import {
FullWidthPageHeader,
PageHeaderSubtitle,
@ -48,6 +50,7 @@ const sortLogsByCreatedAt = (logs: ProvisionerJobLog[]) => {
export interface WorkspaceBuildPageViewProps {
logs: ProvisionerJobLog[] | undefined;
build: WorkspaceBuild | undefined;
buildError?: unknown;
builds: WorkspaceBuild[] | undefined;
activeBuildNumber: number;
}
@ -55,6 +58,7 @@ export interface WorkspaceBuildPageViewProps {
export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
logs,
build,
buildError,
builds,
activeBuildNumber,
}) => {
@ -64,6 +68,17 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
defaultValue: "build",
});
if (buildError) {
return (
<Margins>
<ErrorAlert
error={buildError}
css={{ marginTop: 16, marginBottom: 16 }}
/>
</Margins>
);
}
if (!build) {
return <Loader />;
}

View File

@ -4,6 +4,7 @@ import ScheduleIcon from "@mui/icons-material/Schedule";
import { visuallyHidden } from "@mui/utils";
import type { Workspace } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Stack } from "components/Stack/Stack";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
@ -247,7 +248,11 @@ const Resources: FC<StageProps> = ({ workspaces }) => {
>
{Object.entries(resources).map(([type, summary]) => (
<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>
{summary.count} <code>{type}</code>
</span>

View File

@ -230,7 +230,7 @@ func (t *Tunnel) start(req *StartRequest) error {
if apiToken == "" {
return xerrors.New("missing api token")
}
var header http.Header
header := make(http.Header)
for _, h := range req.GetHeaders() {
header.Add(h.GetName(), h.GetValue())
}

View File

@ -100,6 +100,9 @@ func TestTunnel_StartStop(t *testing.T) {
TunnelFileDescriptor: 2,
CoderUrl: "https://coder.example.com",
ApiToken: "fakeToken",
Headers: []*StartRequest_Header{
{Name: "X-Test-Header", Value: "test"},
},
},
},
})