mirror of https://github.com/coder/coder.git
Merge branch 'main' of github.com:/coder/coder into dk/configurable-cardinality
This commit is contained in:
commit
b1ef0d643b
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
@ -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
8
go.mod
|
@ -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
14
go.sum
|
@ -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=
|
||||
|
|
|
@ -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);
|
||||
}}
|
||||
|
|
|
@ -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>>;
|
|
@ -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",
|
||||
},
|
||||
};
|
|
@ -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>>;
|
|
@ -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;
|
||||
|
|
|
@ -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 = {};
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -56,6 +56,7 @@
|
|||
"lxc.svg",
|
||||
"matlab.svg",
|
||||
"memory.svg",
|
||||
"microsoft-teams.svg",
|
||||
"microsoft.svg",
|
||||
"nix.svg",
|
||||
"node.svg",
|
||||
|
|
|
@ -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 |
Loading…
Reference in New Issue