diff --git a/.github/workflows/coder.yaml b/.github/workflows/coder.yaml index f15e3be0659c4..a83cf896a7a14 100644 --- a/.github/workflows/coder.yaml +++ b/.github/workflows/coder.yaml @@ -194,9 +194,15 @@ jobs: run: ./scripts/yarn_install.sh - name: Install Protoc - uses: arduino/setup-protoc@v1 - with: - version: "3.20.0" + run: | + # protoc must be in lockstep with our dogfood Dockerfile + # or the version in the comments will differ. + set -x + cd dogfood + DOCKER_BUILDKIT=1 docker build . --target proto -t protoc + protoc_dir=/usr/local/bin/protoc + docker run --rm --entrypoint cat protoc /tmp/bin/protoc > $protoc_dir + chmod +x $protoc_dir - uses: actions/setup-go@v3 with: go-version: "~1.19" diff --git a/.gitignore b/.gitignore index d3deb0c72f550..ae2c40f0a576c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ site/out/ .vscode/*.log **/*.swp .coderv2/* +**/__debug_bin diff --git a/Makefile b/Makefile index 3867003a92ea5..3c1a1d74aa038 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,9 @@ bin: $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum darwin:amd64,arm64 .PHONY: bin -build: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates) +GO_FILES=$(shell find . -not -path './vendor/*' -type f -name '*.go') go.mod go.sum $(shell find ./examples/templates) + +build: site/out/index.html $(GO_FILES) rm -rf ./dist mkdir -p ./dist rm -f ./site/out/bin/coder* @@ -55,6 +57,30 @@ build: site/out/index.html $(shell find . -not -path './vendor/*' -type f -name darwin:amd64,arm64 .PHONY: build +# Builds a test binary for just Linux +build-linux-test: site/out/index.html $(GO_FILES) + rm -rf ./dist + mkdir -p ./dist + rm -f ./site/out/bin/coder* + + # build slim artifacts and copy them to the site output directory + ./scripts/build_go_slim.sh \ + --version "$(VERSION)" \ + --compress 6 \ + --output ./dist/ \ + linux:amd64,armv7,arm64 \ + windows:amd64,arm64 \ + darwin:amd64,arm64 + + # build not-so-slim artifacts with the default name format + ./scripts/build_go_matrix.sh \ + --version "$(VERSION)" \ + --output ./dist/ \ + --archive \ + --package-linux \ + linux:amd64 +.PHONY: build-linux-test + # Runs migrations to output a dump of the database. coderd/database/dump.sql: coderd/database/gen/dump/main.go $(wildcard coderd/database/migrations/*.sql) go run coderd/database/gen/dump/main.go diff --git a/agent/agent.go b/agent/agent.go index da19c4ac61de7..3087357baa366 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -65,6 +65,7 @@ type Options struct { WebRTCDialer WebRTCDialer FetchMetadata FetchMetadata + StatsReporter StatsReporter ReconnectingPTYTimeout time.Duration EnvironmentVariables map[string]string Logger slog.Logger @@ -100,6 +101,8 @@ func New(options Options) io.Closer { envVars: options.EnvironmentVariables, coordinatorDialer: options.CoordinatorDialer, fetchMetadata: options.FetchMetadata, + stats: &Stats{}, + statsReporter: options.StatsReporter, } server.init(ctx) return server @@ -125,6 +128,8 @@ type agent struct { network *tailnet.Conn coordinatorDialer CoordinatorDialer + stats *Stats + statsReporter StatsReporter } func (a *agent) run(ctx context.Context) { @@ -194,6 +199,13 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) { a.logger.Critical(ctx, "create tailnet", slog.Error(err)) return } + a.network.SetForwardTCPCallback(func(conn net.Conn, listenerExists bool) net.Conn { + if listenerExists { + // If a listener already exists, we would double-wrap the conn. + return conn + } + return a.stats.wrapConn(conn) + }) go a.runCoordinator(ctx) sshListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(tailnetSSHPort)) @@ -207,7 +219,7 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) { if err != nil { return } - go a.sshServer.HandleConn(conn) + a.sshServer.HandleConn(a.stats.wrapConn(conn)) } }() reconnectingPTYListener, err := a.network.Listen("tcp", ":"+strconv.Itoa(tailnetReconnectingPTYPort)) @@ -219,8 +231,10 @@ func (a *agent) runTailnet(ctx context.Context, derpMap *tailcfg.DERPMap) { for { conn, err := reconnectingPTYListener.Accept() if err != nil { + a.logger.Debug(ctx, "accept pty failed", slog.Error(err)) return } + conn = a.stats.wrapConn(conn) // This cannot use a JSON decoder, since that can // buffer additional data that is required for the PTY. rawLen := make([]byte, 2) @@ -364,17 +378,17 @@ func (a *agent) runStartupScript(ctx context.Context, script string) error { return nil } -func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { +func (a *agent) handlePeerConn(ctx context.Context, peerConn *peer.Conn) { go func() { select { case <-a.closed: - case <-conn.Closed(): + case <-peerConn.Closed(): } - _ = conn.Close() + _ = peerConn.Close() a.connCloseWait.Done() }() for { - channel, err := conn.Accept(ctx) + channel, err := peerConn.Accept(ctx) if err != nil { if errors.Is(err, peer.ErrClosed) || a.isClosed() { return @@ -383,9 +397,11 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { return } + conn := channel.NetConn() + switch channel.Protocol() { case ProtocolSSH: - go a.sshServer.HandleConn(channel.NetConn()) + go a.sshServer.HandleConn(a.stats.wrapConn(conn)) case ProtocolReconnectingPTY: rawID := channel.Label() // The ID format is referenced in conn.go. @@ -418,9 +434,9 @@ func (a *agent) handlePeerConn(ctx context.Context, conn *peer.Conn) { Height: uint16(height), Width: uint16(width), Command: idParts[3], - }, channel.NetConn()) + }, a.stats.wrapConn(conn)) case ProtocolDial: - go a.handleDial(ctx, channel.Label(), channel.NetConn()) + go a.handleDial(ctx, channel.Label(), a.stats.wrapConn(conn)) default: a.logger.Warn(ctx, "unhandled protocol from channel", slog.F("protocol", channel.Protocol()), @@ -514,6 +530,21 @@ func (a *agent) init(ctx context.Context) { } go a.run(ctx) + if a.statsReporter != nil { + cl, err := a.statsReporter(ctx, a.logger, func() *Stats { + return a.stats.Copy() + }) + if err != nil { + a.logger.Error(ctx, "report stats", slog.Error(err)) + return + } + a.connCloseWait.Add(1) + go func() { + defer a.connCloseWait.Done() + <-a.closed + cl.Close() + }() + } } // createCommand processes raw command input with OpenSSH-like behavior. diff --git a/agent/agent_test.go b/agent/agent_test.go index fa671cd01723b..a2a4fc28c445a 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -19,6 +19,7 @@ import ( "time" "golang.org/x/xerrors" + "tailscale.com/tailcfg" scp "github.com/bramvdbogaerde/go-scp" "github.com/google/uuid" @@ -51,6 +52,67 @@ func TestMain(m *testing.M) { func TestAgent(t *testing.T) { t.Parallel() + t.Run("Stats", func(t *testing.T) { + for _, tailscale := range []bool{true, false} { + t.Run(fmt.Sprintf("tailscale=%v", tailscale), func(t *testing.T) { + t.Parallel() + + setupAgent := func(t *testing.T) (agent.Conn, <-chan *agent.Stats) { + var derpMap *tailcfg.DERPMap + if tailscale { + derpMap = tailnettest.RunDERPAndSTUN(t) + } + conn, stats := setupAgent(t, agent.Metadata{ + DERPMap: derpMap, + }, 0) + assert.Empty(t, <-stats) + return conn, stats + } + + t.Run("SSH", func(t *testing.T) { + t.Parallel() + conn, stats := setupAgent(t) + + sshClient, err := conn.SSHClient() + require.NoError(t, err) + session, err := sshClient.NewSession() + require.NoError(t, err) + defer session.Close() + + assert.EqualValues(t, 1, (<-stats).NumConns) + assert.Greater(t, (<-stats).RxBytes, int64(0)) + assert.Greater(t, (<-stats).TxBytes, int64(0)) + }) + + t.Run("ReconnectingPTY", func(t *testing.T) { + t.Parallel() + + conn, stats := setupAgent(t) + + ptyConn, err := conn.ReconnectingPTY(uuid.NewString(), 128, 128, "/bin/bash") + require.NoError(t, err) + defer ptyConn.Close() + + data, err := json.Marshal(agent.ReconnectingPTYRequest{ + Data: "echo test\r\n", + }) + require.NoError(t, err) + _, err = ptyConn.Write(data) + require.NoError(t, err) + + var s *agent.Stats + require.Eventuallyf(t, func() bool { + var ok bool + s, ok = (<-stats) + return ok && s.NumConns > 0 && s.RxBytes > 0 && s.TxBytes > 0 + }, testutil.WaitShort, testutil.IntervalFast, + "never saw stats: %+v", s, + ) + }) + }) + } + }) + t.Run("SessionExec", func(t *testing.T) { t.Parallel() session := setupSSHSession(t, agent.Metadata{}) @@ -169,7 +231,8 @@ func TestAgent(t *testing.T) { t.Run("SFTP", func(t *testing.T) { t.Parallel() - sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient() + conn, _ := setupAgent(t, agent.Metadata{}, 0) + sshClient, err := conn.SSHClient() require.NoError(t, err) client, err := sftp.NewClient(sshClient) require.NoError(t, err) @@ -184,7 +247,9 @@ func TestAgent(t *testing.T) { t.Run("SCP", func(t *testing.T) { t.Parallel() - sshClient, err := setupAgent(t, agent.Metadata{}, 0).SSHClient() + + conn, _ := setupAgent(t, agent.Metadata{}, 0) + sshClient, err := conn.SSHClient() require.NoError(t, err) scpClient, err := scp.NewClientBySSH(sshClient) require.NoError(t, err) @@ -318,7 +383,7 @@ func TestAgent(t *testing.T) { t.Skip("ConPTY appears to be inconsistent on Windows.") } - conn := setupAgent(t, agent.Metadata{ + conn, _ := setupAgent(t, agent.Metadata{ DERPMap: tailnettest.RunDERPAndSTUN(t), }, 0) id := uuid.NewString() @@ -431,7 +496,7 @@ func TestAgent(t *testing.T) { }() // Dial the listener over WebRTC twice and test out of order - conn := setupAgent(t, agent.Metadata{}, 0) + conn, _ := setupAgent(t, agent.Metadata{}, 0) conn1, err := conn.DialContext(context.Background(), l.Addr().Network(), l.Addr().String()) require.NoError(t, err) defer conn1.Close() @@ -462,7 +527,7 @@ func TestAgent(t *testing.T) { }) // Try to dial the non-existent Unix socket over WebRTC - conn := setupAgent(t, agent.Metadata{}, 0) + conn, _ := setupAgent(t, agent.Metadata{}, 0) netConn, err := conn.DialContext(context.Background(), "unix", filepath.Join(tmpDir, "test.sock")) require.Error(t, err) require.ErrorContains(t, err, "remote dial error") @@ -473,7 +538,7 @@ func TestAgent(t *testing.T) { t.Run("Tailnet", func(t *testing.T) { t.Parallel() derpMap := tailnettest.RunDERPAndSTUN(t) - conn := setupAgent(t, agent.Metadata{ + conn, _ := setupAgent(t, agent.Metadata{ DERPMap: derpMap, }, 0) defer conn.Close() @@ -485,7 +550,7 @@ func TestAgent(t *testing.T) { } func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exec.Cmd { - agentConn := setupAgent(t, agent.Metadata{}, 0) + agentConn, _ := setupAgent(t, agent.Metadata{}, 0) listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) waitGroup := sync.WaitGroup{} @@ -523,7 +588,8 @@ func setupSSHCommand(t *testing.T, beforeArgs []string, afterArgs []string) *exe } func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session { - sshClient, err := setupAgent(t, options, 0).SSHClient() + conn, _ := setupAgent(t, options, 0) + sshClient, err := conn.SSHClient() require.NoError(t, err) t.Cleanup(func() { _ = sshClient.Close() @@ -533,11 +599,21 @@ func setupSSHSession(t *testing.T, options agent.Metadata) *ssh.Session { return session } -func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) agent.Conn { +type closeFunc func() error + +func (c closeFunc) Close() error { + return c() +} + +func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) ( + agent.Conn, + <-chan *agent.Stats, +) { client, server := provisionersdk.TransportPipe() tailscale := metadata.DERPMap != nil coordinator := tailnet.NewCoordinator() agentID := uuid.New() + statsCh := make(chan *agent.Stats) closer := agent.New(agent.Options{ FetchMetadata: func(ctx context.Context) (agent.Metadata, error) { return metadata, nil @@ -557,6 +633,38 @@ func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) }, Logger: slogtest.Make(t, nil).Leveled(slog.LevelDebug), ReconnectingPTYTimeout: ptyTimeout, + StatsReporter: func(ctx context.Context, log slog.Logger, statsFn func() *agent.Stats) (io.Closer, error) { + doneCh := make(chan struct{}) + ctx, cancel := context.WithCancel(ctx) + + go func() { + defer close(doneCh) + + t := time.NewTicker(time.Millisecond * 100) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + select { + case statsCh <- statsFn(): + case <-ctx.Done(): + return + default: + // We don't want to send old stats. + continue + } + } + }() + return closeFunc(func() error { + cancel() + <-doneCh + close(statsCh) + return nil + }), nil + }, }) t.Cleanup(func() { _ = client.Close() @@ -586,7 +694,7 @@ func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) conn.SetNodeCallback(sendNode) return &agent.TailnetConn{ Conn: conn, - } + }, statsCh } conn, err := peerbroker.Dial(stream, []webrtc.ICEServer{}, &peer.ConnOptions{ Logger: slogtest.Make(t, nil), @@ -599,7 +707,7 @@ func setupAgent(t *testing.T, metadata agent.Metadata, ptyTimeout time.Duration) return &agent.WebRTCConn{ Negotiator: api, Conn: conn, - } + }, statsCh } var dialTestPayload = []byte("dean-was-here123") diff --git a/agent/stats.go b/agent/stats.go new file mode 100644 index 0000000000000..0015a3e4e1fb1 --- /dev/null +++ b/agent/stats.go @@ -0,0 +1,67 @@ +package agent + +import ( + "context" + "io" + "net" + "sync/atomic" + + "cdr.dev/slog" +) + +// statsConn wraps a net.Conn with statistics. +type statsConn struct { + *Stats + net.Conn `json:"-"` +} + +var _ net.Conn = new(statsConn) + +func (c *statsConn) Read(b []byte) (n int, err error) { + n, err = c.Conn.Read(b) + atomic.AddInt64(&c.RxBytes, int64(n)) + return n, err +} + +func (c *statsConn) Write(b []byte) (n int, err error) { + n, err = c.Conn.Write(b) + atomic.AddInt64(&c.TxBytes, int64(n)) + return n, err +} + +var _ net.Conn = new(statsConn) + +// Stats records the Agent's network connection statistics for use in +// user-facing metrics and debugging. +// Each member value must be written and read with atomic. +type Stats struct { + NumConns int64 `json:"num_comms"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` +} + +func (s *Stats) Copy() *Stats { + return &Stats{ + NumConns: atomic.LoadInt64(&s.NumConns), + RxBytes: atomic.LoadInt64(&s.RxBytes), + TxBytes: atomic.LoadInt64(&s.TxBytes), + } +} + +// wrapConn returns a new connection that records statistics. +func (s *Stats) wrapConn(conn net.Conn) net.Conn { + atomic.AddInt64(&s.NumConns, 1) + cs := &statsConn{ + Stats: s, + Conn: conn, + } + + return cs +} + +// StatsReporter periodically accept and records agent stats. +type StatsReporter func( + ctx context.Context, + log slog.Logger, + stats func() *Stats, +) (io.Closer, error) diff --git a/cli/agent.go b/cli/agent.go index eb6d2287af998..2c6fdef4a03ce 100644 --- a/cli/agent.go +++ b/cli/agent.go @@ -192,6 +192,7 @@ func workspaceAgent() *cobra.Command { "CODER_AGENT_TOKEN": client.SessionToken, }, CoordinatorDialer: client.ListenWorkspaceAgentTailnet, + StatsReporter: client.AgentReportStats, }) <-cmd.Context().Done() return closer.Close() diff --git a/cli/server.go b/cli/server.go index f6f5534f3604c..40e738bad4107 100644 --- a/cli/server.go +++ b/cli/server.go @@ -120,6 +120,8 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command { autoImportTemplates []string spooky bool verbose bool + metricsCacheRefreshInterval time.Duration + agentStatRefreshInterval time.Duration ) root := &cobra.Command{ @@ -345,21 +347,23 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command { } options := &coderd.Options{ - AccessURL: accessURLParsed, - ICEServers: iceServers, - Logger: logger.Named("coderd"), - Database: databasefake.New(), - DERPMap: derpMap, - Pubsub: database.NewPubsubInMemory(), - CacheDir: cacheDir, - GoogleTokenValidator: googleTokenValidator, - SecureAuthCookie: secureAuthCookie, - SSHKeygenAlgorithm: sshKeygenAlgorithm, - TailscaleEnable: tailscaleEnable, - TURNServer: turnServer, - TracerProvider: tracerProvider, - Telemetry: telemetry.NewNoop(), - AutoImportTemplates: validatedAutoImportTemplates, + AccessURL: accessURLParsed, + ICEServers: iceServers, + Logger: logger.Named("coderd"), + Database: databasefake.New(), + DERPMap: derpMap, + Pubsub: database.NewPubsubInMemory(), + CacheDir: cacheDir, + GoogleTokenValidator: googleTokenValidator, + SecureAuthCookie: secureAuthCookie, + SSHKeygenAlgorithm: sshKeygenAlgorithm, + TailscaleEnable: tailscaleEnable, + TURNServer: turnServer, + TracerProvider: tracerProvider, + Telemetry: telemetry.NewNoop(), + AutoImportTemplates: validatedAutoImportTemplates, + MetricsCacheRefreshInterval: metricsCacheRefreshInterval, + AgentStatsRefreshInterval: agentStatRefreshInterval, } if oauth2GithubClientSecret != "" { @@ -834,8 +838,16 @@ func Server(newAPI func(*coderd.Options) *coderd.API) *cobra.Command { `Accepted values are "ed25519", "ecdsa", or "rsa4096"`) cliflag.StringArrayVarP(root.Flags(), &autoImportTemplates, "auto-import-template", "", "CODER_TEMPLATE_AUTOIMPORT", []string{}, "Which templates to auto-import. Available auto-importable templates are: kubernetes") cliflag.BoolVarP(root.Flags(), &spooky, "spooky", "", "", false, "Specifies spookiness level") - cliflag.BoolVarP(root.Flags(), &verbose, "verbose", "v", "CODER_VERBOSE", false, "Enables verbose logging.") _ = root.Flags().MarkHidden("spooky") + cliflag.BoolVarP(root.Flags(), &verbose, "verbose", "v", "CODER_VERBOSE", false, "Enables verbose logging.") + + // These metrics flags are for manually testing the metric system. + // The defaults should be acceptable for any Coder deployment of any + // reasonable size. + cliflag.DurationVarP(root.Flags(), &metricsCacheRefreshInterval, "metrics-cache-refresh-interval", "", "CODER_METRICS_CACHE_REFRESH_INTERVAL", time.Hour, "How frequently metrics are refreshed") + _ = root.Flags().MarkHidden("metrics-cache-refresh-interval") + cliflag.DurationVarP(root.Flags(), &agentStatRefreshInterval, "agent-stats-refresh-interval", "", "CODER_AGENT_STATS_REFRESH_INTERVAL", time.Minute*10, "How frequently agent stats are recorded") + _ = root.Flags().MarkHidden("agent-stats-report-interval") return root } diff --git a/coderd/coderd.go b/coderd/coderd.go index 1f9971bf014da..52305ba256623 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -30,6 +30,7 @@ import ( "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/metricscache" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/coderd/tracing" @@ -76,6 +77,9 @@ type Options struct { TailscaleEnable bool TailnetCoordinator *tailnet.Coordinator DERPMap *tailcfg.DERPMap + + MetricsCacheRefreshInterval time.Duration + AgentStatsRefreshInterval time.Duration } // New constructs a Coder API handler. @@ -121,6 +125,12 @@ func New(options *Options) *API { panic(xerrors.Errorf("read site bin failed: %w", err)) } + metricsCache := metricscache.New( + options.Database, + options.Logger.Named("metrics_cache"), + options.MetricsCacheRefreshInterval, + ) + r := chi.NewRouter() api := &API{ Options: options, @@ -130,6 +140,7 @@ func New(options *Options) *API { Authorizer: options.Authorizer, Logger: options.Logger, }, + metricsCache: metricsCache, } if options.TailscaleEnable { api.workspaceAgentCache = wsconncache.New(api.dialWorkspaceAgentTailnet, 0) @@ -147,6 +158,13 @@ func New(options *Options) *API { httpmw.Recover(api.Logger), httpmw.Logger(api.Logger), httpmw.Prometheus(options.PrometheusRegistry), + // Build-Version is helpful for debugging. + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Build-Version", buildinfo.Version()) + next.ServeHTTP(w, r) + }) + }, ) apps := func(r chi.Router) { @@ -259,7 +277,7 @@ func New(options *Options) *API { apiKeyMiddleware, httpmw.ExtractTemplateParam(options.Database), ) - + r.Get("/daus", api.templateDAUs) r.Get("/", api.template) r.Delete("/", api.deleteTemplate) r.Patch("/", api.patchTemplateMeta) @@ -359,11 +377,14 @@ func New(options *Options) *API { r.Get("/metadata", api.workspaceAgentMetadata) r.Post("/version", api.postWorkspaceAgentVersion) r.Get("/listen", api.workspaceAgentListen) + r.Get("/gitsshkey", api.agentGitSSHKey) r.Get("/turn", api.workspaceAgentTurn) r.Get("/iceservers", api.workspaceAgentICEServers) r.Get("/coordinate", api.workspaceAgentCoordinate) + + r.Get("/report-stats", api.workspaceAgentReportStats) }) r.Route("/{workspaceagent}", func(r chi.Router) { r.Use( @@ -452,6 +473,8 @@ type API struct { websocketWaitGroup sync.WaitGroup workspaceAgentCache *wsconncache.Cache httpAuth *HTTPAuthorizer + + metricsCache *metricscache.Cache } // Close waits for all WebSocket connections to drain before returning. @@ -460,6 +483,8 @@ func (api *API) Close() error { api.websocketWaitGroup.Wait() api.websocketWaitMutex.Unlock() + api.metricsCache.Close() + return api.workspaceAgentCache.Close() } diff --git a/coderd/coderdtest/authtest.go b/coderd/coderdtest/authtest.go index 66a7007eeb4ce..90211c8629f23 100644 --- a/coderd/coderdtest/authtest.go +++ b/coderd/coderdtest/authtest.go @@ -197,6 +197,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "GET:/api/v2/workspaceagents/me/turn": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/me/coordinate": {NoAuthorize: true}, "POST:/api/v2/workspaceagents/me/version": {NoAuthorize: true}, + "GET:/api/v2/workspaceagents/me/report-stats": {NoAuthorize: true}, "GET:/api/v2/workspaceagents/{workspaceagent}/iceservers": {NoAuthorize: true}, // These endpoints have more assertions. This is good, add more endpoints to assert if you can! diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 8970058ffb74d..76cb4098709c2 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -234,7 +234,9 @@ func newWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c }, }, }, - AutoImportTemplates: options.AutoImportTemplates, + AutoImportTemplates: options.AutoImportTemplates, + MetricsCacheRefreshInterval: time.Millisecond * 100, + AgentStatsRefreshInterval: time.Millisecond * 100, }) t.Cleanup(func() { _ = coderAPI.Close() diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 6ebf15613e290..7fc3aea9e71c2 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/lib/pq" + "golang.org/x/exp/maps" "golang.org/x/exp/slices" "github.com/coder/coder/coderd/database" @@ -23,6 +24,7 @@ func New() database.Store { mutex: &sync.RWMutex{}, data: &data{ apiKeys: make([]database.APIKey, 0), + agentStats: make([]database.AgentStat, 0), organizationMembers: make([]database.OrganizationMember, 0), organizations: make([]database.Organization, 0), users: make([]database.User, 0), @@ -78,6 +80,7 @@ type data struct { userLinks []database.UserLink // New tables + agentStats []database.AgentStat auditLogs []database.AuditLog files []database.File gitSSHKey []database.GitSSHKey @@ -134,6 +137,64 @@ func (q *fakeQuerier) AcquireProvisionerJob(_ context.Context, arg database.Acqu } return database.ProvisionerJob{}, sql.ErrNoRows } +func (*fakeQuerier) DeleteOldAgentStats(_ context.Context) error { + // no-op + return nil +} + +func (q *fakeQuerier) InsertAgentStat(_ context.Context, p database.InsertAgentStatParams) (database.AgentStat, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + stat := database.AgentStat{ + ID: p.ID, + CreatedAt: p.CreatedAt, + WorkspaceID: p.WorkspaceID, + AgentID: p.AgentID, + UserID: p.UserID, + Payload: p.Payload, + TemplateID: p.TemplateID, + } + q.agentStats = append(q.agentStats, stat) + return stat, nil +} + +func (q *fakeQuerier) GetTemplateDAUs(_ context.Context, templateID uuid.UUID) ([]database.GetTemplateDAUsRow, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + counts := make(map[time.Time]map[string]struct{}) + + for _, as := range q.agentStats { + if as.TemplateID != templateID { + continue + } + + date := as.CreatedAt.Truncate(time.Hour * 24) + dateEntry := counts[date] + if dateEntry == nil { + dateEntry = make(map[string]struct{}) + } + counts[date] = dateEntry + + dateEntry[as.UserID.String()] = struct{}{} + } + + countKeys := maps.Keys(counts) + sort.Slice(countKeys, func(i, j int) bool { + return countKeys[i].Before(countKeys[j]) + }) + + var rs []database.GetTemplateDAUsRow + for _, key := range countKeys { + rs = append(rs, database.GetTemplateDAUsRow{ + Date: key, + Amount: int64(len(counts[key])), + }) + } + + return rs, nil +} func (q *fakeQuerier) ParameterValue(_ context.Context, id uuid.UUID) (database.ParameterValue, error) { q.mutex.Lock() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 133136ad07689..86a1f91cc8c96 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -87,6 +87,16 @@ CREATE TYPE workspace_transition AS ENUM ( 'delete' ); +CREATE TABLE agent_stats ( + id uuid NOT NULL, + created_at timestamp with time zone NOT NULL, + user_id uuid NOT NULL, + agent_id uuid NOT NULL, + workspace_id uuid NOT NULL, + template_id uuid NOT NULL, + payload jsonb NOT NULL +); + CREATE TABLE api_keys ( id text NOT NULL, hashed_secret bytea NOT NULL, @@ -372,6 +382,9 @@ CREATE TABLE workspaces ( ALTER TABLE ONLY licenses ALTER COLUMN id SET DEFAULT nextval('public.licenses_id_seq'::regclass); +ALTER TABLE ONLY agent_stats + ADD CONSTRAINT agent_stats_pkey PRIMARY KEY (id); + ALTER TABLE ONLY api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); @@ -468,6 +481,10 @@ ALTER TABLE ONLY workspace_resources ALTER TABLE ONLY workspaces ADD CONSTRAINT workspaces_pkey PRIMARY KEY (id); +CREATE INDEX idx_agent_stats_created_at ON agent_stats USING btree (created_at); + +CREATE INDEX idx_agent_stats_user_id ON agent_stats USING btree (user_id); + CREATE INDEX idx_api_keys_user ON api_keys USING btree (user_id); CREATE INDEX idx_audit_log_organization_id ON audit_logs USING btree (organization_id); diff --git a/coderd/database/migrations/000042_agent_stats.down.sql b/coderd/database/migrations/000042_agent_stats.down.sql new file mode 100644 index 0000000000000..6787ab5f6b2bb --- /dev/null +++ b/coderd/database/migrations/000042_agent_stats.down.sql @@ -0,0 +1 @@ +DROP TABLE agent_stats; diff --git a/coderd/database/migrations/000042_agent_stats.up.sql b/coderd/database/migrations/000042_agent_stats.up.sql new file mode 100644 index 0000000000000..97420caab6caf --- /dev/null +++ b/coderd/database/migrations/000042_agent_stats.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE agent_stats ( + id uuid NOT NULL, + PRIMARY KEY (id), + created_at timestamptz NOT NULL, + user_id uuid NOT NULL, + agent_id uuid NOT NULL, + workspace_id uuid NOT NULL, + template_id uuid NOT NULL, + payload jsonb NOT NULL +); + +-- We use created_at for DAU analysis and pruning. +CREATE INDEX idx_agent_stats_created_at ON agent_stats USING btree (created_at); + +-- We perform user grouping to analyze DAUs. +CREATE INDEX idx_agent_stats_user_id ON agent_stats USING btree (user_id); diff --git a/coderd/database/models.go b/coderd/database/models.go index 4a58c476ce1d9..0a56fe560c15f 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -324,6 +324,16 @@ type APIKey struct { IPAddress pqtype.Inet `db:"ip_address" json:"ip_address"` } +type AgentStat struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Payload json.RawMessage `db:"payload" json:"payload"` +} + type AuditLog struct { ID uuid.UUID `db:"id" json:"id"` Time time.Time `db:"time" json:"time"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 1d817ee2af5b3..12400d3924c81 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -22,6 +22,7 @@ type querier interface { DeleteAPIKeyByID(ctx context.Context, id string) error DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error DeleteLicense(ctx context.Context, id int32) (int32, error) + DeleteOldAgentStats(ctx context.Context) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error GetAPIKeyByID(ctx context.Context, id string) (APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) @@ -57,6 +58,7 @@ type querier interface { GetProvisionerLogsByIDBetween(ctx context.Context, arg GetProvisionerLogsByIDBetweenParams) ([]ProvisionerJobLog, error) GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error) GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error) + GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error) GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error) GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error) @@ -99,6 +101,7 @@ type querier interface { GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]Workspace, error) GetWorkspacesAutostart(ctx context.Context) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) + InsertAgentStat(ctx context.Context, arg InsertAgentStatParams) (AgentStat, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) InsertDeploymentID(ctx context.Context, value string) error InsertFile(ctx context.Context, arg InsertFileParams) (File, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 662327a0722ff..76f42cef55e46 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -15,6 +15,104 @@ import ( "github.com/tabbed/pqtype" ) +const deleteOldAgentStats = `-- name: DeleteOldAgentStats :exec +DELETE FROM AGENT_STATS WHERE created_at < now() - interval '30 days' +` + +func (q *sqlQuerier) DeleteOldAgentStats(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteOldAgentStats) + return err +} + +const getTemplateDAUs = `-- name: GetTemplateDAUs :many +select + (created_at at TIME ZONE 'UTC')::date as date, + count(distinct(user_id)) as amount +from + agent_stats +where template_id = $1 +group by + date +order by + date asc +` + +type GetTemplateDAUsRow struct { + Date time.Time `db:"date" json:"date"` + Amount int64 `db:"amount" json:"amount"` +} + +func (q *sqlQuerier) GetTemplateDAUs(ctx context.Context, templateID uuid.UUID) ([]GetTemplateDAUsRow, error) { + rows, err := q.db.QueryContext(ctx, getTemplateDAUs, templateID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTemplateDAUsRow + for rows.Next() { + var i GetTemplateDAUsRow + if err := rows.Scan(&i.Date, &i.Amount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertAgentStat = `-- name: InsertAgentStat :one +INSERT INTO + agent_stats ( + id, + created_at, + user_id, + workspace_id, + template_id, + agent_id, + payload + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, user_id, agent_id, workspace_id, template_id, payload +` + +type InsertAgentStatParams struct { + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + WorkspaceID uuid.UUID `db:"workspace_id" json:"workspace_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + AgentID uuid.UUID `db:"agent_id" json:"agent_id"` + Payload json.RawMessage `db:"payload" json:"payload"` +} + +func (q *sqlQuerier) InsertAgentStat(ctx context.Context, arg InsertAgentStatParams) (AgentStat, error) { + row := q.db.QueryRowContext(ctx, insertAgentStat, + arg.ID, + arg.CreatedAt, + arg.UserID, + arg.WorkspaceID, + arg.TemplateID, + arg.AgentID, + arg.Payload, + ) + var i AgentStat + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UserID, + &i.AgentID, + &i.WorkspaceID, + &i.TemplateID, + &i.Payload, + ) + return i, err +} + const deleteAPIKeyByID = `-- name: DeleteAPIKeyByID :exec DELETE FROM diff --git a/coderd/database/queries/agentstats.sql b/coderd/database/queries/agentstats.sql new file mode 100644 index 0000000000000..ca500b7aa0a14 --- /dev/null +++ b/coderd/database/queries/agentstats.sql @@ -0,0 +1,28 @@ +-- name: InsertAgentStat :one +INSERT INTO + agent_stats ( + id, + created_at, + user_id, + workspace_id, + template_id, + agent_id, + payload + ) +VALUES + ($1, $2, $3, $4, $5, $6, $7) RETURNING *; + +-- name: GetTemplateDAUs :many +select + (created_at at TIME ZONE 'UTC')::date as date, + count(distinct(user_id)) as amount +from + agent_stats +where template_id = $1 +group by + date +order by + date asc; + +-- name: DeleteOldAgentStats :exec +DELETE FROM AGENT_STATS WHERE created_at < now() - interval '30 days'; diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go new file mode 100644 index 0000000000000..056bcdb80bae3 --- /dev/null +++ b/coderd/metricscache/metricscache.go @@ -0,0 +1,172 @@ +package metricscache + +import ( + "context" + "sync/atomic" + "time" + + "golang.org/x/xerrors" + + "github.com/google/uuid" + + "cdr.dev/slog" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" + "github.com/coder/retry" +) + +// Cache holds the template DAU cache. +// The aggregation queries responsible for these values can take up to a minute +// on large deployments. Even in small deployments, aggregation queries can +// take a few hundred milliseconds, which would ruin page load times and +// database performance if in the hot path. +type Cache struct { + database database.Store + log slog.Logger + + templateDAUResponses atomic.Pointer[map[string]codersdk.TemplateDAUsResponse] + + doneCh chan struct{} + cancel func() + + interval time.Duration +} + +func New(db database.Store, log slog.Logger, interval time.Duration) *Cache { + if interval <= 0 { + interval = time.Hour + } + ctx, cancel := context.WithCancel(context.Background()) + + c := &Cache{ + database: db, + log: log, + doneCh: make(chan struct{}), + cancel: cancel, + interval: interval, + } + go c.run(ctx) + return c +} + +func fillEmptyDays(rows []database.GetTemplateDAUsRow) []database.GetTemplateDAUsRow { + var newRows []database.GetTemplateDAUsRow + + for i, row := range rows { + if i == 0 { + newRows = append(newRows, row) + continue + } + + last := rows[i-1] + + const day = time.Hour * 24 + diff := row.Date.Sub(last.Date) + for diff > day { + if diff <= day { + break + } + last.Date = last.Date.Add(day) + last.Amount = 0 + newRows = append(newRows, last) + diff -= day + } + + newRows = append(newRows, row) + continue + } + + return newRows +} + +func (c *Cache) refresh(ctx context.Context) error { + err := c.database.DeleteOldAgentStats(ctx) + if err != nil { + return xerrors.Errorf("delete old stats: %w", err) + } + + templates, err := c.database.GetTemplates(ctx) + if err != nil { + return err + } + + templateDAUs := make(map[string]codersdk.TemplateDAUsResponse, len(templates)) + + for _, template := range templates { + daus, err := c.database.GetTemplateDAUs(ctx, template.ID) + if err != nil { + return err + } + + var resp codersdk.TemplateDAUsResponse + for _, ent := range fillEmptyDays(daus) { + resp.Entries = append(resp.Entries, codersdk.DAUEntry{ + Date: ent.Date, + Amount: int(ent.Amount), + }) + } + templateDAUs[template.ID.String()] = resp + } + + c.templateDAUResponses.Store(&templateDAUs) + return nil +} + +func (c *Cache) run(ctx context.Context) { + defer close(c.doneCh) + + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + + for { + for r := retry.New(time.Millisecond*100, time.Minute); r.Wait(ctx); { + start := time.Now() + err := c.refresh(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + c.log.Error(ctx, "refresh", slog.Error(err)) + continue + } + c.log.Debug( + ctx, + "metrics refreshed", + slog.F("took", time.Since(start)), + slog.F("interval", c.interval), + ) + break + } + + select { + case <-ticker.C: + case <-c.doneCh: + return + case <-ctx.Done(): + return + } + } +} + +func (c *Cache) Close() error { + c.cancel() + <-c.doneCh + return nil +} + +// TemplateDAUs returns an empty response if the template doesn't have users +// or is loading for the first time. +func (c *Cache) TemplateDAUs(id uuid.UUID) codersdk.TemplateDAUsResponse { + m := c.templateDAUResponses.Load() + if m == nil { + // Data loading. + return codersdk.TemplateDAUsResponse{} + } + + resp, ok := (*m)[id.String()] + if !ok { + // Probably no data. + return codersdk.TemplateDAUsResponse{} + } + return resp +} diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go new file mode 100644 index 0000000000000..8111bcfcc3481 --- /dev/null +++ b/coderd/metricscache/metricscache_test.go @@ -0,0 +1,185 @@ +package metricscache_test + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/metricscache" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/testutil" +) + +func date(year, month, day int) time.Time { + return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) +} + +func TestCache(t *testing.T) { + t.Parallel() + + var ( + zebra = uuid.New() + tiger = uuid.New() + ) + + type args struct { + rows []database.InsertAgentStatParams + } + tests := []struct { + name string + args args + want []codersdk.DAUEntry + }{ + {"empty", args{}, nil}, + {"one hole", args{ + rows: []database.InsertAgentStatParams{ + { + CreatedAt: date(2022, 8, 27), + UserID: zebra, + }, + { + CreatedAt: date(2022, 8, 30), + UserID: zebra, + }, + }, + }, []codersdk.DAUEntry{ + { + Date: date(2022, 8, 27), + Amount: 1, + }, + { + Date: date(2022, 8, 28), + Amount: 0, + }, + { + Date: date(2022, 8, 29), + Amount: 0, + }, + { + Date: date(2022, 8, 30), + Amount: 1, + }, + }}, + {"no holes", args{ + rows: []database.InsertAgentStatParams{ + { + CreatedAt: date(2022, 8, 27), + UserID: zebra, + }, + { + CreatedAt: date(2022, 8, 28), + UserID: zebra, + }, + { + CreatedAt: date(2022, 8, 29), + UserID: zebra, + }, + }, + }, []codersdk.DAUEntry{ + { + Date: date(2022, 8, 27), + Amount: 1, + }, + { + Date: date(2022, 8, 28), + Amount: 1, + }, + { + Date: date(2022, 8, 29), + Amount: 1, + }, + }}, + {"holes", args{ + rows: []database.InsertAgentStatParams{ + { + CreatedAt: date(2022, 1, 1), + UserID: zebra, + }, + { + CreatedAt: date(2022, 1, 1), + UserID: tiger, + }, + { + CreatedAt: date(2022, 1, 4), + UserID: zebra, + }, + { + CreatedAt: date(2022, 1, 7), + UserID: zebra, + }, + { + CreatedAt: date(2022, 1, 7), + UserID: tiger, + }, + }, + }, []codersdk.DAUEntry{ + { + Date: date(2022, 1, 1), + Amount: 2, + }, + { + Date: date(2022, 1, 2), + Amount: 0, + }, + { + Date: date(2022, 1, 3), + Amount: 0, + }, + { + Date: date(2022, 1, 4), + Amount: 1, + }, + { + Date: date(2022, 1, 5), + Amount: 0, + }, + { + Date: date(2022, 1, 6), + Amount: 0, + }, + { + Date: date(2022, 1, 7), + Amount: 2, + }, + }}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var ( + db = databasefake.New() + cache = metricscache.New(db, slogtest.Make(t, nil), time.Millisecond*100) + ) + + defer cache.Close() + + templateID := uuid.New() + db.InsertTemplate(context.Background(), database.InsertTemplateParams{ + ID: templateID, + }) + + for _, row := range tt.args.rows { + row.TemplateID = templateID + db.InsertAgentStat(context.Background(), row) + } + + var got codersdk.TemplateDAUsResponse + + require.Eventuallyf(t, func() bool { + got = cache.TemplateDAUs(templateID) + return reflect.DeepEqual(got.Entries, tt.want) + }, testutil.WaitShort, testutil.IntervalFast, + "GetDAUs() = %v, want %v", got, tt.want, + ) + }) + } +} diff --git a/coderd/provisionerjobs_internal_test.go b/coderd/provisionerjobs_internal_test.go index 80a94f88fcb1c..67004661d9583 100644 --- a/coderd/provisionerjobs_internal_test.go +++ b/coderd/provisionerjobs_internal_test.go @@ -39,6 +39,8 @@ func TestProvisionerJobLogs_Unit(t *testing.T) { Pubsub: fPubsub, } api := New(&opts) + defer api.Close() + server := httptest.NewServer(api.Handler) defer server.Close() userID := uuid.New() diff --git a/coderd/templates.go b/coderd/templates.go index 4fc47288ff6c5..64b10ca83f1d2 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -517,6 +517,20 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { httpapi.Write(rw, http.StatusOK, convertTemplate(updated, count, createdByNameMap[updated.ID.String()])) } +func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) { + template := httpmw.TemplateParam(r) + if !api.Authorize(r, rbac.ActionRead, template) { + httpapi.ResourceNotFound(rw) + return + } + + resp := api.metricsCache.TemplateDAUs(template.ID) + if resp.Entries == nil { + resp.Entries = []codersdk.DAUEntry{} + } + httpapi.Write(rw, http.StatusOK, resp) +} + type autoImportTemplateOpts struct { name string archive []byte diff --git a/coderd/templates_test.go b/coderd/templates_test.go index b05dd25024f92..ae1976c6472ef 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -10,10 +10,16 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" + "github.com/coder/coder/peer" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/testutil" ) @@ -539,3 +545,100 @@ func TestDeleteTemplate(t *testing.T) { require.Equal(t, http.StatusPreconditionFailed, apiErr.StatusCode()) }) } + +func TestTemplateDAUs(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerD: true, + }) + + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionDryRun: echo.ProvisionComplete, + Provision: []*proto.Provision_Response{{ + Type: &proto.Provision_Response_Complete{ + Complete: &proto.Provision_Complete{ + Resources: []*proto.Resource{{ + Name: "example", + Type: "aws_instance", + Agents: []*proto.Agent{{ + Id: uuid.NewString(), + Auth: &proto.Agent_Token{ + Token: authToken, + }, + }}, + }}, + }, + }, + }}, + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + + agentClient := codersdk.New(client.URL) + agentClient.SessionToken = authToken + agentCloser := agent.New(agent.Options{ + Logger: slogtest.Make(t, nil), + StatsReporter: agentClient.AgentReportStats, + WebRTCDialer: agentClient.ListenWorkspaceAgent, + FetchMetadata: agentClient.WorkspaceAgentMetadata, + CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet, + }) + defer func() { + _ = agentCloser.Close() + }() + resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + opts := &peer.ConnOptions{ + Logger: slogtest.Make(t, nil).Named("client"), + } + + daus, err := client.TemplateDAUs(context.Background(), template.ID) + require.NoError(t, err) + + require.Equal(t, &codersdk.TemplateDAUsResponse{ + Entries: []codersdk.DAUEntry{}, + }, daus, "no DAUs when stats are empty") + + conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, opts) + require.NoError(t, err) + defer func() { + _ = conn.Close() + }() + + sshConn, err := conn.SSHClient() + require.NoError(t, err) + + session, err := sshConn.NewSession() + require.NoError(t, err) + + _, err = session.Output("echo hello") + require.NoError(t, err) + + want := &codersdk.TemplateDAUsResponse{ + Entries: []codersdk.DAUEntry{ + { + + Date: time.Now().UTC().Truncate(time.Hour * 24), + Amount: 1, + }, + }, + } + require.Eventuallyf(t, func() bool { + daus, err = client.TemplateDAUs(ctx, template.ID) + require.NoError(t, err) + + return assert.ObjectsAreEqual(want, daus) + }, + testutil.WaitShort, testutil.IntervalFast, + "got %+v != %+v", daus, want, + ) +} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 5ed39af446de1..ebedb84ae1939 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/netip" + "reflect" "strconv" "strings" "time" @@ -18,6 +19,7 @@ import ( "golang.org/x/mod/semver" "golang.org/x/xerrors" "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" "tailscale.com/tailcfg" "cdr.dev/slog" @@ -739,6 +741,130 @@ func convertWorkspaceAgent(derpMap *tailcfg.DERPMap, coordinator *tailnet.Coordi return workspaceAgent, nil } +func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Request) { + api.websocketWaitMutex.Lock() + api.websocketWaitGroup.Add(1) + api.websocketWaitMutex.Unlock() + defer api.websocketWaitGroup.Done() + + workspaceAgent := httpmw.WorkspaceAgent(r) + resource, err := api.Database.GetWorkspaceResourceByID(r.Context(), workspaceAgent.ResourceID) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get workspace resource.", + Detail: err.Error(), + }) + return + } + + build, err := api.Database.GetWorkspaceBuildByJobID(r.Context(), resource.JobID) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get build.", + Detail: err.Error(), + }) + return + } + + workspace, err := api.Database.GetWorkspaceByID(r.Context(), build.WorkspaceID) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to get workspace.", + Detail: err.Error(), + }) + return + } + + conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to accept websocket.", + Detail: err.Error(), + }) + return + } + defer conn.Close(websocket.StatusAbnormalClosure, "") + + // Allow overriding the stat interval for debugging and testing purposes. + ctx := r.Context() + timer := time.NewTicker(api.AgentStatsRefreshInterval) + var lastReport codersdk.AgentStatsReportResponse + for { + err := wsjson.Write(ctx, conn, codersdk.AgentStatsReportRequest{}) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to write report request.", + Detail: err.Error(), + }) + return + } + var rep codersdk.AgentStatsReportResponse + + err = wsjson.Read(ctx, conn, &rep) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to read report response.", + Detail: err.Error(), + }) + return + } + + repJSON, err := json.Marshal(rep) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to marshal stat json.", + Detail: err.Error(), + }) + return + } + + // Avoid inserting duplicate rows to preserve DB space. + // We will see duplicate reports when on idle connections + // (e.g. web terminal left open) or when there are no connections at + // all. + var insert = !reflect.DeepEqual(lastReport, rep) + + api.Logger.Debug(ctx, "read stats report", + slog.F("interval", api.AgentStatsRefreshInterval), + slog.F("agent", workspaceAgent.ID), + slog.F("resource", resource.ID), + slog.F("workspace", workspace.ID), + slog.F("insert", insert), + slog.F("payload", rep), + ) + + if insert { + lastReport = rep + + _, err = api.Database.InsertAgentStat(ctx, database.InsertAgentStatParams{ + ID: uuid.New(), + CreatedAt: time.Now(), + AgentID: workspaceAgent.ID, + WorkspaceID: build.WorkspaceID, + UserID: workspace.OwnerID, + TemplateID: workspace.TemplateID, + Payload: json.RawMessage(repJSON), + }) + if err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: "Failed to insert agent stat.", + Detail: err.Error(), + }) + return + } + } + + select { + case <-timer.C: + continue + case <-ctx.Done(): + conn.Close(websocket.StatusNormalClosure, "") + return + } + } +} // wsNetConn wraps net.Conn created by websocket.NetConn(). Cancel func // is called if a read or write error is encountered. diff --git a/codersdk/client.go b/codersdk/client.go index bbeaa990da81b..7bc28616c3ecc 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -218,3 +218,9 @@ func (e *Error) Error() string { } return builder.String() } + +type closeFunc func() error + +func (c closeFunc) Close() error { + return c() +} diff --git a/codersdk/templates.go b/codersdk/templates.go index 38ec2b4ce6d99..dc1240fe7484c 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -133,3 +133,43 @@ func (c *Client) TemplateVersionByName(ctx context.Context, template uuid.UUID, var templateVersion TemplateVersion return templateVersion, json.NewDecoder(res.Body).Decode(&templateVersion) } + +type DAUEntry struct { + Date time.Time `json:"date"` + Amount int `json:"amount"` +} + +type TemplateDAUsResponse struct { + Entries []DAUEntry `json:"entries"` +} + +func (c *Client) TemplateDAUs(ctx context.Context, templateID uuid.UUID) (*TemplateDAUsResponse, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/daus", templateID), nil) + if err != nil { + return nil, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + + var resp TemplateDAUsResponse + return &resp, json.NewDecoder(res.Body).Decode(&resp) +} + +// AgentStatsReportRequest is a WebSocket request by coderd +// to the agent for stats. +// @typescript-ignore AgentStatsReportRequest +type AgentStatsReportRequest struct { +} + +// AgentStatsReportResponse is returned for each report +// request by the agent. +type AgentStatsReportResponse struct { + NumConns int64 `json:"num_comms"` + // RxBytes is the number of received bytes. + RxBytes int64 `json:"rx_bytes"` + // TxBytes is the number of received bytes. + TxBytes int64 `json:"tx_bytes"` +} diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 816d4ea4a01e9..021c4bcf77865 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -19,6 +19,7 @@ import ( "golang.org/x/net/proxy" "golang.org/x/xerrors" "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" "tailscale.com/tailcfg" "cdr.dev/slog" @@ -528,3 +529,87 @@ func (c *Client) turnProxyDialer(ctx context.Context, httpClient *http.Client, p return websocket.NetConn(ctx, conn, websocket.MessageBinary), nil }) } + +// AgentReportStats begins a stat streaming connection with the Coder server. +// It is resilient to network failures and intermittent coderd issues. +func (c *Client) AgentReportStats( + ctx context.Context, + log slog.Logger, + stats func() *agent.Stats, +) (io.Closer, error) { + serverURL, err := c.URL.Parse("/api/v2/workspaceagents/me/report-stats") + if err != nil { + return nil, xerrors.Errorf("parse url: %w", err) + } + + jar, err := cookiejar.New(nil) + if err != nil { + return nil, xerrors.Errorf("create cookie jar: %w", err) + } + + jar.SetCookies(serverURL, []*http.Cookie{{ + Name: SessionTokenKey, + Value: c.SessionToken, + }}) + + httpClient := &http.Client{ + Jar: jar, + } + + doneCh := make(chan struct{}) + ctx, cancel := context.WithCancel(ctx) + + go func() { + defer close(doneCh) + + // If the agent connection succeeds for a while, then fails, then succeeds + // for a while (etc.) the retry may hit the maximum. This is a normal + // case for long-running agents that experience coderd upgrades, so + // we use a short maximum retry limit. + for r := retry.New(time.Second, time.Minute); r.Wait(ctx); { + err = func() error { + conn, res, err := websocket.Dial(ctx, serverURL.String(), &websocket.DialOptions{ + HTTPClient: httpClient, + // Need to disable compression to avoid a data-race. + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + if res == nil { + return err + } + return readBodyAsError(res) + } + + for { + var req AgentStatsReportRequest + err := wsjson.Read(ctx, conn, &req) + if err != nil { + return err + } + + s := stats() + + resp := AgentStatsReportResponse{ + NumConns: s.NumConns, + RxBytes: s.RxBytes, + TxBytes: s.TxBytes, + } + + err = wsjson.Write(ctx, conn, resp) + if err != nil { + return err + } + } + }() + if err != nil && ctx.Err() == nil { + log.Error(ctx, "report stats", slog.Error(err)) + } + } + }() + + return closeFunc(func() error { + cancel() + <-doneCh + return nil + }), nil +} diff --git a/dogfood/Dockerfile b/dogfood/Dockerfile index 7a86904a725b9..c63dba5ffaaef 100644 --- a/dogfood/Dockerfile +++ b/dogfood/Dockerfile @@ -70,6 +70,12 @@ RUN mkdir --parents "$GOPATH" && \ # nfpm is used with `make build` to make release packages go install github.com/goreleaser/nfpm/v2/cmd/nfpm@v2.16.0 +FROM alpine:3.16 as proto +WORKDIR /tmp +RUN apk add curl unzip +RUN curl -L -o protoc.zip https://github.com/protocolbuffers/protobuf/releases/download/v21.5/protoc-21.5-linux-x86_64.zip +RUN unzip protoc.zip + # Ubuntu 20.04 LTS (Focal Fossa) FROM ubuntu:focal @@ -119,7 +125,6 @@ RUN apt-get update --quiet && apt-get install --yes \ openssh-server \ openssl \ pkg-config \ - protobuf-compiler \ python3 \ python3-pip \ rsync \ @@ -156,6 +161,7 @@ RUN apt-get update --quiet && apt-get install --yes \ skopeo \ fish \ gh \ + unzip \ zstd && \ # Delete package cache to avoid consuming space in layer apt-get clean && \ @@ -300,6 +306,7 @@ RUN update-alternatives --install /usr/local/bin/gofmt gofmt /usr/local/go/bin/g COPY --from=go /tmp/bin /usr/local/bin COPY --from=rust-utils /tmp/bin /usr/local/bin +COPY --from=proto /tmp/bin /usr/local/bin USER coder diff --git a/peerbroker/proto/peerbroker.pb.go b/peerbroker/proto/peerbroker.pb.go index 498d250a14149..d4e09f44be118 100644 --- a/peerbroker/proto/peerbroker.pb.go +++ b/peerbroker/proto/peerbroker.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.20.0 +// protoc v3.21.5 // source: peerbroker/proto/peerbroker.proto package proto diff --git a/provisionerd/proto/provisionerd.pb.go b/provisionerd/proto/provisionerd.pb.go index 85aeaf79b38f8..7a42fd40feb08 100644 --- a/provisionerd/proto/provisionerd.pb.go +++ b/provisionerd/proto/provisionerd.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.20.0 +// protoc v3.21.5 // source: provisionerd/proto/provisionerd.proto package proto diff --git a/provisionersdk/proto/provisioner.pb.go b/provisionersdk/proto/provisioner.pb.go index fb2c9024a06c4..836426f24b3f1 100644 --- a/provisionersdk/proto/provisioner.pb.go +++ b/provisionersdk/proto/provisioner.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.26.0 -// protoc v3.20.0 +// protoc v3.21.5 // source: provisionersdk/proto/provisioner.proto package proto diff --git a/site/package.json b/site/package.json index 55d14799d1e39..18984d038f25d 100644 --- a/site/package.json +++ b/site/package.json @@ -38,6 +38,7 @@ "@xstate/react": "3.0.1", "axios": "0.26.1", "can-ndjson-stream": "1.0.2", + "chart.js": "^3.5.0", "cron-parser": "4.5.0", "cronstrue": "2.11.0", "dayjs": "1.11.4", @@ -46,13 +47,14 @@ "front-matter": "4.0.2", "history": "5.3.0", "i18next": "21.9.1", - "just-debounce-it": "3.1.1", + "just-debounce-it": "3.0.1", "react": "18.2.0", + "react-chartjs-2": "^4.3.1", "react-dom": "18.2.0", "react-helmet-async": "1.3.0", "react-i18next": "11.18.4", "react-markdown": "8.0.3", - "react-router-dom": "6.3.0", + "react-router-dom": "^6.3.0", "sourcemapped-stacktrace": "1.1.11", "swr": "1.3.0", "tzdata": "1.0.30", @@ -87,6 +89,7 @@ "@typescript-eslint/eslint-plugin": "5.31.0", "@typescript-eslint/parser": "5.31.0", "@xstate/cli": "0.3.0", + "canvas": "^2.9.3", "chromatic": "6.7.1", "copy-webpack-plugin": "10.2.4", "css-loader": "6.7.1", @@ -113,6 +116,7 @@ "prettier": "2.7.1", "prettier-plugin-organize-imports": "3.0.0", "react-hot-loader": "4.13.0", + "resize-observer": "^1.0.4", "semver": "^7.3.7", "sql-formatter": "8.2.0", "style-loader": "3.3.1", @@ -123,7 +127,7 @@ "webpack": "5.74.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.10.0", - "webpack-dev-server": "4.10.1" + "webpack-dev-server": "4.9.3" }, "browserslist": [ "chrome 66", diff --git a/site/src/api/api.ts b/site/src/api/api.ts index b8f84ce598a03..3544772e3fd15 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -388,3 +388,10 @@ export const getEntitlements = async (): Promise => { const response = await axios.get("/api/v2/entitlements") return response.data } + +export const getTemplateDAUs = async ( + templateId: string, +): Promise => { + const response = await axios.get(`/api/v2/templates/${templateId}/daus`) + return response.data +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index fe1d809f77ea7..5102485e11bba 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -29,6 +29,13 @@ export interface AgentGitSSHKey { readonly private_key: string } +// From codersdk/templates.go +export interface AgentStatsReportResponse { + readonly num_comms: number + readonly rx_bytes: number + readonly tx_bytes: number +} + // From codersdk/roles.go export interface AssignableRoles extends Role { readonly assignable: boolean @@ -175,6 +182,12 @@ export interface CreateWorkspaceRequest { readonly parameter_values?: CreateParameterRequest[] } +// From codersdk/templates.go +export interface DAUEntry { + readonly date: string + readonly amount: number +} + // From codersdk/workspaceresources.go export interface DERPRegion { readonly preferred: boolean @@ -362,6 +375,11 @@ export interface Template { readonly created_by_name: string } +// From codersdk/templates.go +export interface TemplateDAUsResponse { + readonly entries: DAUEntry[] +} + // From codersdk/templateversions.go export interface TemplateVersion { readonly id: string diff --git a/site/src/pages/TemplatePage/DAUChart.test.tsx b/site/src/pages/TemplatePage/DAUChart.test.tsx new file mode 100644 index 0000000000000..c9d20e3fae057 --- /dev/null +++ b/site/src/pages/TemplatePage/DAUChart.test.tsx @@ -0,0 +1,35 @@ +import { render } from "testHelpers/renderHelpers" +import { DAUChart, Language } from "./DAUChart" + +import { screen } from "@testing-library/react" +import { ResizeObserver } from "resize-observer" + +// The Chart performs dynamic resizes which fail in tests without this. +Object.defineProperty(window, "ResizeObserver", { + value: ResizeObserver, +}) + +describe("DAUChart", () => { + it("renders a helpful paragraph on empty state", async () => { + render( + , + ) + + await screen.findAllByText(Language.loadingText) + }) + it("renders a graph", async () => { + render( + , + ) + + await screen.findAllByText(Language.chartTitle) + }) +}) diff --git a/site/src/pages/TemplatePage/DAUChart.tsx b/site/src/pages/TemplatePage/DAUChart.tsx new file mode 100644 index 0000000000000..5e12717b75d79 --- /dev/null +++ b/site/src/pages/TemplatePage/DAUChart.tsx @@ -0,0 +1,123 @@ +import useTheme from "@material-ui/styles/useTheme" + +import { Theme } from "@material-ui/core/styles" +import { + BarElement, + CategoryScale, + Chart as ChartJS, + ChartOptions, + defaults, + Legend, + LinearScale, + LineElement, + PointElement, + Title, + Tooltip, +} from "chart.js" +import { Stack } from "components/Stack/Stack" +import { HelpTooltip, HelpTooltipText, HelpTooltipTitle } from "components/Tooltips/HelpTooltip" +import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection" +import dayjs from "dayjs" +import { FC } from "react" +import { Line } from "react-chartjs-2" +import * as TypesGen from "../../api/typesGenerated" + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + BarElement, + LineElement, + Title, + Tooltip, + Legend, +) + +export interface DAUChartProps { + templateDAUs: TypesGen.TemplateDAUsResponse +} +export const Language = { + loadingText: "DAU stats are loading. Check back later.", + chartTitle: "Daily Active Users", +} + +export const DAUChart: FC = ({ templateDAUs: templateMetricsData }) => { + const theme: Theme = useTheme() + + if (templateMetricsData.entries.length === 0) { + return ( + // We generate hidden element to prove this path is taken in the test + // and through site inspection. +
+

{Language.loadingText}

+
+ ) + } + + const labels = templateMetricsData.entries.map((val) => { + return dayjs(val.date).format("YYYY-MM-DD") + }) + + const data = templateMetricsData.entries.map((val) => { + return val.amount + }) + + defaults.font.family = theme.typography.fontFamily + defaults.color = theme.palette.text.secondary + + const options = { + responsive: true, + plugins: { + legend: { + display: false, + }, + }, + scales: { + y: { + min: 0, + ticks: { + precision: 0, + }, + }, + x: { + ticks: {}, + }, + }, + aspectRatio: 10 / 1, + } as ChartOptions + + return ( + <> + + +

{Language.chartTitle}

+ + How do we calculate DAUs? + + We use all workspace connection traffic to calculate DAUs. + + +
+ +
+ + ) +} diff --git a/site/src/pages/TemplatePage/TemplatePage.test.tsx b/site/src/pages/TemplatePage/TemplatePage.test.tsx index 87099a927843a..d5fb901fe0b2b 100644 --- a/site/src/pages/TemplatePage/TemplatePage.test.tsx +++ b/site/src/pages/TemplatePage/TemplatePage.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, screen } from "@testing-library/react" import { rest } from "msw" +import { ResizeObserver } from "resize-observer" import { server } from "testHelpers/server" import * as CreateDayString from "util/createDayString" import { @@ -12,6 +13,10 @@ import { } from "../../testHelpers/renderHelpers" import { TemplatePage } from "./TemplatePage" +Object.defineProperty(window, "ResizeObserver", { + value: ResizeObserver, +}) + describe("TemplatePage", () => { it("shows the template name, readme and resources", async () => { // Mocking the dayjs module within the createDayString file diff --git a/site/src/pages/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatePage/TemplatePage.tsx index 0efa91bbf3da8..91abc650b8bea 100644 --- a/site/src/pages/TemplatePage/TemplatePage.tsx +++ b/site/src/pages/TemplatePage/TemplatePage.tsx @@ -32,16 +32,19 @@ export const TemplatePage: FC> = () => { organizationId, }, }) + const { template, activeTemplateVersion, templateResources, templateVersions, deleteTemplateError, + templateDAUs, } = templateState.context const xServices = useContext(XServiceContext) const permissions = useSelector(xServices.authXService, selectPermissions) - const isLoading = !template || !activeTemplateVersion || !templateResources || !permissions + const isLoading = + !template || !activeTemplateVersion || !templateResources || !permissions || !templateDAUs const handleDeleteTemplate = () => { templateSend("DELETE") @@ -65,6 +68,7 @@ export const TemplatePage: FC> = () => { activeTemplateVersion={activeTemplateVersion} templateResources={templateResources} templateVersions={templateVersions} + templateDAUs={templateDAUs} canDeleteTemplate={permissions.deleteTemplates} handleDeleteTemplate={handleDeleteTemplate} deleteTemplateError={deleteTemplateError} diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx index 08cb829c27bb4..e50d11c66a57e 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePageView.tsx @@ -12,7 +12,12 @@ import { FC } from "react" import ReactMarkdown from "react-markdown" import { Link as RouterLink } from "react-router-dom" import { firstLetter } from "util/firstLetter" -import { Template, TemplateVersion, WorkspaceResource } from "../../api/typesGenerated" +import { + Template, + TemplateDAUsResponse, + TemplateVersion, + WorkspaceResource, +} from "../../api/typesGenerated" import { Margins } from "../../components/Margins/Margins" import { PageHeader, @@ -24,6 +29,7 @@ import { TemplateResourcesTable } from "../../components/TemplateResourcesTable/ import { TemplateStats } from "../../components/TemplateStats/TemplateStats" import { VersionsTable } from "../../components/VersionsTable/VersionsTable" import { WorkspaceSection } from "../../components/WorkspaceSection/WorkspaceSection" +import { DAUChart } from "./DAUChart" const Language = { settingsButton: "Settings", @@ -39,6 +45,7 @@ export interface TemplatePageViewProps { activeTemplateVersion: TemplateVersion templateResources: WorkspaceResource[] templateVersions?: TemplateVersion[] + templateDAUs?: TemplateDAUsResponse handleDeleteTemplate: (templateId: string) => void deleteTemplateError: Error | unknown canDeleteTemplate: boolean @@ -49,6 +56,7 @@ export const TemplatePageView: FC activeTemplateVersion, templateResources, templateVersions, + templateDAUs, handleDeleteTemplate, deleteTemplateError, canDeleteTemplate, @@ -131,6 +139,7 @@ export const TemplatePageView: FC {deleteError} + {templateDAUs && } > = ({ {Language.pageTitle} - +
+ +
{ + return res(ctx.status(200), ctx.json(M.MockTemplateDAUResponse)) + }), + // build info rest.get("/api/v2/buildinfo", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockBuildInfo)) diff --git a/site/src/xServices/template/templateXService.ts b/site/src/xServices/template/templateXService.ts index 666b4e31c28f5..d9944d382ed4b 100644 --- a/site/src/xServices/template/templateXService.ts +++ b/site/src/xServices/template/templateXService.ts @@ -4,11 +4,17 @@ import { assign, createMachine } from "xstate" import { deleteTemplate, getTemplateByName, + getTemplateDAUs, getTemplateVersion, getTemplateVersionResources, getTemplateVersions, } from "../../api/api" -import { Template, TemplateVersion, WorkspaceResource } from "../../api/typesGenerated" +import { + Template, + TemplateDAUsResponse, + TemplateVersion, + WorkspaceResource, +} from "../../api/typesGenerated" interface TemplateContext { organizationId: string @@ -17,6 +23,7 @@ interface TemplateContext { activeTemplateVersion?: TemplateVersion templateResources?: WorkspaceResource[] templateVersions?: TemplateVersion[] + templateDAUs: TemplateDAUsResponse deleteTemplateError?: Error | unknown } @@ -46,6 +53,9 @@ export const templateMachine = deleteTemplate: { data: Template } + getTemplateDAUs: { + data: TemplateDAUsResponse + } }, }, id: "(machine)", @@ -122,6 +132,25 @@ export const templateMachine = }, }, }, + templateDAUs: { + initial: "gettingTemplateDAUs", + states: { + gettingTemplateDAUs: { + invoke: { + src: "getTemplateDAUs", + onDone: [ + { + actions: "assignTemplateDAUs", + target: "success", + }, + ], + }, + }, + success: { + type: "final", + }, + }, + }, }, onDone: { target: "loaded", @@ -133,6 +162,9 @@ export const templateMachine = target: "confirmingDelete", }, }, + onDone: { + target: "loaded", + }, }, confirmingDelete: { on: { @@ -198,6 +230,12 @@ export const templateMachine = } return deleteTemplate(ctx.template.id) }, + getTemplateDAUs: (ctx) => { + if (!ctx.template) { + throw new Error("Template not loaded") + } + return getTemplateDAUs(ctx.template.id) + }, }, actions: { assignTemplate: assign({ @@ -212,6 +250,9 @@ export const templateMachine = assignTemplateVersions: assign({ templateVersions: (_, event) => event.data, }), + assignTemplateDAUs: assign({ + templateDAUs: (_, event) => event.data, + }), assignDeleteTemplateError: assign({ deleteTemplateError: (_, event) => event.data, }), diff --git a/site/yarn.lock b/site/yarn.lock index 1ba38fea9b6a6..b4f6f9541b967 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -18,9 +18,9 @@ "@babel/highlight" "^7.18.6" "@babel/compat-data@^7.17.7", "@babel/compat-data@^7.18.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" - integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.13.tgz#6aff7b350a1e8c3e40b029e46cbe78e24a913483" + integrity sha512-5yUzC5LqyTFp2HLmDoxGQelcdYgSpP9xsnMWBphAscOdFrHSAVbLNzWiy32sVNDqJRDiJK6klfDnAgu6PAGSHw== "@babel/core@7.12.9": version "7.12.9" @@ -45,32 +45,32 @@ source-map "^0.5.0" "@babel/core@^7.1.0", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.7.2", "@babel/core@^7.7.5", "@babel/core@^7.8.0": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.10.tgz#39ad504991d77f1f3da91be0b8b949a5bc466fb8" - integrity sha512-JQM6k6ENcBFKVtWvLavlvi/mPcpYZ3+R+2EySDEMSMbp7Mn4FexlbbJVrx2R7Ijhr01T8gyqrOaABWIOgxeUyw== + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.13.tgz#9be8c44512751b05094a4d3ab05fc53a47ce00ac" + integrity sha512-ZisbOvRRusFktksHSG6pjj1CSvkPkcZq/KHD45LAkVP/oiHJkNBZWfpvlLmX8OtHDG8IuzsFlVRWo08w7Qxn0A== dependencies: "@ampproject/remapping" "^2.1.0" "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.10" + "@babel/generator" "^7.18.13" "@babel/helper-compilation-targets" "^7.18.9" "@babel/helper-module-transforms" "^7.18.9" "@babel/helpers" "^7.18.9" - "@babel/parser" "^7.18.10" + "@babel/parser" "^7.18.13" "@babel/template" "^7.18.10" - "@babel/traverse" "^7.18.10" - "@babel/types" "^7.18.10" + "@babel/traverse" "^7.18.13" + "@babel/types" "^7.18.13" convert-source-map "^1.7.0" debug "^4.1.0" gensync "^1.0.0-beta.2" json5 "^2.2.1" semver "^6.3.0" -"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.18.10", "@babel/generator@^7.7.2": - version "7.18.12" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.12.tgz#fa58daa303757bd6f5e4bbca91b342040463d9f4" - integrity sha512-dfQ8ebCN98SvyL7IxNMCUtZQSq5R7kxgN+r8qYTGDmmSion1hX2C0zq2yo1bsCDhXixokv1SAWTZUMYbO/V5zg== +"@babel/generator@^7.12.11", "@babel/generator@^7.12.5", "@babel/generator@^7.18.13", "@babel/generator@^7.7.2": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.13.tgz#59550cbb9ae79b8def15587bdfbaa388c4abf212" + integrity sha512-CkPg8ySSPuHTYPJYo7IRALdqyjM9HCbt/3uOBEFbzyGVP6Mn8bwFPB0jX6982JVNBlYzM1nnPkfjuXSOPtQeEQ== dependencies: - "@babel/types" "^7.18.10" + "@babel/types" "^7.18.13" "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" @@ -100,9 +100,9 @@ semver "^6.3.0" "@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.9.tgz#d802ee16a64a9e824fcbf0a2ffc92f19d58550ce" - integrity sha512-WvypNAYaVh23QcjpMR24CwZY2Nz6hqdOcFdPbNpV56hL5H6KiFheO7Xm1aPdlLQ7d5emYZX7VZwPp9x3z+2opw== + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.13.tgz#63e771187bd06d234f95fdf8bd5f8b6429de6298" + integrity sha512-hDvXp+QYxSRL+23mpAlSGxHMDyIGChm0/AwTfTAAK5Ufe40nCsyNdaYCGuK91phn/fVu9kqayImRDkvNAgdrsA== dependencies: "@babel/helper-annotate-as-pure" "^7.18.6" "@babel/helper-environment-visitor" "^7.18.9" @@ -303,10 +303,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.11": - version "7.18.11" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.11.tgz#68bb07ab3d380affa9a3f96728df07969645d2d9" - integrity sha512-9JKn5vN+hDt0Hdqn1PiJ2guflwP+B6Ga8qbDuoF0PzzVhrzsKIJo8yGqVk6CmMHiMei9w1C1Bp9IMJSIK+HPIQ== +"@babel/parser@^7.1.0", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.13": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.13.tgz#5b2dd21cae4a2c5145f1fbd8ca103f9313d3b7e4" + integrity sha512-dgXcIfMuQ0kgzLB2b9tRZs7TTFFaGM2AbtA4fJgUUYukzGH4jwsS7hzQHEGs67jdehpm22vkgKwvbU+aEflgwg== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" @@ -694,9 +694,9 @@ "@babel/helper-plugin-utils" "^7.18.9" "@babel/plugin-transform-destructuring@^7.12.1", "@babel/plugin-transform-destructuring@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.9.tgz#68906549c021cb231bee1db21d3b5b095f8ee292" - integrity sha512-p5VCYNddPLkZTq4XymQIaIfZNJwT9YsjkPOhkVEqt6QIpQFZVM9IltqqYpOEkJoN1DPznmxUDyZ5CTZs/ZCuHA== + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.13.tgz#9e03bc4a94475d62b7f4114938e6c5c33372cbf5" + integrity sha512-TodpQ29XekIsex2A+YJPj5ax2plkGa8YYY6mFjCohk/IG9IY42Rtuj1FuDeemfg2ipxIFLzPeA83SIBnlhSIow== dependencies: "@babel/helper-plugin-utils" "^7.18.9" @@ -1101,26 +1101,26 @@ "@babel/parser" "^7.18.10" "@babel/types" "^7.18.10" -"@babel/traverse@^7.1.6", "@babel/traverse@^7.12.11", "@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.18.10", "@babel/traverse@^7.18.11", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2": - version "7.18.11" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.11.tgz#3d51f2afbd83ecf9912bcbb5c4d94e3d2ddaa16f" - integrity sha512-TG9PiM2R/cWCAy6BPJKeHzNbu4lPzOSZpeMfeNErskGpTJx6trEvFaVCbDvpcxwy49BKWmEPwiW8mrysNiDvIQ== +"@babel/traverse@^7.1.6", "@babel/traverse@^7.12.11", "@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.18.11", "@babel/traverse@^7.18.13", "@babel/traverse@^7.18.9", "@babel/traverse@^7.7.2": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.18.13.tgz#5ab59ef51a997b3f10c4587d648b9696b6cb1a68" + integrity sha512-N6kt9X1jRMLPxxxPYWi7tgvJRH/rtoU+dbKAPDM44RFHiMH8igdsaSBgFeskhSl/kLWLDUvIh1RXCrTmg0/zvA== dependencies: "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.10" + "@babel/generator" "^7.18.13" "@babel/helper-environment-visitor" "^7.18.9" "@babel/helper-function-name" "^7.18.9" "@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.18.11" - "@babel/types" "^7.18.10" + "@babel/parser" "^7.18.13" + "@babel/types" "^7.18.13" debug "^4.1.0" globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.12.11", "@babel/types@^7.12.7", "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.10.tgz#4908e81b6b339ca7c6b7a555a5fc29446f26dde6" - integrity sha512-MJvnbEiiNkpjo+LknnmRrqbY1GPUUggjv+wQVjetM/AONoupqRALB7I6jGqNUAZsKcRIEu2J6FRFvsczljjsaQ== +"@babel/types@^7.0.0", "@babel/types@^7.12.11", "@babel/types@^7.12.7", "@babel/types@^7.18.10", "@babel/types@^7.18.13", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.2.0", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4": + version "7.18.13" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.13.tgz#30aeb9e514f4100f7c1cb6e5ba472b30e48f519a" + integrity sha512-ePqfTihzW0W6XAU+aMw2ykilisStJfDnsejDCXRchCcMJ4O0+8DhPXf2YUbZ6wjBlsEmZwLK/sPweWtu8hcJYQ== dependencies: "@babel/helper-string-parser" "^7.18.10" "@babel/helper-validator-identifier" "^7.18.6" @@ -1274,13 +1274,13 @@ integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== "@eslint/eslintrc@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.0.tgz#29f92c30bb3e771e4a2048c95fa6855392dfac4f" - integrity sha512-UWW0TMTmk2d7hLcWD1/e2g5HDM/HQ3csaLSqXCfqwh4uNDuNqlaKWXmEsL4Cs41Z0KnILNvwbHAah3C2yt06kw== + version "1.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.1.tgz#de0807bfeffc37b964a7d0400e0c348ce5a2543d" + integrity sha512-OhSY22oQQdw3zgPOOwdoj01l/Dzl1Z+xyUP33tkSN+aqyEhymJCcPHyXt+ylW8FSe0TfRC2VG+ROQOapD0aZSQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.3.2" + espree "^9.4.0" globals "^13.15.0" ignore "^5.2.0" import-fresh "^3.2.1" @@ -1394,6 +1394,13 @@ "@types/node" "*" jest-mock "^27.5.1" +"@jest/expect-utils@^29.0.1": + version "29.0.1" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.0.1.tgz#c1a84ee66caaef537f351dd82f7c63d559cf78d5" + integrity sha512-Tw5kUUOKmXGQDmQ9TSgTraFFS7HMC1HG/B7y0AN2G2UzjdAXz9BzK2rmNpCSDl7g7y0Gf/VLBm//blonvhtOTQ== + dependencies: + jest-get-type "^29.0.0" + "@jest/fake-timers@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-27.5.1.tgz#76979745ce0579c8a94a4678af7a748eda8ada74" @@ -1453,6 +1460,13 @@ dependencies: "@sinclair/typebox" "^0.24.1" +"@jest/schemas@^29.0.0": + version "29.0.0" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.0.0.tgz#5f47f5994dd4ef067fb7b4188ceac45f77fe952a" + integrity sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA== + dependencies: + "@sinclair/typebox" "^0.24.1" + "@jest/source-map@^27.5.1": version "27.5.1" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-27.5.1.tgz#6608391e465add4205eae073b55e7f279e04e8cf" @@ -1546,6 +1560,18 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" +"@jest/types@^29.0.1": + version "29.0.1" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.0.1.tgz#1985650acf137bdb81710ff39a4689ec071dd86a" + integrity sha512-ft01rxzVsbh9qZPJ6EFgAIj3PT9FCRfBF9Xljo2/33VDOUjLZr0ZJ2oKANqh9S/K0/GERCsHDAQlBwj7RxA+9g== + dependencies: + "@jest/schemas" "^29.0.0" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -1607,6 +1633,21 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== +"@mapbox/node-pre-gyp@^1.0.0": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.9.tgz#09a8781a3a036151cdebbe8719d6f8b25d4058bc" + integrity sha512-aDF3S3rK9Q2gey/WAttUlISduDItz5BU3306M9Eyv6/oS40aMprnopshtlKTykxRNIBEZuRMaZAnbrQ4QtKGyw== + dependencies: + detect-libc "^2.0.0" + https-proxy-agent "^5.0.0" + make-dir "^3.1.0" + node-fetch "^2.6.7" + nopt "^5.0.0" + npmlog "^5.0.1" + rimraf "^3.0.2" + semver "^7.3.5" + tar "^6.1.11" + "@material-ui/core@4.9.4": version "4.9.4" resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.9.4.tgz#796515b12845dc6ea7e21872888cfc4c0c1c1efe" @@ -1760,9 +1801,9 @@ set-cookie-parser "^2.4.6" "@mswjs/interceptors@^0.17.2": - version "0.17.3" - resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.17.3.tgz#9272545332c0b16ac9cae2d97bf96d3853e14969" - integrity sha512-jBRFPeHBPqKv3od8KPjmrvt4b/+e1DorizFDYJ8NQCrjFT9YGnxA8ojGi0MIo64x/JgdjYkhP8bG9EY4BGPoqg== + version "0.17.4" + resolved "https://registry.yarnpkg.com/@mswjs/interceptors/-/interceptors-0.17.4.tgz#815f1519ab7642b826d6837eb3aa0899ea8fe070" + integrity sha512-8oKWrOQ1P0Wj0kf3ak8WETuymknw2Tl2s1op8OzZctpNF3zSJSdEbcQs0pm347mwsM+95V6POBMv3W4812pEeg== dependencies: "@open-draft/until" "^1.0.3" "@types/debug" "^4.1.7" @@ -1820,10 +1861,10 @@ resolved "https://registry.yarnpkg.com/@open-draft/until/-/until-1.0.3.tgz#db9cc719191a62e7d9200f6e7bab21c5b848adca" integrity sha512-Aq58f5HiWdyDlFffbbSjAlv596h/cOnt2DO1w3DOC7OJ5EHs0hd/nycJfiu9RJbT6Yk6F1knnRRXNSpxoIVZ9Q== -"@pkgr/utils@^2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.3.0.tgz#3b8491f112a80839450498816767eb03b7db6139" - integrity sha512-7dIJ9CRVzBnqyEl7diUHPUFJf/oty2SeoVzcMocc5PeOUDK9KGzvgIBjGRRzzlRDaOjh3ADwH0WeibQvi3ls2Q== +"@pkgr/utils@^2.3.1": + version "2.3.1" + resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.3.1.tgz#0a9b06ffddee364d6642b3cd562ca76f55b34a03" + integrity sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw== dependencies: cross-spawn "^7.0.3" is-glob "^4.0.3" @@ -1866,9 +1907,9 @@ integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== "@sinclair/typebox@^0.24.1": - version "0.24.28" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.28.tgz#15aa0b416f82c268b1573ab653e4413c965fe794" - integrity sha512-dgJd3HLOkLmz4Bw50eZx/zJwtBq65nms3N9VBYu5LTjJ883oBFkTyXRlCB/ZGGwqYpJJHA5zW2Ibhl5ngITfow== + version "0.24.34" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.34.tgz#35b799cf98a203d1940c8ce06688f9a09fbc0f50" + integrity sha512-x3ejWKw7rpy30Bvm6U0AQMOHdjqe2E3YJrBHlTxH0KFsp77bBa+MH324nJxtXZFpnTy/JW2h5HPYVm0vG2WPnw== "@sinonjs/commons@^1.7.0": version "1.8.3" @@ -2965,9 +3006,9 @@ "@babel/types" "^7.0.0" "@types/babel__traverse@*", "@types/babel__traverse@^7.0.4", "@types/babel__traverse@^7.0.6": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.18.0.tgz#8134fd78cb39567465be65b9fdc16d378095f41f" - integrity sha512-v4Vwdko+pgymgS+A2UIaJru93zQd85vIGWObM5ekZNdXCKtDYqATlEYnWgfo86Q6I1Lh0oXnksDnMU1cwmlPDw== + version "7.18.1" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.18.1.tgz#ce5e2c8c272b99b7a9fd69fa39f0b4cd85028bd9" + integrity sha512-FSdLaZh2UxaMuLp9lixWaHq/golWTRWOnRsAXzDTDSDOQLuZb1nsdCt6pJSPWSEQt2eFZ2YVk3oYhn+1kLMeMA== dependencies: "@babel/types" "^7.3.0" @@ -3039,9 +3080,9 @@ "@types/estree" "*" "@types/eslint@*": - version "8.4.5" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.4.5.tgz#acdfb7dd36b91cc5d812d7c093811a8f3d9b31e4" - integrity sha512-dhsC09y1gpJWnK+Ff4SGvCuSnk9DaU0BJZSzOwa6GVSg65XtTugLBITDAAzRU5duGBoXBHpdR/9jHGxJjNflJQ== + version "8.4.6" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.4.6.tgz#7976f054c1bccfcf514bff0564c0c41df5c08207" + integrity sha512-/fqTbjxyFUaYNO7VcW5g+4npmqVACz1bB7RTHYuLj+PRjw9hrCwrUXVQFpChUS0JsyEFvMZ7U/PfmvWgxJhI9g== dependencies: "@types/estree" "*" "@types/json-schema" "*" @@ -3075,7 +3116,15 @@ "@types/qs" "*" "@types/serve-static" "*" -"@types/glob@*", "@types/glob@^7.1.1": +"@types/glob@*": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@types/glob/-/glob-8.0.0.tgz#321607e9cbaec54f687a0792b2d1d370739455d2" + integrity sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA== + dependencies: + "@types/minimatch" "*" + "@types/node" "*" + +"@types/glob@^7.1.1": version "7.2.0" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.2.0.tgz#bc1b5bf3aa92f25bd5dd39f35c57361bdce5b2eb" integrity sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA== @@ -3139,12 +3188,12 @@ "@types/istanbul-lib-report" "*" "@types/jest@*": - version "28.1.6" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-28.1.6.tgz#d6a9cdd38967d2d746861fb5be6b120e38284dd4" - integrity sha512-0RbGAFMfcBJKOmqRazM8L98uokwuwD5F8rHrv/ZMbrZBwVOWZUyPG6VFNscjYr/vjM3Vu4fRrCPbOs42AfemaQ== + version "29.0.0" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.0.0.tgz#bc66835bf6b09d6a47e22c21d7f5b82692e60e72" + integrity sha512-X6Zjz3WO4cT39Gkl0lZ2baFRaEMqJl5NC1OjElkwtNzAlbkr2K/WJXkBkH5VP0zx4Hgsd2TZYdOEfvp2Dxia+Q== dependencies: - jest-matcher-utils "^28.0.0" - pretty-format "^28.0.0" + expect "^29.0.0" + pretty-format "^29.0.0" "@types/jest@27.4.1": version "27.4.1" @@ -3170,9 +3219,9 @@ integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/lodash@^4.14.175": - version "4.14.182" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2" - integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q== + version "4.14.184" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.184.tgz#23f96cd2a21a28e106dc24d825d4aa966de7a9fe" + integrity sha512-RoZphVtHbxPZizt4IcILciSWiC6dcn+eZ8oX9IWEYfDMcocdd42f7NPI6fQj+6zI8y4E0L7gu2pcZKLGTRaV9Q== "@types/mdast@^3.0.0": version "3.0.10" @@ -3192,9 +3241,9 @@ integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== "@types/minimatch@*": - version "3.0.5" - resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" - integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== + version "5.1.2" + resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" + integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== "@types/ms@*": version "0.7.31" @@ -3210,9 +3259,9 @@ form-data "^3.0.0" "@types/node@*": - version "18.7.4" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.4.tgz#95baa50846ae112a7376869d49fec23b2506c69d" - integrity sha512-RzRcw8c0B8LzryWOR4Wj7YOTFXvdYKwvrb6xQQyuDfnlTxwYXGCV5RZ/TEbq5L5kn+w3rliHAUyRcG1RtbmTFg== + version "18.7.14" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.14.tgz#0fe081752a3333392d00586d815485a17c2cf3c9" + integrity sha512-6bbDaETVi8oyIARulOE9qF1/Qdi/23z6emrUh0fNJRUmjznqrixD4MpGDdgOFk5Xb0m2H6Xu42JGdvAxaJR/wA== "@types/node@14.18.22": version "14.18.22" @@ -3220,9 +3269,9 @@ integrity sha512-qzaYbXVzin6EPjghf/hTdIbnVW1ErMx8rPzwRNJhlbyJhu2SyqlvjGOY/tbUt6VFyzg56lROcOeSQRInpt63Yw== "@types/node@^14.0.10": - version "14.18.23" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.23.tgz#70f5f20b0b1b38f696848c1d3647bb95694e615e" - integrity sha512-MhbCWN18R4GhO8ewQWAFK4TGQdBpXWByukz7cWyJmXhvRuCIaM/oWytGPqVmDzgEnnaIc9ss6HbU5mUi+vyZPA== + version "14.18.26" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.26.tgz#239e19f8b4ea1a9eb710528061c1d733dc561996" + integrity sha512-0b+utRBSYj8L7XAp0d+DX7lI4cSmowNaaTkk6/1SKzbKkG+doLuPusB9EOvzLJ8ahJSk03bTLIL6cWaEd4dBKA== "@types/normalize-package-data@^2.4.0": version "2.4.1" @@ -3303,9 +3352,9 @@ "@types/react" "*" "@types/react@*": - version "18.0.17" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.17.tgz#4583d9c322d67efe4b39a935d223edcc7050ccf4" - integrity sha512-38ETy4tL+rn4uQQi7mB81G7V1g0u2ryquNmsVIOKUAEIDK+3CUjZ6rSRpdvS99dNBnkLFL83qfmtLacGOTIhwQ== + version "18.0.18" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.18.tgz#9f16f33d57bc5d9dca848d12c3572110ff9429ac" + integrity sha512-6hI08umYs6NaiHFEEGioXnxJ+oEhY3eRz8VCUaudZmGdtvPviCJB8mgaMxaDWAdPSYd4eFavrPk2QIolwbLYrg== dependencies: "@types/prop-types" "*" "@types/scheduler" "*" @@ -3395,9 +3444,9 @@ "@types/jest" "*" "@types/uglify-js@*": - version "3.16.0" - resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.16.0.tgz#2cf74a0e6ebb6cd54c0d48e509d5bd91160a9602" - integrity sha512-0yeUr92L3r0GLRnBOvtYK1v2SjqMIqQDHMl7GLb+l2L8+6LSFWEEWEIgVsPdMn5ImLM8qzWT8xFPtQYpp8co0g== + version "3.17.0" + resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.17.0.tgz#95271e7abe0bf7094c60284f76ee43232aef43b9" + integrity sha512-3HO6rm0y+/cqvOyA8xcYLweF0TKXlAxmQASjbOi49Co51A1N4nR4bEwBgRoD9kNM+rqFGArjKr654SLp2CoGmQ== dependencies: source-map "^0.6.1" @@ -3412,9 +3461,9 @@ integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== "@types/webpack-env@^1.16.0", "@types/webpack-env@^1.17.0": - version "1.17.0" - resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.17.0.tgz#f99ce359f1bfd87da90cc4a57cab0a18f34a48d0" - integrity sha512-eHSaNYEyxRA5IAG0Ym/yCyf86niZUIF/TpWKofQI/CVfh5HsMEUyfE2kwFxha4ow0s5g0LfISQxpDKjbRDrizw== + version "1.18.0" + resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.18.0.tgz#ed6ecaa8e5ed5dfe8b2b3d00181702c9925f13fb" + integrity sha512-56/MAlX5WMsPVbOg7tAxnYvNYMMWr/QJiIp6BxVSW3JJXUVzzOn64qW8TzQyMSqSUFM2+PVI4aUHcHOzIz/1tg== "@types/webpack-sources@*": version "3.2.0" @@ -3463,6 +3512,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^17.0.8": + version "17.0.12" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.12.tgz#0745ff3e4872b4ace98616d4b7e37ccbd75f9526" + integrity sha512-Nz4MPhecOFArtm81gFQvQqdV7XYCrWKx5uUt6GNHredFHn1i2mtWqXTON7EPXMtNi1qjtjEM/VCHDhcHsAMLXQ== + dependencies: + "@types/yargs-parser" "*" + "@typescript-eslint/eslint-plugin@5.31.0": version "5.31.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.31.0.tgz#cae1967b1e569e6171bbc6bec2afa4e0c8efccfe" @@ -3496,13 +3552,13 @@ "@typescript-eslint/types" "5.31.0" "@typescript-eslint/visitor-keys" "5.31.0" -"@typescript-eslint/scope-manager@5.33.0": - version "5.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.33.0.tgz#509d7fa540a2c58f66bdcfcf278a3fa79002e18d" - integrity sha512-/Jta8yMNpXYpRDl8EwF/M8It2A9sFJTubDo0ATZefGXmOqlaBffEw0ZbkbQ7TNDK6q55NPHFshGBPAZvZkE8Pw== +"@typescript-eslint/scope-manager@5.36.1": + version "5.36.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.1.tgz#23c49b7ddbcffbe09082e6694c2524950766513f" + integrity sha512-pGC2SH3/tXdu9IH3ItoqciD3f3RRGCh7hb9zPdN2Drsr341zgd6VbhP5OHQO/reUqihNltfPpMpTNihFMarP2w== dependencies: - "@typescript-eslint/types" "5.33.0" - "@typescript-eslint/visitor-keys" "5.33.0" + "@typescript-eslint/types" "5.36.1" + "@typescript-eslint/visitor-keys" "5.36.1" "@typescript-eslint/type-utils@5.31.0": version "5.31.0" @@ -3518,10 +3574,10 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.31.0.tgz#7aa389122b64b18e473c1672fb3b8310e5f07a9a" integrity sha512-/f/rMaEseux+I4wmR6mfpM2wvtNZb1p9hAV77hWfuKc3pmaANp5dLAZSiE3/8oXTYTt3uV9KW5yZKJsMievp6g== -"@typescript-eslint/types@5.33.0": - version "5.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.33.0.tgz#d41c584831805554b063791338b0220b613a275b" - integrity sha512-nIMt96JngB4MYFYXpZ/3ZNU4GWPNdBbcB5w2rDOCpXOVUkhtNlG2mmm8uXhubhidRZdwMaMBap7Uk8SZMU/ppw== +"@typescript-eslint/types@5.36.1": + version "5.36.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.1.tgz#1cf0e28aed1cb3ee676917966eb23c2f8334ce2c" + integrity sha512-jd93ShpsIk1KgBTx9E+hCSEuLCUFwi9V/urhjOWnOaksGZFbTOxAT47OH2d4NLJnLhkVD+wDbB48BuaycZPLBg== "@typescript-eslint/typescript-estree@5.31.0": version "5.31.0" @@ -3536,13 +3592,13 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@5.33.0": - version "5.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.33.0.tgz#02d9c9ade6f4897c09e3508c27de53ad6bfa54cf" - integrity sha512-tqq3MRLlggkJKJUrzM6wltk8NckKyyorCSGMq4eVkyL5sDYzJJcMgZATqmF8fLdsWrW7OjjIZ1m9v81vKcaqwQ== +"@typescript-eslint/typescript-estree@5.36.1": + version "5.36.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.1.tgz#b857f38d6200f7f3f4c65cd0a5afd5ae723f2adb" + integrity sha512-ih7V52zvHdiX6WcPjsOdmADhYMDN15SylWRZrT2OMy80wzKbc79n8wFW0xpWpU0x3VpBz/oDgTm2xwDAnFTl+g== dependencies: - "@typescript-eslint/types" "5.33.0" - "@typescript-eslint/visitor-keys" "5.33.0" + "@typescript-eslint/types" "5.36.1" + "@typescript-eslint/visitor-keys" "5.36.1" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" @@ -3562,14 +3618,14 @@ eslint-utils "^3.0.0" "@typescript-eslint/utils@^5.10.0": - version "5.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.33.0.tgz#46797461ce3146e21c095d79518cc0f8ec574038" - integrity sha512-JxOAnXt9oZjXLIiXb5ZIcZXiwVHCkqZgof0O8KPgz7C7y0HS42gi75PdPlqh1Tf109M0fyUw45Ao6JLo7S5AHw== + version "5.36.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.1.tgz#136d5208cc7a3314b11c646957f8f0b5c01e07ad" + integrity sha512-lNj4FtTiXm5c+u0pUehozaUWhh7UYKnwryku0nxJlYUEWetyG92uw2pr+2Iy4M/u0ONMKzfrx7AsGBTCzORmIg== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.33.0" - "@typescript-eslint/types" "5.33.0" - "@typescript-eslint/typescript-estree" "5.33.0" + "@typescript-eslint/scope-manager" "5.36.1" + "@typescript-eslint/types" "5.36.1" + "@typescript-eslint/typescript-estree" "5.36.1" eslint-scope "^5.1.1" eslint-utils "^3.0.0" @@ -3581,12 +3637,12 @@ "@typescript-eslint/types" "5.31.0" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@5.33.0": - version "5.33.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.33.0.tgz#fbcbb074e460c11046e067bc3384b5d66b555484" - integrity sha512-/XsqCzD4t+Y9p5wd9HZiptuGKBlaZO5showwqODii5C0nZawxWLF+Q6k5wYHBrQv96h6GYKyqqMHCSTqta8Kiw== +"@typescript-eslint/visitor-keys@5.36.1": + version "5.36.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.1.tgz#7731175312d65738e501780f923896d200ad1615" + integrity sha512-ojB9aRyRFzVMN3b5joSYni6FAS10BBSCAfKJhjJAV08t/a95aM6tAhz+O1jF+EtgxktuSO3wJysp2R+Def/IWQ== dependencies: - "@typescript-eslint/types" "5.33.0" + "@typescript-eslint/types" "5.36.1" eslint-visitor-keys "^3.3.0" "@webassemblyjs/ast@1.11.1": @@ -3936,6 +3992,11 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== +abbrev@1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== + accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -4199,9 +4260,9 @@ aria-query@^4.2.2: "@babel/runtime-corejs3" "^7.10.2" aria-query@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c" - integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg== + version "5.0.2" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.2.tgz#0b8a744295271861e1d933f8feca13f9b70cfdc1" + integrity sha512-eigU3vhqSO+Z8BKDnVLN/ompjhf3pYzecKXz8+whRy+9gZu8n1TCGfwzQUUPnqdHl9ax1Hr9031orZ+UOEYr7Q== arr-diff@^4.0.0: version "4.0.0" @@ -4708,9 +4769,9 @@ body-parser@1.20.0: unpipe "1.0.0" bonjour-service@^1.0.11: - version "1.0.13" - resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.0.13.tgz#4ac003dc1626023252d58adf2946f57e5da450c1" - integrity sha512-LWKRU/7EqDUC9CTAQtuZl5HzBALoCYwtLhffW3et7vZMwv3bWLpJf8bRYlMD5OCcDpTfnPgNCV4yo9ZIaJGMiA== + version "1.0.14" + resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.0.14.tgz#c346f5bc84e87802d08f8d5a60b93f758e514ee7" + integrity sha512-HIMbgLnk1Vqvs6B4Wq5ep7mxvj9sGz5d1JJyDNSGNIdA/w2MCz6GTjWTdjqOJV1bEPj+6IkxDvWNFKEBxNt4kQ== dependencies: array-flatten "^2.1.2" dns-equal "^1.0.0" @@ -5046,9 +5107,18 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001304, caniuse-lite@^1.0.30001370: - version "1.0.30001376" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001376.tgz#af2450833e5a06873fbb030a9556ca9461a2736d" - integrity sha512-I27WhtOQ3X3v3it9gNs/oTpoE5KpwmqKR5oKPA8M0G7uMXh9Ty81Q904HpKUrM30ei7zfcL5jE7AXefgbOfMig== + version "1.0.30001387" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001387.tgz#90d2b9bdfcc3ab9a5b9addee00a25ef86c9e2e1e" + integrity sha512-fKDH0F1KOJvR+mWSOvhj8lVRr/Q/mc5u5nabU2vi1/sgvlSqEsE8dOq0Hy/BqVbDkCYQPRRHB1WRjW6PGB/7PA== + +canvas@^2.9.3: + version "2.9.3" + resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.9.3.tgz#8723c4f970442d4cdcedba5221579f9660a58bdb" + integrity sha512-WOUM7ghii5TV2rbhaZkh1youv/vW1/Canev6Yx6BG2W+1S07w8jKZqKkPnbiPpQEDsnJdN8ouDd7OvQEGXDcUw== + dependencies: + "@mapbox/node-pre-gyp" "^1.0.0" + nan "^2.15.0" + simple-get "^3.0.3" capture-exit@^2.0.0: version "2.0.0" @@ -5130,6 +5200,11 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +chart.js@^3.5.0: + version "3.9.1" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.9.1.tgz#3abf2c775169c4c71217a107163ac708515924b8" + integrity sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w== + chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -5547,22 +5622,22 @@ copy-webpack-plugin@10.2.4: serialize-javascript "^6.0.0" core-js-compat@^3.21.0, core-js-compat@^3.22.1, core-js-compat@^3.8.1: - version "3.24.1" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.24.1.tgz#d1af84a17e18dfdd401ee39da9996f9a7ba887de" - integrity sha512-XhdNAGeRnTpp8xbD+sR/HFDK9CbeeeqXT6TuofXh3urqEevzkWmLRgrVoykodsw8okqo2pu1BOmuCKrHx63zdw== + version "3.25.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.25.0.tgz#489affbfbf9cb3fa56192fe2dd9ebaee985a66c5" + integrity sha512-extKQM0g8/3GjFx9US12FAgx8KJawB7RCQ5y8ipYLbmfzEzmFRWdDjIlxDx82g7ygcNG85qMVUSRyABouELdow== dependencies: browserslist "^4.21.3" semver "7.0.0" core-js-pure@^3.20.2, core-js-pure@^3.8.1, core-js-pure@^3.8.2: - version "3.24.1" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.24.1.tgz#8839dde5da545521bf282feb7dc6d0b425f39fd3" - integrity sha512-r1nJk41QLLPyozHUUPmILCEMtMw24NG4oWK6RbsDdjzQgg9ZvrUsPBj1MnG0wXXp1DCDU6j+wUvEmBSrtRbLXg== + version "3.25.0" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.25.0.tgz#f8d1f176ff29abbfeb610110de891d5ae5a361d4" + integrity sha512-IeHpLwk3uoci37yoI2Laty59+YqH9x5uR65/yiA0ARAJrTrN4YU0rmauLWfvqOuk77SlNJXj2rM6oT/dBD87+A== core-js@^3.0.4, core-js@^3.16.2, core-js@^3.6.5, core-js@^3.8.2: - version "3.24.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.24.1.tgz#cf7724d41724154010a6576b7b57d94c5d66e64f" - integrity sha512-0QTBSYSUZ6Gq21utGzkfITDylE8jWC9Ne1D2MrhvlsZBI1x39OdDIVbzSqtgMndIy6BlHxBXpMGqzZmnztg2rg== + version "3.25.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.25.0.tgz#be71d9e0dd648ffd70c44a7ec2319d039357eceb" + integrity sha512-CVU1xvJEfJGhyCpBrzzzU1kjCfgsGUxhEvwUV2e/cOedYWHdmluamx+knDnmhqALddMG16fZvIqvs9aijsHHaA== core-util-is@~1.0.0: version "1.0.3" @@ -5961,6 +6036,13 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og== +decompress-response@^4.2.0: + version "4.2.1" + resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" + integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== + dependencies: + mimic-response "^2.0.0" + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -6080,6 +6162,11 @@ detab@2.0.4: dependencies: repeat-string "^1.5.4" +detect-libc@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" + integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w== + detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -6108,6 +6195,11 @@ diff-sequences@^28.1.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-28.1.1.tgz#9989dc731266dc2903457a70e996f3a041913ac6" integrity sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw== +diff-sequences@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.0.0.tgz#bae49972ef3933556bcb0800b72e8579d19d9e4f" + integrity sha512-7Qe/zd1wxSDL4D/X/FPjOMB+ZMDt71W94KYaq05I2l0oQqgXgs7s4ftYYmV38gBSrPz2vcygxfs1xn0FT+rKNA== + diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -6291,9 +6383,9 @@ ee-first@1.1.1: integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== electron-to-chromium@^1.4.202: - version "1.4.219" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.219.tgz#a7a672304b6aa4f376918d3f63a47f2c3906009a" - integrity sha512-zoQJsXOUw0ZA0YxbjkmzBumAJRtr6je5JySuL/bAoFs0DuLiLJ+5FzRF7/ZayihxR2QcewlRZVm5QZdUhwjOgA== + version "1.4.239" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.239.tgz#5b04acb39c16b897a508980d1be95ba5f0201771" + integrity sha512-XbhfzxPIFzMjJm17T7yUGZEyYh5XuUjrA/FQ7JUy2bEd4qQ7MvFTaKpZ6zXZog1cfVttESo2Lx0ctnf7eQOaAQ== element-resize-detector@^1.2.2: version "1.2.4" @@ -6747,10 +6839,10 @@ eslint@8.21.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^9.3.2, espree@^9.3.3: - version "9.3.3" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.3.tgz#2dd37c4162bb05f433ad3c1a52ddf8a49dc08e9d" - integrity sha512-ORs1Rt/uQTqUKjDdGCyrtYxbazf5umATSf/K4qxjmZHORR6HJk+2s/2Pqe+Kk49HHINC/xNIrGfgh8sZcll0ng== +espree@^9.3.3, espree@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a" + integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw== dependencies: acorn "^8.8.0" acorn-jsx "^5.3.2" @@ -6883,6 +6975,17 @@ expect@^27.5.1: jest-matcher-utils "^27.5.1" jest-message-util "^27.5.1" +expect@^29.0.0: + version "29.0.1" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.0.1.tgz#a2fa64a59cffe4b4007877e730bc82be3d1742bb" + integrity sha512-yQgemsjLU+1S8t2A7pXT3Sn/v5/37LY8J+tocWtKEA0iEYYc6gfKbbJJX2fxHZmd7K9WpdbQqXUpmYkq1aewYg== + dependencies: + "@jest/expect-utils" "^29.0.1" + jest-get-type "^29.0.0" + jest-matcher-utils "^29.0.1" + jest-message-util "^29.0.1" + jest-util "^29.0.1" + express@^4.17.1, express@^4.17.3: version "4.18.1" resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" @@ -7169,9 +7272,9 @@ flat-cache@^3.0.4: rimraf "^3.0.2" flatted@^3.1.0: - version "3.2.6" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.6.tgz#022e9218c637f9f3fc9c35ab9c9193f05add60b2" - integrity sha512-0sQoMh9s0BYsm+12Huy/rkKxVu4R1+r96YX5cG44rHV0pQ6iC3Q+mkoMFaGWObMFYQxCVT+ssG1ksneA2MI9KQ== + version "3.2.7" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" + integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== flush-write-stream@^1.0.0: version "1.1.1" @@ -8801,7 +8904,7 @@ jest-diff@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-diff@^28.0.2, jest-diff@^28.1.3: +jest-diff@^28.0.2: version "28.1.3" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-28.1.3.tgz#948a192d86f4e7a64c5264ad4da4877133d8792f" integrity sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw== @@ -8811,6 +8914,16 @@ jest-diff@^28.0.2, jest-diff@^28.1.3: jest-get-type "^28.0.2" pretty-format "^28.1.3" +jest-diff@^29.0.1: + version "29.0.1" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.0.1.tgz#d14e900a38ee4798d42feaaf0c61cb5b98e4c028" + integrity sha512-l8PYeq2VhcdxG9tl5cU78ClAlg/N7RtVSp0v3MlXURR0Y99i6eFnegmasOandyTmO6uEdo20+FByAjBFEO9nuw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.0.0" + jest-get-type "^29.0.0" + pretty-format "^29.0.1" + jest-docblock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-27.5.1.tgz#14092f364a42c6108d42c33c8cf30e058e25f6c0" @@ -8864,6 +8977,11 @@ jest-get-type@^28.0.2: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-28.0.2.tgz#34622e628e4fdcd793d46db8a242227901fcf203" integrity sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA== +jest-get-type@^29.0.0: + version "29.0.0" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.0.0.tgz#843f6c50a1b778f7325df1129a0fd7aa713aef80" + integrity sha512-83X19z/HuLKYXYHskZlBAShO7UfLFXu/vWajw9ZNJASN32li8yHMaVGAQqxFW1RCFOkB7cubaL6FaJVQqqJLSw== + jest-haste-map@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-26.6.2.tgz#dd7e60fe7dc0e9f911a23d79c5ff7fb5c2cafeaa" @@ -8956,15 +9074,15 @@ jest-matcher-utils@^27.0.0, jest-matcher-utils@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" -jest-matcher-utils@^28.0.0: - version "28.1.3" - resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-28.1.3.tgz#5a77f1c129dd5ba3b4d7fc20728806c78893146e" - integrity sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw== +jest-matcher-utils@^29.0.1: + version "29.0.1" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.0.1.tgz#eaa92dd5405c2df9d31d45ec4486361d219de3e9" + integrity sha512-/e6UbCDmprRQFnl7+uBKqn4G22c/OmwriE5KCMVqxhElKCQUDcFnq5XM9iJeKtzy4DUjxT27y9VHmKPD8BQPaw== dependencies: chalk "^4.0.0" - jest-diff "^28.1.3" - jest-get-type "^28.0.2" - pretty-format "^28.1.3" + jest-diff "^29.0.1" + jest-get-type "^29.0.0" + pretty-format "^29.0.1" jest-message-util@^27.5.1: version "27.5.1" @@ -8981,6 +9099,21 @@ jest-message-util@^27.5.1: slash "^3.0.0" stack-utils "^2.0.3" +jest-message-util@^29.0.1: + version "29.0.1" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.0.1.tgz#85c4b5b90296c228da158e168eaa5b079f2ab879" + integrity sha512-wRMAQt3HrLpxSubdnzOo68QoTfQ+NLXFzU0Heb18ZUzO2S9GgaXNEdQ4rpd0fI9dq2NXkpCk1IUWSqzYKji64A== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.0.1" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.0.1" + slash "^3.0.0" + stack-utils "^2.0.3" + jest-mock@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-27.5.1.tgz#19948336d49ef4d9c52021d34ac7b5f36ff967d6" @@ -9162,6 +9295,18 @@ jest-util@^27.0.0, jest-util@^27.5.1: graceful-fs "^4.2.9" picomatch "^2.2.3" +jest-util@^29.0.1: + version "29.0.1" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.0.1.tgz#f854a4a8877c7817316c4afbc2a851ceb2e71598" + integrity sha512-GIWkgNfkeA9d84rORDHPGGTFBrRD13A38QVSKE0bVrGSnoR1KDn8Kqz+0yI5kezMgbT/7zrWaruWP1Kbghlb2A== + dependencies: + "@jest/types" "^29.0.1" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + jest-validate@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-27.5.1.tgz#9197d54dc0bdb52260b8db40b46ae668e04df067" @@ -9432,10 +9577,10 @@ junk@^3.1.0: resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1" integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ== -just-debounce-it@3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/just-debounce-it/-/just-debounce-it-3.1.1.tgz#aa07c395d48c394233e4bafdcc49ed188fcf62a5" - integrity sha512-oPsuRyWp99LJaQ4KXC3A42tQNqkRTcPy0A8BCkRZ5cPCgsx81upB2KUrmHZvDUNhnCDKe7MshfTuWFQB9iXwDg== +just-debounce-it@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/just-debounce-it/-/just-debounce-it-3.0.1.tgz#8c8a4c9327c9523366ec79ac9a959a938153bd2f" + integrity sha512-6EQWOpRV8fm/ame6XvGBSxvsjoMbqj7JS9TV/4Q9aOXt9DQw22GBfTGP6gTAqcBNN/PbzlwtwH7jtM0k9oe9pg== kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: version "3.2.2" @@ -9792,9 +9937,9 @@ mdast-util-to-hast@10.0.1: unist-util-visit "^2.0.0" mdast-util-to-hast@^12.1.0: - version "12.2.0" - resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-12.2.0.tgz#4dbff7ab2b20b8d12fc8fe98bf804d97e7358cbf" - integrity sha512-YDwT5KhGzLgPpSnQhAlK1+WpCW4gsPmNNAxUNMkMTDhxQyPp2eX86WOelnKnLKEvSpfxqJbPbInHFkefXZBhEA== + version "12.2.1" + resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-12.2.1.tgz#5bba5e8234abcf66ae474cace5d0372c0dc4bfd7" + integrity sha512-dyindR2P7qOqXO1hQirZeGtVbiX7xlNQbw7gGaAwN4A1dh4+X8xU/JyYmRoyB8Fu1uPXzp7mlL5QwW7k+knvgA== dependencies: "@types/hast" "^2.0.0" "@types/mdast" "^3.0.0" @@ -10145,6 +10290,11 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +mimic-response@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" + integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== + min-document@^2.19.0: version "2.19.0" resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" @@ -10350,7 +10500,7 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nan@^2.12.1: +nan@^2.12.1, nan@^2.15.0: version "2.16.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.16.0.tgz#664f43e45460fb98faf00edca0bb0d7b8dce7916" integrity sha512-UdAqHyFngu7TfQKsCBgAA6pWDkT8MAO7d0jyOecVhN5354xbLqdn8mV9Tat9gepAupm0bt2DbeaSC8vS52MuFA== @@ -10473,6 +10623,13 @@ node-releases@^2.0.6: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== +nopt@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" + integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== + dependencies: + abbrev "1" + normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" @@ -10578,9 +10735,9 @@ object-visit@^1.0.0: isobject "^3.0.0" object.assign@^4.1.0, object.assign@^4.1.2, object.assign@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.3.tgz#d36b7700ddf0019abb6b1df1bb13f6445f79051f" - integrity sha512-ZFJnX3zltyjcYJL0RoCJuzb+11zWGyaDbjgxZbdV7rFEcHQuYxrZqhow67aA7xpes6LhojyFDaBKAFfogQrikA== + version "4.1.4" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f" + integrity sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ== dependencies: call-bind "^1.0.2" define-properties "^1.1.4" @@ -11450,7 +11607,7 @@ pretty-format@^27.0.0, pretty-format@^27.0.2, pretty-format@^27.5.1: ansi-styles "^5.0.0" react-is "^17.0.1" -pretty-format@^28.0.0, pretty-format@^28.1.3: +pretty-format@^28.1.3: version "28.1.3" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-28.1.3.tgz#c9fba8cedf99ce50963a11b27d982a9ae90970d5" integrity sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q== @@ -11460,15 +11617,24 @@ pretty-format@^28.0.0, pretty-format@^28.1.3: ansi-styles "^5.0.0" react-is "^18.0.0" +pretty-format@^29.0.0, pretty-format@^29.0.1: + version "29.0.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.0.1.tgz#2f8077114cdac92a59b464292972a106410c7ad0" + integrity sha512-iTHy3QZMzuL484mSTYbQIM1AHhEQsH8mXWS2/vd2yFBYnG3EBqGiMONo28PlPgrW7P/8s/1ISv+y7WH306l8cw== + dependencies: + "@jest/schemas" "^29.0.0" + ansi-styles "^5.0.0" + react-is "^18.0.0" + pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A== prismjs@^1.21.0, prismjs@^1.27.0: - version "1.28.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.28.0.tgz#0d8f561fa0f7cf6ebca901747828b149147044b6" - integrity sha512-8aaXdYvl1F7iC7Xm1spqSaY/OJBpYW3v+KJ+F17iYxvdc8sfjW194COK5wVhMZX45tGteiBQgdvD/nhxcRwylw== + version "1.29.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" + integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== prismjs@~1.27.0: version "1.27.0" @@ -11639,6 +11805,11 @@ querystring@0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g== +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -11687,10 +11858,15 @@ raw-loader@^4.0.2: loader-utils "^2.0.0" schema-utils "^3.0.0" +react-chartjs-2@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-4.3.1.tgz#9941e7397fb963f28bb557addb401e9ff96c6681" + integrity sha512-5i3mjP6tU7QSn0jvb8I4hudTzHJqS8l00ORJnVwI2sYu0ihpj83Lv2YzfxunfxTZkscKvZu2F2w9LkwNBhj6xA== + react-colorful@^5.1.2: - version "5.6.0" - resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.0.tgz#1394165de401265d36a809a7ac87c910fad36837" - integrity sha512-2/sW7msvdPWYc6uKFteTOztlX8ujoKImv6k2TVSlqbGNbR3bsQMfTyHcca+kk8dDUe/bsfVkI3M2WOl1bKL+Lg== + version "5.6.1" + resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.6.1.tgz#7dc2aed2d7c72fac89694e834d179e32f3da563b" + integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== react-docgen-typescript@^2.0.0: version "2.2.2" @@ -11860,7 +12036,7 @@ react-refresh@^0.11.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.11.0.tgz#77198b944733f0f1f1a90e791de4541f9f074046" integrity sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A== -react-router-dom@6.3.0, react-router-dom@^6.0.0: +react-router-dom@^6.0.0, react-router-dom@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== @@ -12228,6 +12404,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== +resize-observer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/resize-observer/-/resize-observer-1.0.4.tgz#48beb64602ce408ebd1a433784d64ef76f38d321" + integrity sha512-AQ2MdkWTng9d6JtjHvljiQR949qdae91pjSNugGGeOFzKIuLHvoZIYhUTjePla5hCFDwQHrnkciAIzjzdsTZew== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" @@ -12666,6 +12847,20 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^3.0.3: + version "3.1.1" + resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.1.tgz#cc7ba77cfbe761036fbfce3d021af25fc5584d55" + integrity sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA== + dependencies: + decompress-response "^4.2.0" + once "^1.3.1" + simple-concat "^1.0.0" + sirv@^1.0.7: version "1.0.19" resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49" @@ -12835,9 +13030,9 @@ spdx-expression-parse@^3.0.0: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.11" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz#50c0d8c40a14ec1bf449bae69a0ea4685a9d9f95" - integrity sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g== + version "3.0.12" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.12.tgz#69077835abe2710b65f03969898b6637b505a779" + integrity sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA== spdy-transport@^3.0.0: version "3.0.0" @@ -13202,16 +13397,16 @@ symbol.prototype.description@^1.0.0: object.getownpropertydescriptors "^2.1.2" synchronous-promise@^2.0.15: - version "2.0.15" - resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.15.tgz#07ca1822b9de0001f5ff73595f3d08c4f720eb8e" - integrity sha512-k8uzYIkIVwmT+TcglpdN50pS2y1BDcUnBPK9iJeGu0Pl1lOI8pD6wtzgw91Pjpe+RxtTncw32tLxs/R0yNL2Mg== + version "2.0.16" + resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.16.tgz#669b75e86b4295fdcc1bb0498de9ac1af6fd51a9" + integrity sha512-qImOD23aDfnIDNqlG1NOehdB9IYsn1V9oByPjKY1nakv2MQYCEMyX033/q+aEtYCpmYK1cv2+NTmlH+ra6GA5A== synckit@^0.8.1: - version "0.8.3" - resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.3.tgz#f36ca23fb7cbcf2b2b78c9e553ce6764dc6aa415" - integrity sha512-1goXnDYNJlKwCM37f5MTzRwo+8SqutgVtg2d37D6YnHHT4E3IhQMRfKiGdfTZU7LBlI6T8inCQUxnMBFHrbqWw== + version "0.8.4" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.4.tgz#0e6b392b73fafdafcde56692e3352500261d64ec" + integrity sha512-Dn2ZkzMdSX827QbowGbU/4yjWuvNaCoScLLoMo/yKbu+P4GBR6cRGKZH27k6a9bRzdqcyd1DE96pQtQ6uNkmyw== dependencies: - "@pkgr/utils" "^2.3.0" + "@pkgr/utils" "^2.3.1" tslib "^2.4.0" tapable@^1.0.0, tapable@^1.1.3: @@ -13224,7 +13419,7 @@ tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== -tar@^6.0.2: +tar@^6.0.2, tar@^6.1.11: version "6.1.11" resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621" integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA== @@ -13303,9 +13498,9 @@ terser-webpack-plugin@^4.2.3: webpack-sources "^1.4.3" terser-webpack-plugin@^5.1.3: - version "5.3.4" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.4.tgz#f4d31e265883d20fda3ca9c0fc6a53f173ae62e3" - integrity sha512-SmnkUhBxLDcBfTIeaq+ZqJXLVEyXxSaNcCeSezECdKjfkMrTTnPvapBILylYwyEvHFZAn2cJ8dtiXel5XnfOfQ== + version "5.3.6" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz#5590aec31aa3c6f771ce1b1acca60639eab3195c" + integrity sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ== dependencies: "@jridgewell/trace-mapping" "^0.3.14" jest-worker "^27.4.5" @@ -13323,9 +13518,9 @@ terser@^4.1.2, terser@^4.6.3: source-map-support "~0.5.12" terser@^5.10.0, terser@^5.14.1, terser@^5.3.4: - version "5.14.2" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.2.tgz#9ac9f22b06994d736174f4091aa368db896f1c10" - integrity sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA== + version "5.15.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.0.tgz#e16967894eeba6e1091509ec83f0c60e179f2425" + integrity sha512-L1BJiXVmheAQQy+as0oF3Pwtlo4s3Wi1X2zNZ2NxOB4wx9bdS9Vk67XQENLFdLYGCK/Z2di53mTj/hBafR+dTA== dependencies: "@jridgewell/source-map" "^0.3.2" acorn "^8.5.0" @@ -13474,13 +13669,14 @@ totalist@^1.0.0: integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== tough-cookie@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.0.0.tgz#d822234eeca882f991f0f908824ad2622ddbece4" - integrity sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg== + version "4.1.2" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.2.tgz#e53e84b85f24e0b65dd526f46628db6c85f6b874" + integrity sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ== dependencies: psl "^1.1.33" punycode "^2.1.1" - universalify "^0.1.2" + universalify "^0.2.0" + url-parse "^1.5.3" tr46@^2.1.0: version "2.1.0" @@ -13679,9 +13875,9 @@ tzdata@1.0.30: integrity sha512-/0yogZsIRUVhGIEGZahL+Nnl9gpMD6jtQ9MlVtPVofFwhaqa+cFTgRy1desTAKqdmIJjS6CL+i6F/mnetrLaxw== uglify-js@^3.1.4: - version "3.16.3" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.16.3.tgz#94c7a63337ee31227a18d03b8a3041c210fd1f1d" - integrity sha512-uVbFqx9vvLhQg0iBaau9Z75AxWJ8tqM9AV890dIZCLApF4rTcyHwmAvLeEdYRs+BzYWu8Iw81F79ah0EfTXbaw== + version "3.17.0" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.0.tgz#55bd6e9d19ce5eef0d5ad17cd1f587d85b180a85" + integrity sha512-aTeNPVmgIMPpm1cxXr2Q/nEbvkmV8yq66F3om7X3P/cvOXQ0TMQ64Wk63iyT1gPlmdmGzjGpyLh1f3y8MZWXGg== unbox-primitive@^1.0.2: version "1.0.2" @@ -13858,10 +14054,10 @@ unist-util-visit-parents@^3.0.0: "@types/unist" "^2.0.0" unist-util-is "^4.0.0" -unist-util-visit-parents@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.0.tgz#44bbc5d25f2411e7dfc5cecff12de43296aa8521" - integrity sha512-y+QVLcY5eR/YVpqDsLf/xh9R3Q2Y4HxkZTp7ViLDU6WtJCEcPmRzW1gpdWDCDIqIlhuPDXOgttqPlykrHYDekg== +unist-util-visit-parents@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.1.tgz#868f353e6fce6bf8fa875b251b0f4fec3be709bb" + integrity sha512-gks4baapT/kNRaWxuGkl5BIhoanZo7sC/cUT/JToSRNL1dYoXRFl75d++NkjYk4TAu2uv2Px+l8guMajogeuiw== dependencies: "@types/unist" "^2.0.0" unist-util-is "^5.0.0" @@ -13876,18 +14072,18 @@ unist-util-visit@2.0.3, unist-util-visit@^2.0.0: unist-util-visit-parents "^3.0.0" unist-util-visit@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.0.tgz#f41e407a9e94da31594e6b1c9811c51ab0b3d8f5" - integrity sha512-n7lyhFKJfVZ9MnKtqbsqkQEk5P1KShj0+//V7mAcoI6bpbUjh3C/OG8HVD+pBihfh6Ovl01m8dkcv9HNqYajmQ== + version "4.1.1" + resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-4.1.1.tgz#1c4842d70bd3df6cc545276f5164f933390a9aad" + integrity sha512-n9KN3WV9k4h1DxYR1LoajgN93wpEi/7ZplVe02IoB4gH5ctI1AaF2670BLHQYbwj+pY83gFtyeySFiyMHJklrg== dependencies: "@types/unist" "^2.0.0" unist-util-is "^5.0.0" - unist-util-visit-parents "^5.0.0" + unist-util-visit-parents "^5.1.1" -universalify@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" - integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== universalify@^2.0.0: version "2.0.0" @@ -13941,6 +14137,14 @@ url-loader@^4.1.1: mime-types "^2.1.27" schema-utils "^3.0.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" @@ -14294,10 +14498,10 @@ webpack-dev-middleware@^5.3.1: range-parser "^1.2.1" schema-utils "^4.0.0" -webpack-dev-server@4.10.1: - version "4.10.1" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.10.1.tgz#124ac9ac261e75303d74d95ab6712b4aec3e12ed" - integrity sha512-FIzMq3jbBarz3ld9l7rbM7m6Rj1lOsgq/DyLGMX/fPEB1UBUPtf5iL/4eNfhx8YYJTRlzfv107UfWSWcBK5Odw== +webpack-dev-server@4.9.3: + version "4.9.3" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.9.3.tgz#2360a5d6d532acb5410a668417ad549ee3b8a3c9" + integrity sha512-3qp/eoboZG5/6QgiZ3llN8TUzkSpYg1Ko9khWX1h40MIEUNS2mDoIa8aXsPfskER+GbTvs/IJZ1QTBBhhuetSw== dependencies: "@types/bonjour" "^3.5.9" "@types/connect-history-api-fallback" "^1.3.5" @@ -14616,9 +14820,9 @@ xstate@4.32.1: integrity sha512-QYUd+3GkXZ8i6qdixnOn28bL3EvA++LONYL/EMWwKlFSh/hiLndJ8YTnz77FDs+JUXcwU7NZJg7qoezoRHc4GQ== xstate@^4.29.0: - version "4.33.2" - resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.33.2.tgz#eb356f7c982fd1191603c7129c3cdc288d02909a" - integrity sha512-hIcoubJm6zuHyTPtE4tKp0Keb94Bs11ohQ1bBE3pwua5PuTDpAPut6gSTJeU/xI1akd1LsTrCvZTil6Kc4LqEA== + version "4.33.5" + resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.33.5.tgz#eafd193827b173901fac0d99e61bb4e5add6c7c8" + integrity sha512-C8WGBeQC+dNMp4MmQX359BUkJCv2VPAH/CGRnhtgri5JZ7wVEX7fsbfcqznAgnKyD0m9Hd3cGhg/wuzIjnfT4A== xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1: version "4.0.2" diff --git a/tailnet/conn.go b/tailnet/conn.go index 7f73d66c9f849..12ab2ad593a86 100644 --- a/tailnet/conn.go +++ b/tailnet/conn.go @@ -214,15 +214,16 @@ type Conn struct { closed chan struct{} logger slog.Logger - dialer *tsdial.Dialer - tunDevice *tstun.Wrapper - netMap *netmap.NetworkMap - netStack *netstack.Impl - magicConn *magicsock.Conn - wireguardMonitor *monitor.Mon - wireguardRouter *router.Config - wireguardEngine wgengine.Engine - listeners map[listenKey]*listener + dialer *tsdial.Dialer + tunDevice *tstun.Wrapper + netMap *netmap.NetworkMap + netStack *netstack.Impl + magicConn *magicsock.Conn + wireguardMonitor *monitor.Mon + wireguardRouter *router.Config + wireguardEngine wgengine.Engine + listeners map[listenKey]*listener + forwardTCPCallback func(conn net.Conn, listenerExists bool) net.Conn lastMutex sync.Mutex // It's only possible to store these values via status functions, @@ -232,6 +233,17 @@ type Conn struct { lastDERPLatency map[string]float64 } +// SetForwardTCPCallback is called every time a TCP connection is initiated inbound. +// listenerExists is true if a listener is registered for the target port. If there +// isn't one, traffic is forwarded to the local listening port. +// +// This allows wrapping a Conn to track reads and writes. +func (c *Conn) SetForwardTCPCallback(callback func(conn net.Conn, listenerExists bool) net.Conn) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.forwardTCPCallback = callback +} + // SetNodeCallback is triggered when a network change occurs and peer // renegotiation may be required. Clients should constantly be emitting // node changes. @@ -411,6 +423,9 @@ func (c *Conn) DialContextUDP(ctx context.Context, ipp netip.AddrPort) (*gonet.U func (c *Conn) forwardTCP(conn net.Conn, port uint16) { c.mutex.Lock() ln, ok := c.listeners[listenKey{"tcp", "", fmt.Sprint(port)}] + if c.forwardTCPCallback != nil { + conn = c.forwardTCPCallback(conn, ok) + } c.mutex.Unlock() if !ok { c.forwardTCPToLocal(conn, port) diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index fb57ccd13afbd..0000000000000 --- a/yarn.lock +++ /dev/null @@ -1,4 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - -