Merge branch 'main' of github.com:/coder/coder into dk/configurable-cardinality

This commit is contained in:
Danny Kopping 2024-03-12 12:25:21 +02:00
commit b1ef0d643b
No known key found for this signature in database
GPG Key ID: A1B5D94381738C65
26 changed files with 557 additions and 294 deletions

1
coderd/apidoc/docs.go generated
View File

@ -12579,6 +12579,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"schedule": {
"description": "Schedule is expected to be of the form ` + "`" + `CRON_TZ=\u003cIANA Timezone\u003e \u003cmin\u003e \u003chour\u003e * * \u003cdow\u003e` + "`" + `\nExample: ` + "`" + `CRON_TZ=US/Central 30 9 * * 1-5` + "`" + ` represents 0930 in the timezone US/Central\non weekdays (Mon-Fri). ` + "`" + `CRON_TZ` + "`" + ` defaults to UTC if not present.",
"type": "string"
}
}

View File

@ -11402,6 +11402,7 @@
"type": "object",
"properties": {
"schedule": {
"description": "Schedule is expected to be of the form `CRON_TZ=\u003cIANA Timezone\u003e \u003cmin\u003e \u003chour\u003e * * \u003cdow\u003e`\nExample: `CRON_TZ=US/Central 30 9 * * 1-5` represents 0930 in the timezone US/Central\non weekdays (Mon-Fri). `CRON_TZ` defaults to UTC if not present.",
"type": "string"
}
}

View File

@ -8266,6 +8266,19 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
}
}
if len(arg.WorkspaceIds) > 0 {
match := false
for _, id := range arg.WorkspaceIds {
if workspace.ID == id {
match = true
break
}
}
if !match {
continue
}
}
// If the filter exists, ensure the object is authorized.
if prepared != nil && prepared.Authorize(ctx, workspace.RBACObject()) != nil {
continue

View File

@ -221,6 +221,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
arg.OwnerUsername,
arg.TemplateName,
pq.Array(arg.TemplateIDs),
pq.Array(arg.WorkspaceIds),
arg.Name,
arg.HasAgent,
arg.AgentInactiveDisconnectTimeoutSeconds,

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"io"
"net"
"sync"
"time"
@ -164,16 +165,36 @@ func (q *msgQueue) dropped() {
q.cond.Broadcast()
}
// pqListener is an interface that represents a *pq.Listener for testing
type pqListener interface {
io.Closer
Listen(string) error
Unlisten(string) error
NotifyChan() <-chan *pq.Notification
}
type pqListenerShim struct {
*pq.Listener
}
func (l pqListenerShim) NotifyChan() <-chan *pq.Notification {
return l.Notify
}
// PGPubsub is a pubsub implementation using PostgreSQL.
type PGPubsub struct {
ctx context.Context
cancel context.CancelFunc
logger slog.Logger
listenDone chan struct{}
pgListener *pq.Listener
db *sql.DB
mut sync.Mutex
queues map[string]map[uuid.UUID]*msgQueue
logger slog.Logger
listenDone chan struct{}
pgListener pqListener
db *sql.DB
qMu sync.Mutex
queues map[string]map[uuid.UUID]*msgQueue
// making the close state its own mutex domain simplifies closing logic so
// that we don't have to hold the qMu --- which could block processing
// notifications while the pqListener is closing.
closeMu sync.Mutex
closedListener bool
closeListenerErr error
@ -192,16 +213,14 @@ const BufferSize = 2048
// Subscribe calls the listener when an event matching the name is received.
func (p *PGPubsub) Subscribe(event string, listener Listener) (cancel func(), err error) {
return p.subscribeQueue(event, newMsgQueue(p.ctx, listener, nil))
return p.subscribeQueue(event, newMsgQueue(context.Background(), listener, nil))
}
func (p *PGPubsub) SubscribeWithErr(event string, listener ListenerWithErr) (cancel func(), err error) {
return p.subscribeQueue(event, newMsgQueue(p.ctx, nil, listener))
return p.subscribeQueue(event, newMsgQueue(context.Background(), nil, listener))
}
func (p *PGPubsub) subscribeQueue(event string, newQ *msgQueue) (cancel func(), err error) {
p.mut.Lock()
defer p.mut.Unlock()
defer func() {
if err != nil {
// if we hit an error, we need to close the queue so we don't
@ -213,9 +232,13 @@ func (p *PGPubsub) subscribeQueue(event string, newQ *msgQueue) (cancel func(),
}
}()
// The pgListener waits for the response to `LISTEN` on a mainloop that also dispatches
// notifies. We need to avoid holding the mutex while this happens, since holding the mutex
// blocks reading notifications and can deadlock the pgListener.
// c.f. https://github.com/coder/coder/issues/11950
err = p.pgListener.Listen(event)
if err == nil {
p.logger.Debug(p.ctx, "started listening to event channel", slog.F("event", event))
p.logger.Debug(context.Background(), "started listening to event channel", slog.F("event", event))
}
if errors.Is(err, pq.ErrChannelAlreadyOpen) {
// It's ok if it's already open!
@ -224,6 +247,8 @@ func (p *PGPubsub) subscribeQueue(event string, newQ *msgQueue) (cancel func(),
if err != nil {
return nil, xerrors.Errorf("listen: %w", err)
}
p.qMu.Lock()
defer p.qMu.Unlock()
var eventQs map[uuid.UUID]*msgQueue
var ok bool
@ -234,30 +259,36 @@ func (p *PGPubsub) subscribeQueue(event string, newQ *msgQueue) (cancel func(),
id := uuid.New()
eventQs[id] = newQ
return func() {
p.mut.Lock()
defer p.mut.Unlock()
p.qMu.Lock()
listeners := p.queues[event]
q := listeners[id]
q.close()
delete(listeners, id)
if len(listeners) == 0 {
delete(p.queues, event)
}
p.qMu.Unlock()
// as above, we must not hold the lock while calling into pgListener
if len(listeners) == 0 {
uErr := p.pgListener.Unlisten(event)
p.closeMu.Lock()
defer p.closeMu.Unlock()
if uErr != nil && !p.closedListener {
p.logger.Warn(p.ctx, "failed to unlisten", slog.Error(uErr), slog.F("event", event))
p.logger.Warn(context.Background(), "failed to unlisten", slog.Error(uErr), slog.F("event", event))
} else {
p.logger.Debug(p.ctx, "stopped listening to event channel", slog.F("event", event))
p.logger.Debug(context.Background(), "stopped listening to event channel", slog.F("event", event))
}
}
}, nil
}
func (p *PGPubsub) Publish(event string, message []byte) error {
p.logger.Debug(p.ctx, "publish", slog.F("event", event), slog.F("message_len", len(message)))
p.logger.Debug(context.Background(), "publish", slog.F("event", event), slog.F("message_len", len(message)))
// This is safe because we are calling pq.QuoteLiteral. pg_notify doesn't
// support the first parameter being a prepared statement.
//nolint:gosec
_, err := p.db.ExecContext(p.ctx, `select pg_notify(`+pq.QuoteLiteral(event)+`, $1)`, message)
_, err := p.db.ExecContext(context.Background(), `select pg_notify(`+pq.QuoteLiteral(event)+`, $1)`, message)
if err != nil {
p.publishesTotal.WithLabelValues("false").Inc()
return xerrors.Errorf("exec pg_notify: %w", err)
@ -269,53 +300,38 @@ func (p *PGPubsub) Publish(event string, message []byte) error {
// Close closes the pubsub instance.
func (p *PGPubsub) Close() error {
p.logger.Info(p.ctx, "pubsub is closing")
p.cancel()
p.logger.Info(context.Background(), "pubsub is closing")
err := p.closeListener()
<-p.listenDone
p.logger.Debug(p.ctx, "pubsub closed")
p.logger.Debug(context.Background(), "pubsub closed")
return err
}
// closeListener closes the pgListener, unless it has already been closed.
func (p *PGPubsub) closeListener() error {
p.mut.Lock()
defer p.mut.Unlock()
p.closeMu.Lock()
defer p.closeMu.Unlock()
if p.closedListener {
return p.closeListenerErr
}
p.closeListenerErr = p.pgListener.Close()
p.closedListener = true
p.closeListenerErr = p.pgListener.Close()
return p.closeListenerErr
}
// listen begins receiving messages on the pq listener.
func (p *PGPubsub) listen() {
defer func() {
p.logger.Info(p.ctx, "pubsub listen stopped receiving notify")
cErr := p.closeListener()
if cErr != nil {
p.logger.Error(p.ctx, "failed to close listener")
}
p.logger.Info(context.Background(), "pubsub listen stopped receiving notify")
close(p.listenDone)
}()
var (
notif *pq.Notification
ok bool
)
for {
select {
case <-p.ctx.Done():
return
case notif, ok = <-p.pgListener.Notify:
if !ok {
return
}
}
notify := p.pgListener.NotifyChan()
for notif := range notify {
// A nil notification can be dispatched on reconnect.
if notif == nil {
p.logger.Debug(p.ctx, "notifying subscribers of a reconnection")
p.logger.Debug(context.Background(), "notifying subscribers of a reconnection")
p.recordReconnect()
continue
}
@ -331,8 +347,8 @@ func (p *PGPubsub) listenReceive(notif *pq.Notification) {
p.messagesTotal.WithLabelValues(sizeLabel).Inc()
p.receivedBytesTotal.Add(float64(len(notif.Extra)))
p.mut.Lock()
defer p.mut.Unlock()
p.qMu.Lock()
defer p.qMu.Unlock()
queues, ok := p.queues[notif.Channel]
if !ok {
return
@ -344,8 +360,8 @@ func (p *PGPubsub) listenReceive(notif *pq.Notification) {
}
func (p *PGPubsub) recordReconnect() {
p.mut.Lock()
defer p.mut.Unlock()
p.qMu.Lock()
defer p.qMu.Unlock()
for _, listeners := range p.queues {
for _, q := range listeners {
q.dropped()
@ -409,30 +425,32 @@ func (p *PGPubsub) startListener(ctx context.Context, connectURL string) error {
d: net.Dialer{},
}
)
p.pgListener = pq.NewDialListener(dialer, connectURL, time.Second, time.Minute, func(t pq.ListenerEventType, err error) {
switch t {
case pq.ListenerEventConnected:
p.logger.Info(ctx, "pubsub connected to postgres")
p.connected.Set(1.0)
case pq.ListenerEventDisconnected:
p.logger.Error(ctx, "pubsub disconnected from postgres", slog.Error(err))
p.connected.Set(0)
case pq.ListenerEventReconnected:
p.logger.Info(ctx, "pubsub reconnected to postgres")
p.connected.Set(1)
case pq.ListenerEventConnectionAttemptFailed:
p.logger.Error(ctx, "pubsub failed to connect to postgres", slog.Error(err))
}
// This callback gets events whenever the connection state changes.
// Don't send if the errChannel has already been closed.
select {
case <-errCh:
return
default:
errCh <- err
close(errCh)
}
})
p.pgListener = pqListenerShim{
Listener: pq.NewDialListener(dialer, connectURL, time.Second, time.Minute, func(t pq.ListenerEventType, err error) {
switch t {
case pq.ListenerEventConnected:
p.logger.Info(ctx, "pubsub connected to postgres")
p.connected.Set(1.0)
case pq.ListenerEventDisconnected:
p.logger.Error(ctx, "pubsub disconnected from postgres", slog.Error(err))
p.connected.Set(0)
case pq.ListenerEventReconnected:
p.logger.Info(ctx, "pubsub reconnected to postgres")
p.connected.Set(1)
case pq.ListenerEventConnectionAttemptFailed:
p.logger.Error(ctx, "pubsub failed to connect to postgres", slog.Error(err))
}
// This callback gets events whenever the connection state changes.
// Don't send if the errChannel has already been closed.
select {
case <-errCh:
return
default:
errCh <- err
close(errCh)
}
}),
}
select {
case err := <-errCh:
if err != nil {
@ -501,24 +519,31 @@ func (p *PGPubsub) Collect(metrics chan<- prometheus.Metric) {
p.connected.Collect(metrics)
// implicit metrics
p.mut.Lock()
p.qMu.Lock()
events := len(p.queues)
subs := 0
for _, subscriberMap := range p.queues {
subs += len(subscriberMap)
}
p.mut.Unlock()
p.qMu.Unlock()
metrics <- prometheus.MustNewConstMetric(currentSubscribersDesc, prometheus.GaugeValue, float64(subs))
metrics <- prometheus.MustNewConstMetric(currentEventsDesc, prometheus.GaugeValue, float64(events))
}
// New creates a new Pubsub implementation using a PostgreSQL connection.
func New(startCtx context.Context, logger slog.Logger, database *sql.DB, connectURL string) (*PGPubsub, error) {
// Start a new context that will be canceled when the pubsub is closed.
ctx, cancel := context.WithCancel(context.Background())
p := &PGPubsub{
ctx: ctx,
cancel: cancel,
p := newWithoutListener(logger, database)
if err := p.startListener(startCtx, connectURL); err != nil {
return nil, err
}
go p.listen()
logger.Info(startCtx, "pubsub has started")
return p, nil
}
// newWithoutListener creates a new PGPubsub without creating the pqListener.
func newWithoutListener(logger slog.Logger, database *sql.DB) *PGPubsub {
return &PGPubsub{
logger: logger,
listenDone: make(chan struct{}),
db: database,
@ -567,10 +592,4 @@ func New(startCtx context.Context, logger slog.Logger, database *sql.DB, connect
Help: "Whether we are connected (1) or not connected (0) to postgres",
}),
}
if err := p.startListener(startCtx, connectURL); err != nil {
return nil, err
}
go p.listen()
logger.Info(ctx, "pubsub has started")
return p, nil
}

View File

@ -3,10 +3,15 @@ package pubsub
import (
"context"
"fmt"
"sync"
"testing"
"github.com/lib/pq"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/testutil"
)
@ -138,3 +143,115 @@ func Test_msgQueue_Full(t *testing.T) {
// for the error, so we read 2 less than we sent.
require.Equal(t, BufferSize, n)
}
func TestPubSub_DoesntBlockNotify(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
uut := newWithoutListener(logger, nil)
fListener := newFakePqListener()
uut.pgListener = fListener
go uut.listen()
cancels := make(chan func())
go func() {
subCancel, err := uut.Subscribe("bagels", func(ctx context.Context, message []byte) {
t.Logf("got message: %s", string(message))
})
assert.NoError(t, err)
cancels <- subCancel
}()
subCancel := testutil.RequireRecvCtx(ctx, t, cancels)
cancelDone := make(chan struct{})
go func() {
defer close(cancelDone)
subCancel()
}()
testutil.RequireRecvCtx(ctx, t, cancelDone)
closeErrs := make(chan error)
go func() {
closeErrs <- uut.Close()
}()
err := testutil.RequireRecvCtx(ctx, t, closeErrs)
require.NoError(t, err)
}
const (
numNotifications = 5
testMessage = "birds of a feather"
)
// fakePqListener is a fake version of pq.Listener. This test code tests for regressions of
// https://github.com/coder/coder/issues/11950 where pq.Listener deadlocked because we blocked the
// PGPubsub.listen() goroutine while calling other pq.Listener functions. So, all function calls
// into the fakePqListener will send 5 notifications before returning to ensure the listen()
// goroutine is unblocked.
type fakePqListener struct {
mu sync.Mutex
channels map[string]struct{}
notify chan *pq.Notification
}
func (f *fakePqListener) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
ch := f.getTestChanLocked()
for i := 0; i < numNotifications; i++ {
f.notify <- &pq.Notification{Channel: ch, Extra: testMessage}
}
// note that the realPqListener must only be closed once, so go ahead and
// close the notify unprotected here. If it panics, we have a bug.
close(f.notify)
return nil
}
func (f *fakePqListener) Listen(s string) error {
f.mu.Lock()
defer f.mu.Unlock()
ch := f.getTestChanLocked()
for i := 0; i < numNotifications; i++ {
f.notify <- &pq.Notification{Channel: ch, Extra: testMessage}
}
if _, ok := f.channels[s]; ok {
return pq.ErrChannelAlreadyOpen
}
f.channels[s] = struct{}{}
return nil
}
func (f *fakePqListener) Unlisten(s string) error {
f.mu.Lock()
defer f.mu.Unlock()
ch := f.getTestChanLocked()
for i := 0; i < numNotifications; i++ {
f.notify <- &pq.Notification{Channel: ch, Extra: testMessage}
}
if _, ok := f.channels[s]; ok {
delete(f.channels, s)
return nil
}
return pq.ErrChannelNotOpen
}
func (f *fakePqListener) NotifyChan() <-chan *pq.Notification {
return f.notify
}
// getTestChanLocked returns the name of a channel we are currently listening for, if there is one.
// Otherwise, it just returns "test". We prefer to send test notifications for channels that appear
// in the tests, but if there are none, just return anything.
func (f *fakePqListener) getTestChanLocked() string {
for c := range f.channels {
return c
}
return "test"
}
func newFakePqListener() *fakePqListener {
return &fakePqListener{
channels: make(map[string]struct{}),
notify: make(chan *pq.Notification),
}
}

View File

@ -109,60 +109,6 @@ func TestPubsub(t *testing.T) {
message := <-messageChannel
assert.Equal(t, string(message), data)
})
t.Run("ClosePropagatesContextCancellationToSubscription", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
connectionURL, closePg, err := postgres.Open()
require.NoError(t, err)
defer closePg()
db, err := sql.Open("postgres", connectionURL)
require.NoError(t, err)
defer db.Close()
pubsub, err := pubsub.New(ctx, logger, db, connectionURL)
require.NoError(t, err)
defer pubsub.Close()
event := "test"
done := make(chan struct{})
called := make(chan struct{})
unsub, err := pubsub.Subscribe(event, func(subCtx context.Context, _ []byte) {
defer close(done)
select {
case <-subCtx.Done():
assert.Fail(t, "context should not be canceled")
default:
}
close(called)
select {
case <-subCtx.Done():
case <-ctx.Done():
assert.Fail(t, "timeout waiting for sub context to be canceled")
}
})
require.NoError(t, err)
defer unsub()
go func() {
err := pubsub.Publish(event, nil)
assert.NoError(t, err)
}()
select {
case <-called:
case <-ctx.Done():
require.Fail(t, "timeout waiting for handler to be called")
}
err = pubsub.Close()
require.NoError(t, err)
select {
case <-done:
case <-ctx.Done():
require.Fail(t, "timeout waiting for handler to finish")
}
})
}
func TestPubsub_ordering(t *testing.T) {

View File

@ -11989,16 +11989,22 @@ WHERE
workspaces.template_id = ANY($6)
ELSE true
END
-- Filter by workspace_ids
AND CASE
WHEN array_length($7 :: uuid[], 1) > 0 THEN
workspaces.id = ANY($7)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN $7 :: text != '' THEN
workspaces.name ILIKE '%' || $7 || '%'
WHEN $8 :: text != '' THEN
workspaces.name ILIKE '%' || $8 || '%'
ELSE true
END
-- Filter by agent status
-- has-agent: is only applicable for workspaces in "start" transition. Stopped and deleted workspaces don't have agents.
AND CASE
WHEN $8 :: text != '' THEN
WHEN $9 :: text != '' THEN
(
SELECT COUNT(*)
FROM
@ -12010,7 +12016,7 @@ WHERE
WHERE
workspace_resources.job_id = latest_build.provisioner_job_id AND
latest_build.transition = 'start'::workspace_transition AND
$8 = (
$9 = (
CASE
WHEN workspace_agents.first_connected_at IS NULL THEN
CASE
@ -12021,7 +12027,7 @@ WHERE
END
WHEN workspace_agents.disconnected_at > workspace_agents.last_connected_at THEN
'disconnected'
WHEN NOW() - workspace_agents.last_connected_at > INTERVAL '1 second' * $9 :: bigint THEN
WHEN NOW() - workspace_agents.last_connected_at > INTERVAL '1 second' * $10 :: bigint THEN
'disconnected'
WHEN workspace_agents.last_connected_at IS NOT NULL THEN
'connected'
@ -12034,24 +12040,24 @@ WHERE
END
-- Filter by dormant workspaces.
AND CASE
WHEN $10 :: boolean != 'false' THEN
WHEN $11 :: boolean != 'false' THEN
dormant_at IS NOT NULL
ELSE true
END
-- Filter by last_used
AND CASE
WHEN $11 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN
workspaces.last_used_at <= $11
WHEN $12 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN
workspaces.last_used_at <= $12
ELSE true
END
AND CASE
WHEN $12 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN
workspaces.last_used_at >= $12
WHEN $13 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN
workspaces.last_used_at >= $13
ELSE true
END
AND CASE
WHEN $13 :: boolean IS NOT NULL THEN
(latest_build.template_version_id = template.active_version_id) = $13 :: boolean
WHEN $14 :: boolean IS NOT NULL THEN
(latest_build.template_version_id = template.active_version_id) = $14 :: boolean
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
@ -12063,7 +12069,7 @@ WHERE
filtered_workspaces fw
ORDER BY
-- To ensure that 'favorite' workspaces show up first in the list only for their owner.
CASE WHEN owner_id = $14 AND favorite THEN 0 ELSE 1 END ASC,
CASE WHEN owner_id = $15 AND favorite THEN 0 ELSE 1 END ASC,
(latest_build_completed_at IS NOT NULL AND
latest_build_canceled_at IS NULL AND
latest_build_error IS NULL AND
@ -12072,11 +12078,11 @@ WHERE
LOWER(name) ASC
LIMIT
CASE
WHEN $16 :: integer > 0 THEN
$16
WHEN $17 :: integer > 0 THEN
$17
END
OFFSET
$15
$16
), filtered_workspaces_order_with_summary AS (
SELECT
fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.template_name, fwo.template_version_id, fwo.template_version_name, fwo.username, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition
@ -12111,7 +12117,7 @@ WHERE
'', -- latest_build_error
'start'::workspace_transition -- latest_build_transition
WHERE
$17 :: boolean = true
$18 :: boolean = true
), total_count AS (
SELECT
count(*) AS count
@ -12134,6 +12140,7 @@ type GetWorkspacesParams struct {
OwnerUsername string `db:"owner_username" json:"owner_username"`
TemplateName string `db:"template_name" json:"template_name"`
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
WorkspaceIds []uuid.UUID `db:"workspace_ids" json:"workspace_ids"`
Name string `db:"name" json:"name"`
HasAgent string `db:"has_agent" json:"has_agent"`
AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"`
@ -12182,6 +12189,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
arg.OwnerUsername,
arg.TemplateName,
pq.Array(arg.TemplateIDs),
pq.Array(arg.WorkspaceIds),
arg.Name,
arg.HasAgent,
arg.AgentInactiveDisconnectTimeoutSeconds,

View File

@ -204,6 +204,12 @@ WHERE
workspaces.template_id = ANY(@template_ids)
ELSE true
END
-- Filter by workspace_ids
AND CASE
WHEN array_length(@workspace_ids :: uuid[], 1) > 0 THEN
workspaces.id = ANY(@workspace_ids)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN @name :: text != '' THEN

View File

@ -103,6 +103,7 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
}
parser := httpapi.NewQueryParamParser()
filter.WorkspaceIds = parser.UUIDs(values, []uuid.UUID{}, "id")
filter.OwnerUsername = parser.String(values, "", "owner")
filter.TemplateName = parser.String(values, "", "template")
filter.Name = parser.String(values, "", "name")

View File

@ -178,6 +178,10 @@ func TestSearchWorkspace(t *testing.T) {
}
assert.Contains(t, s.String(), c.ExpectedErrorContains)
} else {
if len(c.Expected.WorkspaceIds) == len(values.WorkspaceIds) {
// nil slice vs 0 len slice is equivalent for our purposes.
c.Expected.WorkspaceIds = values.WorkspaceIds
}
assert.Len(t, errs, 0, "expected no error")
assert.Equal(t, c.Expected, values, "expected values")
}

View File

@ -9,6 +9,7 @@ import (
"math"
"net/http"
"os"
"slices"
"strings"
"testing"
"time"
@ -1356,6 +1357,39 @@ func TestWorkspaceFilterManual(t *testing.T) {
require.NoError(t, err)
require.Len(t, res.Workspaces, 0)
})
t.Run("IDs", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
alpha := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
bravo := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// full match
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
FilterQuery: fmt.Sprintf("id:%s,%s", alpha.ID, bravo.ID),
})
require.NoError(t, err)
require.Len(t, res.Workspaces, 2)
require.True(t, slices.ContainsFunc(res.Workspaces, func(workspace codersdk.Workspace) bool {
return workspace.ID == alpha.ID
}), "alpha workspace")
require.True(t, slices.ContainsFunc(res.Workspaces, func(workspace codersdk.Workspace) bool {
return workspace.ID == alpha.ID
}), "bravo workspace")
// no match
res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{
FilterQuery: fmt.Sprintf("id:%s", uuid.NewString()),
})
require.NoError(t, err)
require.Len(t, res.Workspaces, 0)
})
t.Run("Template", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})

View File

@ -253,6 +253,9 @@ func (c *Client) UpdateWorkspace(ctx context.Context, id uuid.UUID, req UpdateWo
// UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule.
type UpdateWorkspaceAutostartRequest struct {
// Schedule is expected to be of the form `CRON_TZ=<IANA Timezone> <min> <hour> * * <dow>`
// Example: `CRON_TZ=US/Central 30 9 * * 1-5` represents 0930 in the timezone US/Central
// on weekdays (Mon-Fri). `CRON_TZ` defaults to UTC if not present.
Schedule *string `json:"schedule"`
}

6
docs/api/schemas.md generated
View File

@ -6551,9 +6551,9 @@ If the schedule is empty, the user will be updated to use the default schedule.|
### Properties
| Name | Type | Required | Restrictions | Description |
| ---------- | ------ | -------- | ------------ | ----------- |
| `schedule` | string | false | | |
| Name | Type | Required | Restrictions | Description |
| ---------- | ------ | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `schedule` | string | false | | Schedule is expected to be of the form `CRON_TZ=<IANA Timezone> <min> <hour> * * <dow>` Example: `CRON_TZ=US/Central 30 9 * * 1-5` represents 0930 in the timezone US/Central on weekdays (Mon-Fri). `CRON_TZ` defaults to UTC if not present. |
## codersdk.UpdateWorkspaceDormancy

8
go.mod
View File

@ -180,14 +180,14 @@ require (
go.uber.org/atomic v1.11.0
go.uber.org/goleak v1.2.1
go4.org/netipx v0.0.0-20230728180743-ad4cb58a6516
golang.org/x/crypto v0.20.0
golang.org/x/crypto v0.21.0
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848
golang.org/x/mod v0.15.0
golang.org/x/net v0.21.0
golang.org/x/oauth2 v0.17.0
golang.org/x/sync v0.6.0
golang.org/x/sys v0.17.0
golang.org/x/term v0.17.0
golang.org/x/sys v0.18.0
golang.org/x/term v0.18.0
golang.org/x/text v0.14.0
golang.org/x/tools v0.18.0
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028
@ -200,7 +200,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
gvisor.dev/gvisor v0.0.0-20230504175454-7b0a1988a28f
nhooyr.io/websocket v1.8.7
storj.io/drpc v0.0.33-0.20230420154621-9716137f6037
storj.io/drpc v0.0.33
tailscale.com v1.46.1
)

14
go.sum
View File

@ -1023,8 +1023,8 @@ golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg=
golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE=
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
@ -1120,8 +1120,9 @@ golang.org/x/sys v0.4.1-0.20230131160137-e7d7f63158de/go.mod h1:oPkhp1MJrh7nUepC
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -1129,8 +1130,9 @@ golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@ -1249,5 +1251,5 @@ sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
software.sslmate.com/src/go-pkcs12 v0.2.0 h1:nlFkj7bTysH6VkC4fGphtjXRbezREPgrHuJG20hBGPE=
software.sslmate.com/src/go-pkcs12 v0.2.0/go.mod h1:23rNcYsMabIc1otwLpTkCCPwUq6kQsTyowttG/as0kQ=
storj.io/drpc v0.0.33-0.20230420154621-9716137f6037 h1:SYRl2YUthhsXNkrP30KwxkDGN9TESdNrbpr14rOxsnM=
storj.io/drpc v0.0.33-0.20230420154621-9716137f6037/go.mod h1:vR804UNzhBa49NOJ6HeLjd2H3MakC1j5Gv8bsOQT6N4=
storj.io/drpc v0.0.33 h1:yCGZ26r66ZdMP0IcTYsj7WDAUIIjzXk6DJhbhvt9FHI=
storj.io/drpc v0.0.33/go.mod h1:vR804UNzhBa49NOJ6HeLjd2H3MakC1j5Gv8bsOQT6N4=

View File

@ -34,7 +34,7 @@ import {
SearchEmpty,
SearchInput,
searchStyles,
} from "components/Menu/Search";
} from "components/Search/Search";
import { useDebouncedFunction } from "hooks/debounce";
import type { useFilterMenu } from "./menu";
import type { BaseOption } from "./options";
@ -612,7 +612,7 @@ function SearchMenu<TOption extends BaseOption>({
<SearchInput
autoFocus
value={query}
ref={searchInputRef}
$$ref={searchInputRef}
onChange={(e) => {
onQueryChange(e.target.value);
}}

View File

@ -1,105 +0,0 @@
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
// eslint-disable-next-line no-restricted-imports -- use it to have the component prop
import Box, { type BoxProps } from "@mui/material/Box";
import visuallyHidden from "@mui/utils/visuallyHidden";
import {
type FC,
type HTMLAttributes,
type InputHTMLAttributes,
forwardRef,
} from "react";
export const Search = forwardRef<HTMLElement, BoxProps>(
({ children, ...boxProps }, ref) => {
const theme = useTheme();
return (
<Box
ref={ref}
{...boxProps}
css={{
display: "flex",
alignItems: "center",
paddingLeft: 16,
height: 40,
borderBottom: `1px solid ${theme.palette.divider}`,
}}
>
<SearchOutlined
css={{
fontSize: 14,
color: theme.palette.text.secondary,
}}
/>
{children}
</Box>
);
},
);
type SearchInputProps = InputHTMLAttributes<HTMLInputElement> & {
label?: string;
};
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ label, ...inputProps }, ref) => {
const theme = useTheme();
return (
<>
<label css={{ ...visuallyHidden }} htmlFor={inputProps.id}>
{label}
</label>
<input
ref={ref}
tabIndex={-1}
type="text"
placeholder="Search..."
css={{
height: "100%",
border: 0,
background: "none",
flex: 1,
marginLeft: 16,
outline: 0,
"&::placeholder": {
color: theme.palette.text.secondary,
},
}}
{...inputProps}
/>
</>
);
},
);
export const SearchEmpty: FC<HTMLAttributes<HTMLDivElement>> = ({
children = "Not found",
...props
}) => {
const theme = useTheme();
return (
<div
css={{
fontSize: 13,
color: theme.palette.text.secondary,
textAlign: "center",
paddingTop: 8,
paddingBottom: 8,
}}
{...props}
>
{children}
</div>
);
};
export const searchStyles = {
content: {
width: 320,
padding: 0,
borderRadius: 4,
},
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Search, SearchInput } from "./Search";
const meta: Meta<typeof SearchInput> = {
title: "components/Search",
component: SearchInput,
decorators: [
(Story) => (
<Search>
<Story />
</Search>
),
],
};
export default meta;
type Story = StoryObj<typeof SearchInput>;
export const Example: Story = {};
export const WithPlaceholder: Story = {
args: {
label: "uwu",
placeholder: "uwu",
},
};

View File

@ -0,0 +1,117 @@
import type { Interpolation, Theme } from "@emotion/react";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
// eslint-disable-next-line no-restricted-imports -- use it to have the component prop
import Box, { type BoxProps } from "@mui/material/Box";
import visuallyHidden from "@mui/utils/visuallyHidden";
import type { FC, HTMLAttributes, InputHTMLAttributes, Ref } from "react";
interface SearchProps extends Omit<BoxProps, "ref"> {
$$ref?: Ref<unknown>;
}
/**
* A container component meant for `SearchInput`
*
* ```
* <Search>
* <SearchInput />
* </Search>
* ```
*/
export const Search: FC<SearchProps> = ({ children, $$ref, ...boxProps }) => {
return (
<Box ref={$$ref} {...boxProps} css={SearchStyles.container}>
<SearchOutlined css={SearchStyles.icon} />
{children}
</Box>
);
};
const SearchStyles = {
container: (theme) => ({
display: "flex",
alignItems: "center",
paddingLeft: 16,
height: 40,
borderBottom: `1px solid ${theme.palette.divider}`,
}),
icon: (theme) => ({
fontSize: 14,
color: theme.palette.text.secondary,
}),
} satisfies Record<string, Interpolation<Theme>>;
type SearchInputProps = InputHTMLAttributes<HTMLInputElement> & {
label?: string;
$$ref?: Ref<HTMLInputElement>;
};
export const SearchInput: FC<SearchInputProps> = ({
label,
$$ref,
...inputProps
}) => {
return (
<>
<label css={{ ...visuallyHidden }} htmlFor={inputProps.id}>
{label}
</label>
<input
ref={$$ref}
tabIndex={-1}
type="text"
placeholder="Search..."
css={SearchInputStyles.input}
{...inputProps}
/>
</>
);
};
const SearchInputStyles = {
input: (theme) => ({
color: "inherit",
height: "100%",
border: 0,
background: "none",
flex: 1,
marginLeft: 16,
outline: 0,
"&::placeholder": {
color: theme.palette.text.secondary,
},
}),
} satisfies Record<string, Interpolation<Theme>>;
export const SearchEmpty: FC<HTMLAttributes<HTMLDivElement>> = ({
children = "Not found",
...props
}) => {
return (
<div css={SearchEmptyStyles.empty} {...props}>
{children}
</div>
);
};
const SearchEmptyStyles = {
empty: (theme) => ({
fontSize: 13,
color: theme.palette.text.secondary,
textAlign: "center",
paddingTop: 8,
paddingBottom: 8,
}),
} satisfies Record<string, Interpolation<Theme>>;
/**
* Reusable styles for consumers of the base components
*/
export const searchStyles = {
content: {
width: 320,
padding: 0,
borderRadius: 4,
},
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -2,6 +2,7 @@ import { css } from "@emotion/css";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import type { FormikContextType } from "formik";
import { type FC, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
@ -21,6 +22,8 @@ import {
customLifetimeDay,
} from "./utils";
dayjs.extend(utc);
interface CreateTokenFormProps {
form: FormikContextType<CreateTokenData>;
maxTokenLifetime?: number;

View File

@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CreateTokenPage } from "./CreateTokenPage";
const meta: Meta<typeof CreateTokenPage> = {
title: "components/CreateTokenPage",
component: CreateTokenPage,
parameters: {
queries: [
{
key: ["tokenconfig"],
data: { max_token_lifetime: 1_000 },
},
],
},
};
export default meta;
type Story = StoryObj<typeof CreateTokenPage>;
export const Default: Story = {};

View File

@ -11,13 +11,13 @@ import {
import type { Template } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Loader } from "components/Loader/Loader";
import { SearchEmpty, searchStyles } from "components/Menu/Search";
import { OverflowY } from "components/OverflowY/OverflowY";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import { SearchEmpty, searchStyles } from "components/Search/Search";
import { SearchBox } from "./WorkspacesSearchBox";
const ICON_SIZE = 18;

View File

@ -5,33 +5,30 @@
* reusable this is outside of workspace dropdowns.
*/
import {
type ForwardedRef,
type FC,
type KeyboardEvent,
type InputHTMLAttributes,
forwardRef,
type Ref,
useId,
} from "react";
import { Search, SearchInput } from "components/Menu/Search";
import { Search, SearchInput } from "components/Search/Search";
interface SearchBoxProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
value: string;
onKeyDown?: (event: KeyboardEvent) => void;
onValueChange: (newValue: string) => void;
$$ref?: Ref<HTMLInputElement>;
}
export const SearchBox = forwardRef(function SearchBox(
props: SearchBoxProps,
ref?: ForwardedRef<HTMLInputElement>,
) {
const {
onValueChange,
onKeyDown,
label = "Search",
placeholder = "Search...",
...attrs
} = props;
export const SearchBox: FC<SearchBoxProps> = ({
onValueChange,
onKeyDown,
label = "Search",
placeholder = "Search...",
$$ref,
...attrs
}) => {
const hookId = useId();
const inputId = `${hookId}-${SearchBox.name}-input`;
@ -39,7 +36,7 @@ export const SearchBox = forwardRef(function SearchBox(
<Search>
<SearchInput
label={label}
ref={ref}
$$ref={$$ref}
id={inputId}
autoFocus
tabIndex={0}
@ -50,4 +47,4 @@ export const SearchBox = forwardRef(function SearchBox(
/>
</Search>
);
});
};

View File

@ -56,6 +56,7 @@
"lxc.svg",
"matlab.svg",
"memory.svg",
"microsoft-teams.svg",
"microsoft.svg",
"nix.svg",
"node.svg",

View File

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 13.9032C19 13.4044 19.4044 13 19.9032 13H31.0968C31.5956 13 32 13.4044 32 13.9032V20.5C32 24.0899 29.0899 27 25.5 27C21.9101 27 19 24.0899 19 20.5V13.9032Z" fill="url(#paint0_linear_87_7777)"/>
<path d="M9 12.2258C9 11.5488 9.54881 11 10.2258 11H23.7742C24.4512 11 25 11.5488 25 12.2258V22C25 26.4183 21.4183 30 17 30C12.5817 30 9 26.4183 9 22V12.2258Z" fill="url(#paint1_linear_87_7777)"/>
<circle cx="27" cy="8" r="3" fill="#34439E"/>
<circle cx="27" cy="8" r="3" fill="url(#paint2_linear_87_7777)"/>
<circle cx="18" cy="6" r="4" fill="url(#paint3_linear_87_7777)"/>
<mask id="mask0_87_7777" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="9" y="0" width="16" height="30">
<path d="M17 10C19.7615 10 22 7.76147 22 5C22 2.23853 19.7615 0 17 0C14.2385 0 12 2.23853 12 5C12 7.76147 14.2385 10 17 10Z" fill="url(#paint4_linear_87_7777)"/>
<path d="M10.2258 11C9.54883 11 9 11.5488 9 12.2258V22C9 26.4183 12.5817 30 17 30C21.4183 30 25 26.4183 25 22V12.2258C25 11.5488 24.4512 11 23.7742 11H10.2258Z" fill="url(#paint5_linear_87_7777)"/>
</mask>
<g mask="url(#mask0_87_7777)">
<path d="M7 12C7 10.3431 8.34315 9 10 9H17C18.6569 9 20 10.3431 20 12V24C20 25.6569 18.6569 27 17 27H7V12Z" fill="#000000" fill-opacity="0.3"/>
</g>
<rect y="7" width="18" height="18" rx="2" fill="url(#paint6_linear_87_7777)"/>
<path d="M13 11H5V12.8347H7.99494V21H10.0051V12.8347H13V11Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_87_7777" x1="19" y1="13.7368" x2="32.1591" y2="22.3355" gradientUnits="userSpaceOnUse">
<stop stop-color="#364088"/>
<stop offset="1" stop-color="#6E7EE1"/>
</linearGradient>
<linearGradient id="paint1_linear_87_7777" x1="9" y1="19.4038" x2="25" y2="19.4038" gradientUnits="userSpaceOnUse">
<stop stop-color="#515FC4"/>
<stop offset="1" stop-color="#7084EA"/>
</linearGradient>
<linearGradient id="paint2_linear_87_7777" x1="24" y1="5.31579" x2="29.7963" y2="9.39469" gradientUnits="userSpaceOnUse">
<stop stop-color="#364088"/>
<stop offset="1" stop-color="#6E7EE1"/>
</linearGradient>
<linearGradient id="paint3_linear_87_7777" x1="15.1429" y1="3.14286" x2="20.2857" y2="9.14286" gradientUnits="userSpaceOnUse">
<stop stop-color="#4858AE"/>
<stop offset="1" stop-color="#4E60CE"/>
</linearGradient>
<linearGradient id="paint4_linear_87_7777" x1="13.4286" y1="1.42857" x2="19.8571" y2="8.92857" gradientUnits="userSpaceOnUse">
<stop stop-color="#4858AE"/>
<stop offset="1" stop-color="#4E60CE"/>
</linearGradient>
<linearGradient id="paint5_linear_87_7777" x1="13.4286" y1="1.42857" x2="19.8571" y2="8.92857" gradientUnits="userSpaceOnUse">
<stop stop-color="#4858AE"/>
<stop offset="1" stop-color="#4E60CE"/>
</linearGradient>
<linearGradient id="paint6_linear_87_7777" x1="-5.21539e-08" y1="16" x2="18" y2="16" gradientUnits="userSpaceOnUse">
<stop stop-color="#2A3887"/>
<stop offset="1" stop-color="#4C56B9"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB